+
+
+ {/* Knob row */}
+
+ setGenre(GENRES[v] ?? "metal")}
+ format={(v) => GENRE_LABELS[GENRES[v] ?? "metal"] ?? ""}
+ />
+ setMood(MOODS[v] ?? "energetic")}
+ format={(v) => MOOD_LABELS[MOODS[v] ?? "energetic"] ?? ""}
+ />
+ `${v} BPM`}
+ editable
+ />
+
+
+ {/* Your-take-is knob — declares what instrument the user is playing
+ so the buddy can complement it (no MIR on input). */}
+
+
+ Your take is
+
+
+ For MIDI we can't read the notes, so tell us what you're
+ playing — it helps the buddy complement (not duplicate) it. Audio is
+ heard directly (audio-to-audio).
+
+
+ );
+}
diff --git a/apps/web/data/onomatopoeia.json b/apps/web/data/onomatopoeia.json
new file mode 100644
index 0000000000000000000000000000000000000000..80007c400f0368df8407ba74f6edd958cbc7b8bc
--- /dev/null
+++ b/apps/web/data/onomatopoeia.json
@@ -0,0 +1,39 @@
+{
+ "version": 1,
+ "entries": [
+ {
+ "id": "skank-beat",
+ "patterns": ["tupatupatupa", "tupa-tupa-tupa", "chka-chka-chka", "tupa tupa tupa"],
+ "patternId": "skank",
+ "defaultCymbal": { "type": "hihat-open", "pattern": "upstrokes" },
+ "confidence": 0.9,
+ "notes": "Ska-style offbeat hi-hat upstrokes. The canonical demo phrase."
+ },
+ {
+ "id": "blast-beat",
+ "patterns": ["krrk-krrk-krrk", "krrk krrk krrk", "BLAM-BLAM-BLAM", "krkrkrkrk"],
+ "patternId": "blast-traditional",
+ "defaultIntensity": "brutal",
+ "confidence": 0.85,
+ "notes": "Traditional blast beat. Kick-snare alternation."
+ },
+ {
+ "id": "boom-bap",
+ "patterns": ["boom-bap", "boom bap", "boom-bap-boom-bap"],
+ "patternId": "hiphop-basic",
+ "confidence": 0.95
+ },
+ {
+ "id": "china-accent",
+ "patterns": ["tss", "chka"],
+ "patternId": "china-accent",
+ "defaultCymbal": { "type": "china", "pattern": "wash" }
+ },
+ {
+ "id": "ride-bell",
+ "patterns": ["ding-ding-ding", "ding ding ding", "ding-ding-ding-ding"],
+ "patternId": "ride-bell-8ths",
+ "defaultCymbal": { "type": "ride", "pattern": "bell" }
+ }
+ ]
+}
diff --git a/apps/web/data/patterns/d-beat.json b/apps/web/data/patterns/d-beat.json
new file mode 100644
index 0000000000000000000000000000000000000000..157125d985454fdb51083685ab4f155e6ac6a4d1
--- /dev/null
+++ b/apps/web/data/patterns/d-beat.json
@@ -0,0 +1,28 @@
+{
+ "id": "d-beat",
+ "name": "D-Beat",
+ "description": "D-beat pattern at 180 BPM. Kick plays on every beat with 8th-note doubles, snare hits on 2 and 4, ride bell plays steady 8ths throughout. Common in Discharge, Entombed, and Scandinavian crust punk.",
+ "tags": ["metal", "punk", "discharge", "entombed", "crust", "fast"],
+ "genre": "metal",
+ "defaultTempo": 180,
+ "defaultBars": 1,
+ "swingRatio": 0,
+ "hits": [
+ { "position": 0.0, "limb": "kick", "velocity": 110 },
+ { "position": 0.125, "limb": "kick", "velocity": 80 },
+ { "position": 0.25, "limb": "snare", "velocity": 100 },
+ { "position": 0.375, "limb": "kick", "velocity": 80 },
+ { "position": 0.5, "limb": "kick", "velocity": 110 },
+ { "position": 0.625, "limb": "kick", "velocity": 80 },
+ { "position": 0.75, "limb": "snare", "velocity": 100 },
+ { "position": 0.875, "limb": "kick", "velocity": 80 },
+ { "position": 0.0, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.125, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.25, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.375, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.5, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.625, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.75, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.875, "limb": "ride-bell", "velocity": 70 }
+ ]
+}
diff --git a/apps/web/data/patterns/skank.json b/apps/web/data/patterns/skank.json
new file mode 100644
index 0000000000000000000000000000000000000000..959066c13aa7203f83077a76bfbeea6a70b6d9e5
--- /dev/null
+++ b/apps/web/data/patterns/skank.json
@@ -0,0 +1,20 @@
+{
+ "id": "skank",
+ "name": "Skank Beat",
+ "description": "Skank beat at 120 BPM. Kick on 1 and 3, snare on 2 and 4, hi-hat plays on every upbeat — the 'and' of each beat. Ska upstroke feel.",
+ "tags": ["ska", "punk", "upstrokes", "offbeat"],
+ "genre": "punk",
+ "defaultTempo": 120,
+ "defaultBars": 1,
+ "swingRatio": 0,
+ "hits": [
+ { "position": 0.0, "limb": "kick", "velocity": 100 },
+ { "position": 0.5, "limb": "kick", "velocity": 100 },
+ { "position": 0.25, "limb": "snare", "velocity": 100 },
+ { "position": 0.75, "limb": "snare", "velocity": 100 },
+ { "position": 0.125, "limb": "hihat", "velocity": 80 },
+ { "position": 0.375, "limb": "hihat", "velocity": 80 },
+ { "position": 0.625, "limb": "hihat", "velocity": 80 },
+ { "position": 0.875, "limb": "hihat", "velocity": 80 }
+ ]
+}
diff --git a/apps/web/lib/jambuddy/player.ts b/apps/web/lib/jambuddy/player.ts
new file mode 100644
index 0000000000000000000000000000000000000000..82f04bd6aee10b53dd0f3e9707a42bab87eb6842
--- /dev/null
+++ b/apps/web/lib/jambuddy/player.ts
@@ -0,0 +1,209 @@
+/**
+ * Jam Buddy playback.
+ *
+ * Plays the user's MIDI take and the buddy's generated response TOGETHER, in
+ * tempo, via the Web Audio API.
+ *
+ * - The MIDI take is rendered to audio with a lightweight synth (an oscillator
+ * per note) so it's audible as "your" part, distinct from the buddy.
+ * - The generated response is a decoded AudioBuffer (the buddy's WAV).
+ * - Both are scheduled on the SAME AudioContext clock starting together, so
+ * they stay in sync.
+ *
+ * This module is browser-only (window.AudioContext). It does not run under
+ * Node — keep all Web Audio in here and call from the page.
+ */
+
+import { Midi } from "@tonejs/midi";
+
+/** A parsed note: absolute start time (sec), midi note, velocity 0-1. */
+export interface ParsedNote {
+ time: number;
+ midi: number;
+ duration: number;
+ velocity: number;
+ /** GM channel 0-15; 9 = percussion. Used to route drums to a click. */
+ channel: number;
+}
+
+/** Parse a MIDI file's bytes into playable notes (+ duration). */
+export function parseMidi(bytes: ArrayBuffer): ParsedNote[] {
+ const midi = new Midi(bytes);
+ const notes: ParsedNote[] = [];
+ for (const track of midi.tracks) {
+ // tonejs exposes the channel on the Track (a plain number), not each Note.
+ const channel = track.channel ?? 0;
+ for (const note of track.notes) {
+ notes.push({
+ time: note.time,
+ midi: note.midi,
+ duration: note.duration,
+ velocity: note.velocity,
+ channel,
+ });
+ }
+ }
+ notes.sort((a, b) => a.time - b.time);
+ return notes;
+}
+
+/**
+ * Is this a percussion note (GM channel 9)? Drums render as a percussive
+ * click/noise burst so they're audible alongside the produced WAV, instead
+ * of a low thin oscillator that gets buried.
+ */
+export function isPercussion(note: ParsedNote): boolean {
+ return note.channel === 9;
+}
+
+/** midi note -> frequency in Hz. */
+export function midiToFreq(n: number): number {
+ return 440 * Math.pow(2, (n - 69) / 12);
+}
+
+/**
+ * Schedule a MIDI note as an audible source through a gain envelope.
+ * Percussion (GM channel 9) renders as a short noise-burst click so drum hits
+ * cut through the produced WAV; pitched notes render as a short oscillator.
+ */
+function scheduleNote(
+ ctx: AudioContext,
+ note: ParsedNote,
+ dest: AudioNode,
+ when: number,
+) {
+ const gain = ctx.createGain();
+ const dur = Math.max(0.05, note.duration);
+ const vel = Math.max(0.2, note.velocity); // floor so quiet notes stay audible
+
+ if (isPercussion(note)) {
+ // Short noise burst (kick/snare-ish) so drum hits are clearly heard.
+ const len = Math.max(0.05, Math.min(0.15, dur));
+ const buf = ctx.createBuffer(1, Math.ceil(len * ctx.sampleRate), ctx.sampleRate);
+ const data = buf.getChannelData(0);
+ for (let i = 0; i < data.length; i++) {
+ data[i] = (Math.random() * 2 - 1) * Math.exp(-(i / data.length) * 6);
+ }
+ const src = ctx.createBufferSource();
+ src.buffer = buf;
+ gain.gain.setValueAtTime(0, when);
+ gain.gain.linearRampToValueAtTime(vel * 0.7, when + 0.005);
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + len);
+ src.connect(gain);
+ gain.connect(dest);
+ src.start(when);
+ src.stop(when + len + 0.02);
+ return;
+ }
+
+ const osc = ctx.createOscillator();
+ osc.type = "triangle";
+ osc.frequency.value = midiToFreq(note.midi);
+
+ gain.gain.setValueAtTime(0, when);
+ gain.gain.linearRampToValueAtTime(vel * 0.5, when + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + dur);
+
+ osc.connect(gain);
+ gain.connect(dest);
+ osc.start(when);
+ osc.stop(when + dur + 0.02);
+}
+
+/**
+ * Play the MIDI take and the buddy WAV together.
+ *
+ * @param midiBytes the user's MIDI take (raw bytes)
+ * @param buddyAudioUrl the generated response WAV (object URL)
+ * @param when optional start offset in sec (default: immediately)
+ * @returns an object with stop(), and promise resolving when both finish.
+ */
+export async function playTogether(
+ midiBytes: ArrayBuffer,
+ buddyAudioUrl: string,
+): Promise<{ stop: () => void; done: Promise }> {
+ const ctx = new AudioContext();
+ await ctx.resume();
+
+ // Decode the buddy WAV into a buffer.
+ const resp = await fetch(buddyAudioUrl);
+ const wav = await resp.arrayBuffer();
+ const buddyBuffer = await ctx.decodeAudioData(wav);
+
+ const master = ctx.createGain();
+ master.gain.value = 0.8;
+ master.connect(ctx.destination);
+
+ const notes = parseMidi(midiBytes);
+ const startAt = ctx.currentTime + 0.1;
+
+ // Schedule the buddy at the same clock time.
+ const buddySrc = ctx.createBufferSource();
+ buddySrc.buffer = buddyBuffer;
+ buddySrc.connect(master);
+ buddySrc.start(startAt);
+
+ // Schedule the MIDI notes.
+ for (const note of notes) {
+ scheduleNote(ctx, note, master, startAt + note.time);
+ }
+
+ const buddyEnd = startAt + buddyBuffer.duration;
+ const lastMidiTime = notes.length ? (notes[notes.length - 1]?.time ?? 0) : 0;
+ const midiEnd = startAt + lastMidiTime + 1;
+ const end = Math.max(buddyEnd, midiEnd);
+
+ const done = new Promise((resolve) => {
+ setTimeout(() => {
+ try {
+ ctx.close();
+ } catch {
+ /* already closed */
+ }
+ resolve();
+ }, Math.max(0, (end - ctx.currentTime) * 1000) + 200);
+ });
+
+ return {
+ stop: () => {
+ try {
+ buddySrc.stop();
+ ctx.close();
+ } catch {
+ /* ignore */
+ }
+ },
+ done,
+ };
+}
+
+/** Get the detected tempo from a MIDI file (for display). */
+export function midiTempo(buf: ArrayBuffer): number {
+ try {
+ const midi = new Midi(buf);
+ const t = midi.header.tempos[0]?.bpm;
+ return t ? Math.round(t) : 120;
+ } catch {
+ return 120;
+ }
+}
+
+/**
+ * Get the total duration (seconds) of a MIDI file — the time of the last note
+ * end. This is what the generated response must match so the take and the
+ * buddy are the same length and stay in tempo together.
+ */
+export function midiDuration(buf: ArrayBuffer): number {
+ try {
+ const midi = new Midi(buf);
+ let end = 0;
+ for (const track of midi.tracks) {
+ for (const note of track.notes) {
+ end = Math.max(end, note.time + note.duration);
+ }
+ }
+ return end;
+ } catch {
+ return 30;
+ }
+}
diff --git a/apps/web/lib/jambuddy/prompt.ts b/apps/web/lib/jambuddy/prompt.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1149bf106cf381da320d65316dda00d481fe5070
--- /dev/null
+++ b/apps/web/lib/jambuddy/prompt.ts
@@ -0,0 +1,287 @@
+/**
+ * Jam Buddy prompt builder.
+ *
+ * Builds the SA3 text prompt from the user's "knobs", following the official
+ * Stable Audio 3 prompt guide (docs/guides/prompting.md → "Stem & Solo
+ * Instrument Prompting"):
+ *
+ * Key elements: TrackType: Instrument, Instrument/Stem, Genre, Mood & energy,
+ * BPM.
+ *
+ * The guide's recommended structure for an isolated instrument is:
+ * TrackType: Instrument, {instrument}, {genre}, {mood}, {BPM} BPM
+ *
+ * We also append "studio recording" (positive) and use "field recording" as the
+ * negative prompt — the training data is Freesound/AudioSparx (full of field
+ * recordings), so steering away from that improves quality.
+ *
+ * Pure function — no DOM, no I/O. Test this thoroughly.
+ */
+
+export type BuddyInstrument =
+ | "bass"
+ | "lead"
+ | "rhythm"
+ | "synth"
+ | "drums"
+ | "sax"
+ | "cleanguitar"
+ | "overdrivenguitar";
+
+/**
+ * What the user IS playing on their take. SA3 has no MIR — it cannot read the
+ * input MIDI/audio to know what's already there. The user declares it, which
+ * (a) gives the buddy context about what to complement, and (b) honestly
+ * acknowledges the model limit. "other" = default, no specific declaration.
+ */
+export type InputInstrument =
+ | "drums"
+ | "bass"
+ | "guitar"
+ | "keys"
+ | "vocals"
+ | "other";
+
+export type BuddyGenre =
+ | "metal"
+ | "rock"
+ | "punk"
+ | "hiphop"
+ | "edm"
+ | "jazz"
+ | "pop"
+ | "any";
+
+export type BuddyMood =
+ | "energetic"
+ | "chill"
+ | "dark"
+ | "bright"
+ | "aggressive"
+ | "melodic";
+
+/** Instrument → AudioSparx `Instruments:` tag fragment (the "knob" options). */
+export const INSTRUMENT_PROMPTS: Readonly> = {
+ bass: "Bass Guitar, a grooving bass line, tight and in the pocket",
+ lead: "Lead Guitar, a soaring melodic lead guitar riff",
+ rhythm: "Rhythm Guitar, tight palm-muted power chords",
+ synth: "Synth, a warm atmospheric pad",
+ drums: "Drums, a punchy drum groove, kick and snare locked in",
+ sax: "Saxophone, a warm breathy saxophone line with a rich tone",
+ cleanguitar: "Clean Guitar, bright chimey clean electric guitar arpeggios",
+ overdrivenguitar: "Overdriven Guitar, a gritty overdriven guitar riff with crunch",
+};
+
+/** Genre → AudioSparx `Genre:` tag. Matches the model's training vocab. */
+export const GENRE_PROMPTS: Readonly> = {
+ metal: "Heavy Metal",
+ rock: "Rock",
+ punk: "Punk",
+ hiphop: "Hip Hop",
+ edm: "Electronic Dance Music",
+ jazz: "Jazz",
+ pop: "Pop",
+ any: "",
+};
+
+/** Mood → AudioSparx `Moods:` tag. */
+export const MOOD_PROMPTS: Readonly> = {
+ energetic: "Energetic",
+ chill: "Relaxed",
+ dark: "Dark",
+ bright: "Uplifting",
+ aggressive: "Aggressive",
+ melodic: "Melodic",
+};
+
+/** Negative-prompt tokens always applied (full-mix steer). */
+const BASE_NEGATIVES: readonly string[] = [
+ "other instruments", "full band", "mixed ensemble", "vocals",
+ "singing", "chords", "crowd", "noise", "field recording",
+];
+
+/**
+ * SA3 model per instrument. Drums force `small-sfx` — it yields clean isolated
+ * drum hits, not full-mix texture (verified: sfx is sparse, ~1.2 hits/sec,
+ * clean single hits vs music's dense smear). Everything else uses `small-music`
+ * for musical phrases.
+ */
+export const MODEL_FOR_INSTRUMENT: Readonly> = {
+ bass: "small-music",
+ lead: "small-music",
+ rhythm: "small-music",
+ synth: "small-music",
+ drums: "small-sfx",
+ sax: "small-music",
+ cleanguitar: "small-music",
+ overdrivenguitar: "small-music",
+};
+
+export interface BuddyKnobs {
+ instrument: BuddyInstrument;
+ /** What the user is playing on the take. Helps SA3 complement, not duplicate. */
+ inputInstrument: InputInstrument;
+ genre: BuddyGenre;
+ mood: BuddyMood;
+ bpm: number;
+}
+
+export interface BuddyPrompt {
+ /** Positive prompt for SA3. */
+ prompt: string;
+ /** Negative prompt for SA3. */
+ negativePrompt: string;
+}
+
+/**
+ * Build the SA3 prompt from the knobs.
+ *
+ * Structure follows the official Stable Audio 3 prompt guide — the model is
+ * trained on Freesound/AudioSparx metadata, so prompts that use the AudioSparx
+ * tag vocabulary (`Genre:`, `Moods:`, `Instruments:`) adhere best:
+ *
+ * TrackType: Music, VocalType: Instrumental, Genre: {genre},
+ * Moods: {mood}, Instruments: {instrument}, {BPM} BPM, studio recording
+ *
+ * `TrackType: Music, VocalType: Instrumental` is the documented prefix for
+ * music generation (it's what the training metadata prepends) and materially
+ * improves quality/adherence vs. the old `TrackType: Instrument`.
+ */
+export function buildPrompt(knobs: BuddyKnobs): BuddyPrompt {
+ const parts: string[] = ["TrackType: Music, VocalType: Instrumental"];
+
+ const genre = GENRE_PROMPTS[knobs.genre];
+ if (genre) parts.push(`Genre: ${genre}`);
+
+ const mood = MOOD_PROMPTS[knobs.mood];
+ parts.push(`Moods: ${mood}`);
+
+ const instrument = INSTRUMENT_PROMPTS[knobs.instrument];
+ parts.push(`Instruments: ${instrument}`);
+
+ const bpm = Math.max(40, Math.min(240, Math.round(knobs.bpm)));
+ parts.push(`${bpm} BPM`, "studio recording");
+
+ // Build the negative prompt only — DO NOT add "avoid ..." to the positive
+ // prompt. SA3 is trained on AudioSparx tag vocab; free-form "avoid drums"
+ // would be out-of-vocab noise that dilutes the metadata header. The
+ // negative prompt is the correct place to steer away from the user's input
+ // instrument, and SA3 reads it as a hard constraint.
+ const negatives = [...BASE_NEGATIVES];
+ if (knobs.instrument !== "drums") negatives.push("percussion");
+ if (knobs.inputInstrument && knobs.inputInstrument !== "other") {
+ const ctx = INPUT_INSTRUMENT_PROMPTS[knobs.inputInstrument];
+ // Only negate the user's instrument when it's a DIFFERENT family from
+ // the buddy. e.g. buddy=lead (lead guitar) + input=guitar would steer
+ // SA3 away from ALL guitars including the buddy's. Compare nouns, not
+ // strings: drop the negation when the input noun overlaps the buddy's
+ // noun family.
+ if (ctx && !buddyInstrumentFamilyMatches(knobs.instrument, ctx)) {
+ negatives.push(ctx);
+ }
+ }
+ const negativePrompt = negatives.join(", ");
+
+ return {
+ prompt: parts.join(", "),
+ negativePrompt,
+ };
+}
+
+/**
+ * Does the buddy instrument's noun family overlap with the user's input
+ * noun? Used to avoid negating an instrument family the buddy is part of.
+ *
+ * e.g. buddy=lead ("lead guitar"), input=guitar ("guitar") — overlap, so we
+ * do NOT add "guitar" to the negative, since it would steer SA3 away from
+ * the buddy itself.
+ */
+function buddyInstrumentFamilyMatches(
+ instrument: BuddyInstrument,
+ inputNoun: string,
+): boolean {
+ const family: Readonly> = {
+ bass: "bass",
+ lead: "guitar",
+ rhythm: "guitar",
+ synth: "synth",
+ drums: "drums",
+ sax: "sax",
+ cleanguitar: "guitar",
+ overdrivenguitar: "guitar",
+ };
+ return family[instrument] === inputNoun;
+}
+
+/** The knob options, for rendering dropdowns. */
+export const INSTRUMENTS: readonly BuddyInstrument[] = [
+ "bass",
+ "lead",
+ "rhythm",
+ "synth",
+ "drums",
+ "sax",
+ "cleanguitar",
+ "overdrivenguitar",
+];
+/** Input-instrument vocab (what the user is playing on the take). */
+export const INPUT_INSTRUMENTS: readonly InputInstrument[] = [
+ "drums",
+ "bass",
+ "guitar",
+ "keys",
+ "vocals",
+ "other",
+];
+/** Display labels for the input-instrument knob (AudioSparx vocab where possible). */
+export const INPUT_INSTRUMENT_LABELS: Readonly> = {
+ drums: "Drums",
+ bass: "Bass",
+ guitar: "Guitar",
+ keys: "Keys / Synth",
+ vocals: "Vocals",
+ other: "Other / mixed",
+};
+/** Input-instrument → short AudioSparx-style noun for the prompt. */
+export const INPUT_INSTRUMENT_PROMPTS: Readonly> = {
+ drums: "drums",
+ bass: "bass",
+ guitar: "guitar",
+ keys: "keys",
+ vocals: "vocals",
+ other: "",
+};
+export const GENRES: readonly BuddyGenre[] = [
+ "metal",
+ "rock",
+ "punk",
+ "hiphop",
+ "edm",
+ "jazz",
+ "pop",
+ "any",
+];
+export const MOODS: readonly BuddyMood[] = [
+ "energetic",
+ "chill",
+ "dark",
+ "bright",
+ "aggressive",
+ "melodic",
+];
+/** Human-friendly knob labels. The enum stays lowercase for wire-format
+ * stability; the UI reads these for display. AudioSparx vocab where possible. */
+export const GENRE_LABELS: Readonly> = {
+ metal: "Heavy Metal", rock: "Rock", punk: "Punk Rock", hiphop: "Hip Hop",
+ edm: "Electronic", jazz: "Jazz", pop: "Pop", any: "Any",
+};
+export const MOOD_LABELS: Readonly> = {
+ energetic: "Energetic", chill: "Chill", dark: "Dark", bright: "Bright",
+ aggressive: "Aggressive", melodic: "Melodic",
+};
+/** Human-friendly labels for the buddy-instrument pads. */
+export const INSTRUMENT_LABELS: Readonly> = {
+ bass: "Bass", lead: "Lead Guitar", rhythm: "Rhythm Guitar", synth: "Synth",
+ drums: "Drums", sax: "Sax", cleanguitar: "Clean Guitar",
+ overdrivenguitar: "Overdriven Guitar",
+};
diff --git a/apps/web/lib/jambuddy/recorder.ts b/apps/web/lib/jambuddy/recorder.ts
new file mode 100644
index 0000000000000000000000000000000000000000..76fff676a7e26a021e9479889762bf821d4c71cb
--- /dev/null
+++ b/apps/web/lib/jambuddy/recorder.ts
@@ -0,0 +1,156 @@
+/**
+ * Web MIDI capture for Jam Buddy.
+ *
+ * Listens to a connected MIDI input (a controller the user is playing),
+ * records note-on/note-off events with timestamps, and converts them into a
+ * Standard MIDI File (.mid) so the take feeds the existing upload pipeline
+ * (tempo auto-detect + duration + generation).
+ *
+ * Browser-only (Web MIDI API). Keep all MIDI access here, call from the page.
+ */
+
+import { Midi } from "@tonejs/midi";
+
+export interface RecordedNote {
+ /** Absolute time (seconds) from the recording start. */
+ time: number;
+ midi: number;
+ velocity: number;
+ /** GM channel the message arrived on (0-15; 9 = percussion). */
+ channel: number;
+}
+
+export interface MidiRecorder {
+ start(): void;
+ stop(): { notes: RecordedNote[]; durationSec: number };
+ isActive(): boolean;
+ /** Detach the MIDI listener (call when leaving the page / no longer needed). */
+ dispose(): void;
+}
+
+/**
+ * Build a Standard MIDI File byte buffer from recorded notes + a detected BPM.
+ * Single track, tempo meta at t=0, one note-on/note-off pair per event in
+ * PPQ-relative ticks. Reuses @tonejs/midi so we don't hand-roll SMF bytes.
+ */
+export function recordedNotesToMidi(
+ notes: RecordedNote[],
+ bpm: number,
+): Uint8Array {
+ const midi = new Midi();
+ midi.header.setTempo(60_000_000 / bpm);
+ midi.header.timeSignatures = [{ ticks: 0, timeSignature: [4, 4] }];
+
+ // Group by channel: a separate track per GM channel so percussion (9) and
+ // pitched notes don't collide.
+ const channels = [...new Set(notes.map((n) => n.channel))];
+ const byChannel = new Map();
+ for (const ch of channels) byChannel.set(ch, []);
+ for (const n of notes) byChannel.get(n.channel)!.push(n);
+
+ const ppq = 480;
+ for (const [ch, chNotes] of byChannel) {
+ const track = midi.addTrack();
+ track.channel = ch;
+ track.name = `Capture ch${ch}`;
+ for (const n of [...chNotes].sort((a, b) => a.time - b.time)) {
+ const ticks = Math.max(0, Math.round((n.time / 60) * bpm * ppq));
+ const durTicks = Math.max(1, Math.round((0.1 / 60) * bpm * ppq));
+ track.addNote({
+ midi: n.midi,
+ ticks,
+ durationTicks: durTicks,
+ velocity: Math.max(0.01, Math.min(1, n.velocity / 127)),
+ });
+ }
+ }
+ return midi.toArray();
+}
+
+/** Build a .mid File (for the take upload slot). */
+export function recordedNotesAsFile(
+ notes: RecordedNote[],
+ bpm: number,
+): File {
+ const bytes = recordedNotesToMidi(notes, bpm);
+ const ts = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
+ return new File(
+ [new Uint8Array(bytes)],
+ `jambuddy-live-capture-${ts}.mid`,
+ { type: "audio/midi" },
+ );
+}
+
+/**
+ * Create a recorder bound to a MIDI input. Requests MIDI access (user gesture
+ * required in some browsers). Returns a started recorder + the chosen input.
+ *
+ * If `preferredDeviceName` is given, use that input; otherwise the first
+ * available. Throws if Web MIDI is unsupported or no input is available.
+ */
+export async function createMidiRecorder(
+ preferredDeviceName?: string,
+): Promise {
+ if (typeof navigator === "undefined" || !("requestMIDIAccess" in navigator)) {
+ throw new Error("Web MIDI is not supported in this browser (try Chrome/Edge).");
+ }
+
+ const access = await navigator.requestMIDIAccess();
+ const inputs = [...access.inputs.values()];
+ if (inputs.length === 0) {
+ throw new Error("No MIDI input device found. Connect a controller.");
+ }
+ const input =
+ inputs.find((i) => i.name === preferredDeviceName) ??
+ inputs[0] ??
+ null;
+ if (!input) {
+ throw new Error("No MIDI input device found. Connect a controller.");
+ }
+
+ let active = false;
+ let startTime = 0;
+ const notes: RecordedNote[] = [];
+
+ const onMessage = (e: MIDIMessageEvent) => {
+ if (!active || !e.data) return;
+ const status = e.data[0];
+ const midi = e.data[1];
+ const vel = e.data[2];
+ if (status === undefined || midi === undefined || vel === undefined) return;
+ const cmd = status & 0xf0;
+ const channel = status & 0x0f;
+ if (cmd === 0x90 && vel > 0) {
+ notes.push({
+ time: (performance.now() - startTime) / 1000,
+ midi,
+ velocity: vel,
+ channel,
+ });
+ }
+ };
+ input.addEventListener("midimessage", onMessage);
+
+ return {
+ start() {
+ startTime = performance.now();
+ notes.length = 0;
+ active = true;
+ },
+ stop() {
+ active = false;
+ return {
+ notes: [...notes],
+ durationSec:
+ notes.length > 0
+ ? (performance.now() - startTime) / 1000
+ : 0,
+ };
+ },
+ isActive: () => active,
+ dispose() {
+ active = false;
+ input.removeEventListener("midimessage", onMessage);
+ },
+ };
+}
diff --git a/apps/web/lib/jambuddy/visualizer.tsx b/apps/web/lib/jambuddy/visualizer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..80a2d26ccef3b7dcb12d5b770a07709529f25e6e
--- /dev/null
+++ b/apps/web/lib/jambuddy/visualizer.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+/**
+ * Jam Buddy take visualizer.
+ *
+ * Renders the user's take and the buddy's response on a canvas:
+ * - MIDI -> a piano-roll (notes as bars over time, pitch on the y-axis).
+ * - audio -> a real waveform (min/max peaks over time).
+ *
+ * MIDI has no waveform (it's note data, not audio), so the honest visual for a
+ * MIDI take is a piano-roll; audio gets the waveform. Both share a time axis so
+ * you can compare the take and the response side by side.
+ */
+
+import { useEffect, useRef } from "react";
+import { parseMidi, type ParsedNote } from "./player";
+
+interface VisualizerProps {
+ /** MIDI bytes -> piano-roll. Mutually exclusive with audioUrl. */
+ midiBytes?: ArrayBuffer | null;
+ /** Audio object URL -> waveform. Mutually exclusive with midiBytes. */
+ audioUrl?: string | null;
+ /** Optional label shown above the canvas. */
+ label?: string;
+ /** Height of the canvas in px. */
+ height?: number;
+}
+
+const NOTE_MIN = 21; // A0
+const NOTE_MAX = 108; // C8
+
+/** Draw a piano-roll of MIDI notes onto a canvas. */
+function drawPianoRoll(
+ canvas: HTMLCanvasElement,
+ notes: ParsedNote[],
+ duration: number,
+) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.scale(dpr, dpr);
+
+ ctx.fillStyle = "#12131b";
+ ctx.fillRect(0, 0, w, h);
+
+ if (notes.length === 0 || duration <= 0) {
+ ctx.fillStyle = "#7f829c";
+ ctx.font = "12px ui-monospace, monospace";
+ ctx.textAlign = "center";
+ ctx.fillText("no notes", w / 2, h / 2);
+ return;
+ }
+
+ const pad = 8;
+ const plotW = w - pad * 2;
+ const plotH = h - pad * 2;
+ const span = NOTE_MAX - NOTE_MIN;
+
+ // Grid lines every octave.
+ ctx.strokeStyle = "#2a2d3d";
+ ctx.lineWidth = 1;
+ for (let n = NOTE_MIN; n <= NOTE_MAX; n += 12) {
+ const y = pad + (1 - (n - NOTE_MIN) / span) * plotH;
+ ctx.beginPath();
+ ctx.moveTo(pad, y);
+ ctx.lineTo(w - pad, y);
+ ctx.stroke();
+ }
+
+ // Notes as bars.
+ const maxTime = Math.max(duration, ...notes.map((n) => n.time + n.duration));
+ for (const n of notes) {
+ const x = pad + (n.time / maxTime) * plotW;
+ const bw = Math.max(2, (n.duration / maxTime) * plotW);
+ const y = pad + (1 - (n.midi - NOTE_MIN) / span) * plotH;
+ const bh = Math.max(2, plotH / span);
+ ctx.fillStyle = n.channel === 9 ? "#f4a261" : "#5fd38a";
+ ctx.fillRect(x, y - bh, bw, bh);
+ }
+}
+
+/** Draw a waveform (min/max peaks) of an AudioBuffer onto a canvas. */
+function drawWaveform(
+ canvas: HTMLCanvasElement,
+ buffer: AudioBuffer,
+) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.scale(dpr, dpr);
+
+ ctx.fillStyle = "#12131b";
+ ctx.fillRect(0, 0, w, h);
+
+ const data = buffer.getChannelData(0);
+ const step = Math.ceil(data.length / w);
+ const amp = h / 2;
+ ctx.fillStyle = "#5fd38a";
+ for (let x = 0; x < w; x++) {
+ let min = 1;
+ let max = -1;
+ for (let i = 0; i < step; i++) {
+ const v = data[x * step + i];
+ if (v === undefined) continue;
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ const y1 = amp + min * amp;
+ const y2 = amp + max * amp;
+ ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
+ }
+}
+
+export function Visualizer({
+ midiBytes,
+ audioUrl,
+ label,
+ height = 120,
+}: VisualizerProps) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ if (midiBytes) {
+ let notes: ParsedNote[] = [];
+ let duration = 0;
+ try {
+ notes = parseMidi(midiBytes);
+ duration = notes.reduce(
+ (m, n) => Math.max(m, n.time + n.duration),
+ 0,
+ );
+ } catch {
+ /* corrupt bytes -> empty roll */
+ }
+ drawPianoRoll(canvas, notes, duration);
+ return;
+ }
+
+ if (audioUrl) {
+ const ctx = new AudioContext();
+ fetch(audioUrl)
+ .then((r) => r.arrayBuffer())
+ .then((buf) => ctx.decodeAudioData(buf))
+ .then((audio) => {
+ drawWaveform(canvas, audio);
+ ctx.close();
+ })
+ .catch(() => {
+ const c = canvas.getContext("2d");
+ if (c) {
+ c.fillStyle = "#12131b";
+ c.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
+ c.fillStyle = "#7f829c";
+ c.font = "12px ui-monospace, monospace";
+ c.textAlign = "center";
+ c.fillText("no audio", canvas.clientWidth / 2, canvas.clientHeight / 2);
+ }
+ });
+ return;
+ }
+
+ // Nothing to draw.
+ const c = canvas.getContext("2d");
+ if (c) {
+ c.fillStyle = "#12131b";
+ c.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
+ }
+ }, [midiBytes, audioUrl]);
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/lib/midi/generator.ts b/apps/web/lib/midi/generator.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0ae1c2098c04646c1036b40fcc466bd52ec76515
--- /dev/null
+++ b/apps/web/lib/midi/generator.ts
@@ -0,0 +1,106 @@
+/**
+ * MIDI file generator.
+ *
+ * Wraps @tonejs/midi to convert MidiEvent[] into a Uint8Array (.mid file bytes)
+ * suitable for download or for the drag-and-drop-into-Reaper flow.
+ *
+ * Source: docs/03-data-model.md §MIDI generation.
+ */
+
+import { Midi } from "@tonejs/midi";
+import {
+ LIMB_TO_GM,
+ type Limb,
+ type MidiEvent,
+} from "@patterntalk/shared-types";
+import { bpmToMicrosecondsPerQuarter } from "../patterns/engine";
+
+export interface MidiMeta {
+ patternId: string;
+ patternName: string;
+ bars: number;
+ bpm: number;
+}
+
+/**
+ * Convert MidiEvent[] to a SMF (Standard MIDI File) byte buffer.
+ * One track, channel 10 (the GM drum channel).
+ */
+export function eventsToMidi(events: MidiEvent[], meta: MidiMeta): Uint8Array {
+ const midi = new Midi();
+
+ // Tempo + time signature on the master track header.
+ midi.header.setTempo(bpmToMicrosecondsPerQuarter(meta.bpm));
+ midi.header.timeSignatures = [{ ticks: 0, timeSignature: [4, 4] }];
+
+ const drums = midi.addTrack();
+ drums.name = `${meta.patternName} (Jam Buddy)`;
+ drums.channel = 9; // 0-indexed; SMF channel 10 is the GM drum channel.
+
+ for (const ev of events) {
+ const midiNote = LIMB_TO_GM[ev.limb];
+ drums.addNote({
+ midi: midiNote,
+ ticks: ev.tick,
+ durationTicks: ev.duration,
+ velocity: ev.velocity / 127,
+ });
+ }
+
+ return midi.toArray();
+}
+
+/**
+ * Build a filename per docs/07-reaper-integration.md §File naming.
+ * Example: jambuddy-d-beat-4bars-180bpm-2026-08-22T1430Z.mid
+ */
+export function midiFilename(meta: MidiMeta, when: Date = new Date()): string {
+ const ts = when.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
+ return `jambuddy-${meta.patternId}-${meta.bars}bars-${meta.bpm}bpm-${ts}.mid`;
+}
+
+/**
+ * Trigger a browser download of the MIDI file. Returns the filename used.
+ *
+ * The caller is responsible for the Reaper drag-and-drop UX (see
+ * docs/07-reaper-integration.md §Drag-from-browser directly into Reaper)
+ * — this is the simpler click-to-download path, kept as the reliable fallback.
+ */
+export function downloadMidi(events: MidiEvent[], meta: MidiMeta): string {
+ const bytes = eventsToMidi(events, meta);
+ // `new Uint8Array(...)` re-wraps as a plain (non-SharedArrayBuffer-backed)
+ // ArrayBuffer — required by lib.dom.d.ts in TS 5.7+.
+ const blob = new Blob([new Uint8Array(bytes)], { type: "audio/midi" });
+ const url = URL.createObjectURL(blob);
+ const filename = midiFilename(meta);
+
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.rel = "noopener";
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ // Revoke after a tick so the download has time to start.
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+
+ return filename;
+}
+
+/**
+ * Inverse of downloadMidi: build a File object suitable for the HTML5
+ * drag-and-drop API (DataTransfer.files). Used for drag-from-browser
+ * directly into a Reaper track.
+ */
+export function midiAsFile(events: MidiEvent[], meta: MidiMeta): File {
+ const bytes = eventsToMidi(events, meta);
+ return new File(
+ [new Uint8Array(bytes)],
+ midiFilename(meta),
+ { type: "audio/midi" },
+ );
+}
+
+// Re-export Limb for convenience — keeps consumers from digging into shared-types.
+export type { Limb };
diff --git a/apps/web/lib/patterns/engine.ts b/apps/web/lib/patterns/engine.ts
new file mode 100644
index 0000000000000000000000000000000000000000..11a4d66f327f91374ca7cf9637d48e6660c983b6
--- /dev/null
+++ b/apps/web/lib/patterns/engine.ts
@@ -0,0 +1,273 @@
+/**
+ * Pattern engine.
+ *
+ * Expands a PatternTemplate into MidiEvent[] given the user's request.
+ * Pure function — no DOM, no audio, no I/O. Test this thoroughly.
+ *
+ * Process (per docs/03-data-model.md §Pattern engine):
+ * 1. Load template
+ * 2. Expand to requested bars (repeat template.defaultBars pattern N times)
+ * 3. Apply cymbal override (replace ride/hihat hits with cymbal type)
+ * 4. Apply accents (+20 velocity on matching positions)
+ * 5. Apply feel modifier (swing / half-time / double-time)
+ * 6. Convert fractional positions (0.0–1.0) to absolute ticks at tempoBpm
+ */
+
+import type {
+ Hit,
+ Limb,
+ MidiEvent,
+ ParsedRequest,
+ PatternTemplate,
+} from "@patterntalk/shared-types";
+
+/** MIDI ticks per quarter note. The de facto standard (also SMTE/QT). */
+export const TICKS_PER_QUARTER = 480;
+
+/** Compute ticks-per-bar for a given time signature. */
+export function ticksPerBar(
+ timeSignature: { numerator: number; denominator: number },
+): number {
+ // A bar = numerator quarter notes, regardless of denominator.
+ return TICKS_PER_QUARTER * timeSignature.numerator;
+}
+
+export interface EngineOverrides {
+ cymbal?: ParsedRequest["cymbal"];
+ accents?: string[];
+ feel?: ParsedRequest["feel"];
+}
+
+export interface EngineInput {
+ template: PatternTemplate;
+ bars: number;
+ bpm: number;
+ timeSignature?: { numerator: number; denominator: number };
+ overrides?: EngineOverrides;
+}
+
+/** Limb set considered "cymbal-shaped" — replaced by a cymbal override. */
+const CYMBAL_LIMBS: ReadonlySet = new Set([
+ "hihat",
+ "hihat-open",
+ "ride",
+ "ride-bell",
+ "crash",
+ "china",
+ "splash",
+]);
+
+/** Map cymbal hint to a Limb. */
+function cymbalToLimb(type: NonNullable["type"]): Limb {
+ switch (type) {
+ case "crash":
+ return "crash";
+ case "ride":
+ return "ride-bell"; // default ride → ride-bell (8ths on the bell)
+ case "china":
+ return "china";
+ case "hihat-open":
+ return "hihat-open";
+ case "hihat-closed":
+ return "hihat";
+ case "splash":
+ return "splash";
+ }
+}
+
+interface ExpandedBar {
+ /** 0-based bar index. */
+ barIndex: number;
+ hits: Array<{ hit: Hit; tick: number }>;
+}
+
+/**
+ * Expand a template to N bars. Repeats the template's `defaultBars` pattern
+ * until N bars are filled. Velocity humanization: tiny per-bar nudge so
+ * repeated bars don't sound robotic.
+ */
+function expandBars(input: EngineInput): ExpandedBar[] {
+ const ts = input.timeSignature ?? { numerator: 4, denominator: 4 };
+ const barTicks = ticksPerBar(ts);
+ const template = input.template;
+ const repeatLength = Math.max(1, template.defaultBars);
+ const totalBars = Math.max(1, Math.floor(input.bars));
+
+ const result: ExpandedBar[] = [];
+ for (let barIndex = 0; barIndex < totalBars; barIndex++) {
+ // Index into the template pattern (handles templates that cover > 1 bar)
+ const templateBarIndex = barIndex % repeatLength;
+
+ // Humanization: ±2 velocity, deterministic per bar index.
+ const humanize = (barIndex * 17 + 11) % 5 - 2;
+
+ const hits: Array<{ hit: Hit; tick: number }> = [];
+ for (const hit of template.hits) {
+ // Filter hits to only those in the current template bar.
+ // Hits can have positions across multiple bars; we only want the ones
+ // for the current templateBarIndex. Since positions are 0.0–1.0 within
+ // a single bar, all hits in a single-bar template belong to bar 0;
+ // for multi-bar templates the position encoding is per-bar.
+ if (template.defaultBars === 1 || templateBarIndex === 0) {
+ // Clamp position to [0, 1) — guards against authoring errors.
+ const pos = Math.max(0, Math.min(0.999_999, hit.position));
+ const tick = Math.round(barIndex * barTicks + pos * barTicks);
+ const velocity = Math.max(
+ 1,
+ Math.min(127, hit.velocity + humanize),
+ );
+ hits.push({ hit: { ...hit, velocity }, tick });
+ }
+ }
+ result.push({ barIndex, hits });
+ }
+ return result;
+}
+
+/**
+ * Apply a cymbal override. For each expanded hit whose limb is a cymbal,
+ * replace the limb with the override's target.
+ *
+ * For multi-bar templates we only override cymbals in the bars that exist.
+ */
+function applyCymbalOverride(
+ bars: ExpandedBar[],
+ cymbal: NonNullable,
+): ExpandedBar[] {
+ const target: Limb = cymbalToLimb(cymbal.type);
+ return bars.map((bar) => ({
+ ...bar,
+ hits: bar.hits.map(({ hit, tick }) =>
+ CYMBAL_LIMBS.has(hit.limb)
+ ? { hit: { ...hit, limb: target }, tick }
+ : { hit, tick },
+ ),
+ }));
+}
+
+/**
+ * Apply accent boosts. For each accent descriptor, find hits whose position
+ * matches and boost velocity by +20.
+ *
+ * GOTCHA: `barTicks` must come from `ticksPerBar(timeSignature)`, NOT from
+ * any property of `bars[N]`. `ExpandedBar` has `{ barIndex, hits }` — no
+ * `.tick` field. Reading `bars[1]?.tick` returns undefined, which silently
+ * collapses `barTicks` to 1, and accents then only match bar 0. Regression
+ * test: "__tests__/engine.test.ts > regression: accents apply to ALL bars".
+ *
+ * Supported descriptors (start of v1):
+ * "1", "2", "3", "4" — beats within a 4/4 bar
+ * "and-of-2", "and-of-4" — classic backbeat accents
+ */
+function applyAccents(
+ bars: ExpandedBar[],
+ accents: string[],
+ timeSignature: { numerator: number; denominator: number } = { numerator: 4, denominator: 4 },
+): ExpandedBar[] {
+ const barTicks = ticksPerBar(timeSignature);
+ const quarter = TICKS_PER_QUARTER;
+
+ return bars.map((bar) => {
+ const hits = bar.hits.map(({ hit, tick }) => {
+ const localTick = tick - bar.barIndex * barTicks;
+ // Position within the bar in quarter notes
+ const localQuarter = localTick / quarter;
+ const beat = Math.floor(localQuarter) + 1; // 1-based
+ const isAnd = (localQuarter - Math.floor(localQuarter)) > 0.4;
+ const localPos = `${beat}${isAnd ? "-and" : ""}`;
+
+ const matched = accents.some((a) => {
+ if (a === localPos) return true;
+ if (a === "1" && beat === 1 && !isAnd) return true;
+ if (a === "3" && beat === 3 && !isAnd) return true;
+ if (a === "and-of-2" && beat === 2 && isAnd) return true;
+ if (a === "and-of-4" && beat === 4 && isAnd) return true;
+ return false;
+ });
+
+ return matched
+ ? {
+ hit: { ...hit, velocity: Math.min(127, hit.velocity + 20), accent: true },
+ tick,
+ }
+ : { hit, tick };
+ });
+ return { ...bar, hits };
+ });
+}
+
+/**
+ * Apply feel modifier. v1 supports swing (delays offbeat 8ths by swingRatio).
+ * Half-time and double-time are recognized but not yet implemented —
+ * TODO for the engine-extension PR.
+ */
+function applyFeel(
+ bars: ExpandedBar[],
+ feel: NonNullable,
+ swingRatio: number,
+ timeSignature: { numerator: number; denominator: number } = { numerator: 4, denominator: 4 },
+): ExpandedBar[] {
+ if (feel !== "swing") return bars; // half-time / double-time deferred
+
+ const barTicks = ticksPerBar(timeSignature);
+ const quarter = TICKS_PER_QUARTER;
+ const swingOffset = Math.round(swingRatio * quarter);
+
+ return bars.map((bar) => ({
+ ...bar,
+ hits: bar.hits.map(({ hit, tick }) => {
+ const localTick = tick - bar.barIndex * barTicks;
+ const localQuarter = localTick / quarter;
+ const isAnd = (localQuarter - Math.floor(localQuarter)) > 0.4;
+ return isAnd
+ ? { hit, tick: tick + swingOffset }
+ : { hit, tick };
+ }),
+ }));
+}
+
+/**
+ * Expand a PatternTemplate into MidiEvent[] for the requested bars and tempo.
+ *
+ * This is the single entry point used by the MIDI generator and by tests.
+ */
+export function expandPattern(input: EngineInput): MidiEvent[] {
+ const ts = input.timeSignature ?? { numerator: 4, denominator: 4 };
+ const overrides = input.overrides ?? {};
+
+ let bars = expandBars(input);
+
+ if (overrides.cymbal) {
+ bars = applyCymbalOverride(bars, overrides.cymbal);
+ }
+ if (overrides.accents && overrides.accents.length > 0) {
+ bars = applyAccents(bars, overrides.accents, ts);
+ }
+ if (overrides.feel) {
+ bars = applyFeel(bars, overrides.feel, input.template.swingRatio ?? 0, ts);
+ }
+
+ const events: MidiEvent[] = [];
+ for (const bar of bars) {
+ for (const { hit, tick } of bar.hits) {
+ events.push({
+ tick,
+ limb: hit.limb,
+ velocity: hit.velocity,
+ duration: 8, // ~8 ticks — short drum hit
+ });
+ }
+ }
+
+ // Sort by tick for clean MIDI output.
+ events.sort((a, b) => a.tick - b.tick);
+ return events;
+}
+
+/**
+ * Compute the bar-tempo in microseconds-per-quarter-note for the MIDI header.
+ * Standard MIDI tempo meta-event.
+ */
+export function bpmToMicrosecondsPerQuarter(bpm: number): number {
+ return Math.round(60_000_000 / bpm);
+}
diff --git a/apps/web/lib/voice/conversation.ts b/apps/web/lib/voice/conversation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a6b439303d46fbde35a5c55fd1e1b301834484e0
--- /dev/null
+++ b/apps/web/lib/voice/conversation.ts
@@ -0,0 +1,219 @@
+/**
+ * Voice conversation state machine.
+ *
+ * The single source of truth for "what state is the voice UI in."
+ * Consumed by the React layer to decide which UI is shown and which
+ * speech synthesis phrase to play.
+ *
+ * States (docs/04-ux-voice-first.md + two additions for error handling
+ * and confirmation):
+ *
+ * idle — nothing happening
+ * listening — mic active, capturing
+ * parsing — got final transcript, running the parser
+ * awaiting-confirmation — parser confidence < 0.7, user must pick
+ * generating — pattern engine + audio service running
+ * ready — MIDI + sample available, awaiting next command
+ * playing — audio preview playing
+ * error — recoverable failure (mic denied, parse error)
+ *
+ * Transitions are encoded as a pure function — given (state, event) return
+ * (nextState, side-effects). The React layer (or a test) executes side-effects.
+ */
+
+import type {
+ ConversationContext,
+ ConversationState,
+ ParseResult,
+} from "@patterntalk/shared-types";
+
+/** Events that drive state transitions. */
+export type ConversationEvent =
+ | { type: "START_LISTENING" }
+ | { type: "STOP_LISTENING" }
+ | { type: "TRANSCRIPT_FINAL"; transcript: string }
+ | { type: "PARSE_OK"; result: Extract }
+ | { type: "PARSE_LOW_CONFIDENCE"; result: Extract }
+ | { type: "CONFIRM_CANDIDATE"; index: number }
+ | { type: "GENERATION_OK"; sampleUrl?: string; variations?: number }
+ | { type: "GENERATION_FAIL"; message: string }
+ | { type: "PLAY_START" }
+ | { type: "PLAY_STOP" }
+ | { type: "RESET" }
+ | { type: "ERROR"; kind: NonNullable["kind"]; message: string };
+
+export interface Transition {
+ next: ConversationState;
+ context: ConversationContext;
+ /** Side effects the host should run (TTS, API calls, etc.). */
+ sideEffects: ConversationSideEffect[];
+}
+
+export type ConversationSideEffect =
+ | { type: "SPEAK"; text: string; interrupt: boolean }
+ | { type: "START_MIC" }
+ | { type: "STOP_MIC" }
+ | { type: "TRIGGER_GENERATION" }
+ | { type: "TRIGGER_PLAYBACK" }
+ | { type: "TRIGGER_STOP" };
+
+/**
+ * Pure transition function. Given a state + event, returns the next state,
+ * updated context, and side-effects to run.
+ *
+ * Unknown events for the current state are ignored (returns same state).
+ * This is intentional — voice UIs receive many stray events; the FSM
+ * should be quiet when not in the right state.
+ */
+export function transition(
+ state: ConversationState,
+ context: ConversationContext,
+ event: ConversationEvent,
+): Transition {
+ switch (event.type) {
+ case "RESET":
+ return {
+ next: "idle",
+ context: {},
+ sideEffects: [{ type: "SPEAK", text: "Starting over.", interrupt: true }],
+ };
+
+ case "START_LISTENING": {
+ if (state !== "idle" && state !== "ready" && state !== "error") {
+ return { next: state, context, sideEffects: [] };
+ }
+ return {
+ next: "listening",
+ context: {},
+ sideEffects: [{ type: "START_MIC" }],
+ };
+ }
+
+ case "STOP_LISTENING": {
+ if (state !== "listening") {
+ return { next: state, context, sideEffects: [] };
+ }
+ return {
+ next: state,
+ context,
+ sideEffects: [{ type: "STOP_MIC" }],
+ };
+ }
+
+ case "TRANSCRIPT_FINAL": {
+ if (state !== "listening") {
+ return { next: state, context, sideEffects: [] };
+ }
+ return {
+ next: "parsing",
+ context: {},
+ sideEffects: [
+ { type: "STOP_MIC" },
+ { type: "SPEAK", text: "Parsing.", interrupt: false },
+ ],
+ };
+ }
+
+ case "PARSE_OK": {
+ if (state !== "parsing") return { next: state, context, sideEffects: [] };
+ return {
+ next: "generating",
+ context: { parsed: event.result.request },
+ sideEffects: [
+ {
+ type: "SPEAK",
+ text: `Generating ${event.result.request.patternName ?? event.result.request.patternId ?? "pattern"}, ${event.result.request.bars} bars at ${event.result.request.tempo ?? event.result.request.tempoDefault} BPM.`,
+ interrupt: true,
+ },
+ { type: "TRIGGER_GENERATION" },
+ ],
+ };
+ }
+
+ case "PARSE_LOW_CONFIDENCE": {
+ if (state !== "parsing") return { next: state, context, sideEffects: [] };
+ const top = event.result.candidates[0];
+ const message = top
+ ? `I heard ${event.result.heardAs}. Did you mean ${top.request.patternName ?? top.request.patternId}?`
+ : `I heard ${event.result.heardAs}, but I'm not sure.`;
+ return {
+ next: "awaiting-confirmation",
+ context: {},
+ sideEffects: [{ type: "SPEAK", text: message, interrupt: true }],
+ };
+ }
+
+ case "CONFIRM_CANDIDATE": {
+ if (state !== "awaiting-confirmation") {
+ return { next: state, context, sideEffects: [] };
+ }
+ // The host should have stored the candidates in context.candidates.
+ // We can't store ParseResult directly because it lives in shared-types;
+ // for now, callers pass the index and the host resolves it externally.
+ // This is intentional — keeps the FSM serializable.
+ return {
+ next: "generating",
+ context,
+ sideEffects: [{ type: "TRIGGER_GENERATION" }],
+ };
+ }
+
+ case "GENERATION_OK": {
+ if (state !== "generating") {
+ return { next: state, context, sideEffects: [] };
+ }
+ return {
+ next: "ready",
+ context: {
+ ...context,
+ sampleUrl: event.sampleUrl,
+ variations: context.variations ?? [],
+ },
+ sideEffects: [
+ {
+ type: "SPEAK",
+ text: "Ready. Say play to preview, regenerate, download MIDI, or new pattern.",
+ interrupt: true,
+ },
+ ],
+ };
+ }
+
+ case "GENERATION_FAIL": {
+ return {
+ next: "error",
+ context: {
+ ...context,
+ error: { kind: "audio-error", message: event.message },
+ },
+ sideEffects: [{ type: "SPEAK", text: event.message, interrupt: true }],
+ };
+ }
+
+ case "PLAY_START": {
+ if (state !== "ready") return { next: state, context, sideEffects: [] };
+ return {
+ next: "playing",
+ context,
+ sideEffects: [{ type: "TRIGGER_PLAYBACK" }],
+ };
+ }
+
+ case "PLAY_STOP": {
+ if (state !== "playing") return { next: state, context, sideEffects: [] };
+ return {
+ next: "ready",
+ context,
+ sideEffects: [{ type: "TRIGGER_STOP" }],
+ };
+ }
+
+ case "ERROR": {
+ return {
+ next: "error",
+ context: { ...context, error: { kind: event.kind, message: event.message } },
+ sideEffects: [{ type: "SPEAK", text: event.message, interrupt: true }],
+ };
+ }
+ }
+}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..40c3d68096c270ef976f3db4e9eb42b05c7067bb
--- /dev/null
+++ b/apps/web/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..a356934ecae342c7a60d6935bf046b07ec4640da
--- /dev/null
+++ b/apps/web/next.config.mjs
@@ -0,0 +1,16 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ // PatternTalk's audio service runs on a different port (8001); in dev we
+ // proxy /api/audio/* there to avoid CORS. See feat/audio-service PR.
+ async rewrites() {
+ return [
+ {
+ source: "/api/audio/:path*",
+ destination: "http://localhost:8001/:path*",
+ },
+ ];
+ },
+};
+
+export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..551af8c8f0e652f53f0b6e01f513af93552d335e
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@patterntalk/web",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev -p 3000",
+ "build": "next build",
+ "start": "next start -p 3000",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@patterntalk/shared-types": "workspace:*",
+ "@tonejs/midi": "^2.0.28",
+ "next": "^14.2.18",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.9.3",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^8.57.1",
+ "eslint-config-next": "^14.2.18",
+ "happy-dom": "^15.11.7",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.15",
+ "typescript": "^5.6.3",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..2aa7205d4b402a1bdfbe07110c61df920b370066
--- /dev/null
+++ b/apps/web/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/web/scripts/_debug_jambuddy.cjs b/apps/web/scripts/_debug_jambuddy.cjs
new file mode 100644
index 0000000000000000000000000000000000000000..da0445371f70a8cee18ab0465a1ed118d6a3e97c
--- /dev/null
+++ b/apps/web/scripts/_debug_jambuddy.cjs
@@ -0,0 +1,28 @@
+const { execFile } = require("node:child_process");
+const { promisify } = require("node:util");
+const execFileAsync = promisify(execFile);
+const { join } = require("node:path");
+
+const repoRoot = join(__dirname, "..", "..", "..");
+const python = join(repoRoot, "stable-audio-3", ".venv", "Scripts", "python.exe");
+const script = join(repoRoot, "tools", "jam_buddy.py");
+const out = join(require("node:os").tmpdir(), "out.wav");
+
+execFileAsync(
+ python,
+ [script, "--bpm", "120", "--out", out],
+ { timeout: 300000, maxBuffer: 10 * 1024 * 1024 },
+)
+ .then(({ stdout }) => {
+ console.log("OK. stdout bytes:", stdout.length);
+ console.log("tail:", stdout.slice(-200));
+ })
+ .catch((err) => {
+ console.log("ERROR name:", err.name);
+ console.log("ERROR code:", err.code);
+ console.log("ERROR killed:", err.killed);
+ console.log("ERROR signal:", err.signal);
+ console.log("ERROR message:", err.message);
+ console.log("ERROR stderr:", JSON.stringify(err.stderr));
+ console.log("ERROR stdout:", JSON.stringify(err.stdout?.slice(-300)));
+ });
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6fe9dce94ce85503b717d9a20c8f4f949f0a29cd
--- /dev/null
+++ b/apps/web/tailwind.config.ts
@@ -0,0 +1,29 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: [
+ "./app/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./lib/**/*.{ts,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ // Per docs/05-accessibility.md §High contrast and theming
+ bg: "var(--bg)",
+ fg: "var(--fg)",
+ accent: "var(--accent)",
+ focus: "var(--focus)",
+ "limb-kick": "var(--limb-kick)",
+ "limb-snare": "var(--limb-snare)",
+ "limb-hihat": "var(--limb-hihat)",
+ "limb-ride": "var(--limb-ride)",
+ "limb-crash": "var(--limb-crash)",
+ "limb-china": "var(--limb-china)",
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..9cb6fe55c763f1abe6f0c76e67cbeac5a15b25d9
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "allowJs": false,
+ "noEmit": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": ["node_modules", ".next", "dist", "__tests__/**/*"]
+}
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5138a309851a7adbfae4b6b22b7de78df8a615d7
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import path from "node:path";
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "happy-dom",
+ globals: false,
+ include: ["__tests__/**/*.test.ts", "__tests__/**/*.test.tsx"],
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "."),
+ },
+ },
+});
diff --git a/docs/01-vision.md b/docs/01-vision.md
new file mode 100644
index 0000000000000000000000000000000000000000..d213f2d5949b6f5b53cf58d9638728df6b3d911f
--- /dev/null
+++ b/docs/01-vision.md
@@ -0,0 +1,90 @@
+# 01 — Vision
+
+## The problem
+
+Music generators and practice tools don't act like a bandmate. You sit down,
+start playing, and the software asks you to describe what you want in its terms
+— or it produces a finished "song" you have no part in. There's no tool that
+just **listens to what you're playing and joins in**.
+
+AI music tools compound the problem:
+- They generate full mixes at a fixed tempo, not parts that lock to *your* tempo.
+- They're visual toy apps or opaque audio generators with no semantic structure,
+ so blind and visually impaired producers can't use them at all.
+
+## The product
+
+**Jam Buddy** is a call-and-response practice companion. The interaction is the
+product: *you start playing, it joins in at your tempo, in the instrument you
+pick.*
+
+1. You load a **MIDI take** (controller) or **audio take** (mic/interface).
+2. The buddy detects your tempo (exact from MIDI note times; tempo-range prior
+ on audio) and matches its response length to your take.
+3. You pick the **instrument** the buddy should play (bass / lead / rhythm /
+ synth / drums) plus a genre and mood.
+4. SA3 generates the response — `small-music` for melodic parts, `small-sfx`
+ for clean isolated drums — at your tempo.
+5. **PLAY BOTH** plays your take and the buddy's response together, in tempo.
+6. Every response is saved to `generations/` so you can keep and A/B it.
+
+The UI is a hardware-sampler-styled rack, and it's **screenreader-compatible by
+design**: labelled range-input knobs, accessible button names, `aria-live`
+status. Voice-first *is* accessibility — we build the accessible version first,
+not as an afterthought.
+
+## Why this fits the Stability AI challenge
+
+The brief asks for *"a publicly available and accessible tool for music
+producers using the power of the Stable Audio 3 models, encouraging open
+development and showing the strengths of local open models."*
+
+- **SA3 powers the response** — generated locally from the open `small-music` /
+ `small-sfx` weights, no black-box API.
+- **Two real input modes**: MIDI (tempo + length lock) and audio
+ (audio-to-audio — the buddy genuinely hears and responds to your groove).
+- **Accessibility as load-bearing**, not bolted on.
+- **Open + local**: the whole pipeline runs on consumer hardware.
+
+## Who this is for
+
+**Primary:**
+- Musicians who want a practice partner that locks to *their* tempo.
+- Producers exploring complementary parts without leaving their flow.
+- Blind / visually impaired producers — the rack is fully keyboard + screenreader
+ operable.
+
+**Secondary:**
+- Any jam context — one player's take, a generated counterpart in the room.
+
+## What success looks like at the hackathon
+
+**Demo (3–4 minutes):**
+1. Elevator pitch: *"a practice partner that hears you start playing and joins
+ in at your tempo, in your instrument."*
+2. Load a MIDI take from a real controller.
+3. Pick an instrument, hit JOIN IN — the buddy responds at your tempo.
+4. PLAY BOTH — you + the buddy together, the co-play moment.
+5. Toggle the keyboard/screenreader path to show accessibility.
+6. Point at the `generations/` files — everything it produced is kept.
+
+**Deliverables:**
+- Working web app (`apps/web`) + the `tools/jam_buddy.py` SA3 pipeline.
+- Open-source MIT repo.
+- Screenreader-tested UI (NVDA + VoiceOver).
+
+**Stretch / not claimed:**
+- A LoRA fine-tune for "your style" is a real path (via `underfit` on a GPU)
+ but is NOT shipped — we demo the open local model, honestly.
+
+## What we explicitly are not
+
+- Not a DAW. Reaper / the DAW does that.
+- Not a full song generator. We produce one complementary part at your tempo.
+- Not a VST first. The web rack is the primary surface; plugin wrap is stretch.
+
+## North star
+
+A musician plays a 20-second idea, picks "lead guitar," and within a minute the
+buddy has responded with a lead part at the same tempo — and both play together.
+That's the co-play moment we're building toward.
diff --git a/docs/02-architecture.md b/docs/02-architecture.md
new file mode 100644
index 0000000000000000000000000000000000000000..29ac187253aac9256596635c916616eb87363d18
--- /dev/null
+++ b/docs/02-architecture.md
@@ -0,0 +1,271 @@
+# 02 — Architecture
+
+## System overview
+
+PatternTalk is two services plus a shared data layer.
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Browser │
+│ ┌──────────────────────────────────────────────────────┐ │
+│ │ Next.js Web App (TypeScript) │ │
+│ │ ├─ Voice UI (Web Speech API: STT + TTS) │ │
+│ │ ├─ Prompt parser │ │
+│ │ ├─ Pattern engine (loads templates) │ │
+│ │ ├─ MIDI generator (@tonejs/midi) │ │
+│ │ ├─ Audio context analyzer (Meyda/Essentia.js) │ │
+│ │ ├─ Reaper Web Control client │ │
+│ │ └─ Visual grid (optional, layered on top) │ │
+│ └──────────────────────────────────────────────────────┘ │
+└──────────┬──────────────────────────────────┬───────────────┘
+ │ HTTP │ WebSocket
+ ▼ ▼
+┌──────────────────────┐ ┌────────────────────────────┐
+│ Audio Service │ │ Reaper (user's machine) │
+│ (Python, FastAPI) │ │ Web Control surface │
+│ ├─ SA3 inference │ │ + ReaScript bridge │
+│ ├─ LoRA loader │ └────────────────────────────┘
+│ └─ Sample generator │
+└──────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────┐
+│ Stable Audio 3 weights (local FS) │
+│ + Brutal-drum LoRA adapter │
+└──────────────────────────────────────┘
+```
+
+## Services
+
+### 1. Web app (`apps/web`)
+
+**Stack:** Next.js 14+ (App Router), TypeScript, Tailwind CSS, Radix UI primitives, @tonejs/midi, Meyda.
+
+**Responsibilities:**
+- Render the voice-first UI
+- Capture microphone input → text via Web Speech API (`SpeechRecognition`)
+- Speak responses via `SpeechSynthesis`
+- Parse prompts → structured requests (see [`docs/03-data-model.md`](03-data-model.md))
+- Load pattern templates, generate MIDI in-browser
+- Optional: analyze uploaded audio for BPM (Meyda/Essentia.js)
+- Talk to Reaper via Web Control (when available)
+- Talk to audio service for sample generation
+
+**State management:** Zustand or React state. Avoid Redux. State is small.
+
+**Why Next.js:**
+- Server components let us split render work from interactive shell
+- Easy deploy to Vercel
+- TS support is first-class
+- Accessibility ecosystem (react-aria, Radix) is mature
+
+### 2. Audio service (`services/audio`)
+
+**Stack:** Python 3.10+, FastAPI, PyTorch 2.x, diffusers/transformers for SA3.
+
+**Responsibilities:**
+- Load Stable Audio 3 weights (base model)
+- Load LoRA adapter (brutal-drums)
+- Run inference on prompts → audio buffers
+- Stream or return audio as WAV
+- Cache generated samples (LRU + disk-backed)
+
+**Why separate service:**
+- Python ML ecosystem is non-negotiable for SA3
+- Decoupling lets us run on different machines (Vega for dev, cloud GPU for demo)
+- Browser can't run SA3 inference efficiently
+- Service can be scaled or replaced without touching the web app
+
+### 3. Reaper integration (in user's DAW)
+
+**Stack:** Reaper Web Control surface (built-in HTTP server) + optional ReaScript.
+
+**Responsibilities:**
+- Expose project tempo (BPM), time signature, play state
+- Accept MIDI files dropped onto tracks
+
+**Why this is not a "service":**
+- Reaper runs on the user's machine
+- PatternTalk is a client to its Web Control API
+- No persistent server-side integration needed
+
+## Data flow
+
+### Happy path: voice prompt → MIDI + sample
+
+```
+User voice: "tupatupatupa on the hihat, 4 bars"
+ │
+ ▼
+[Web Speech API] ──text──▶ "tupatupatupa on the hihat, 4 bars"
+ │
+ ▼
+[Onomatopoeia matcher] ──▶ { onomatopoeia: "tupatupatupa", mappedTo: "skank-beat" }
+ │
+ ▼
+[Prompt parser] ──▶ {
+ │ pattern: "skank-beat",
+ │ bars: 4,
+ │ tempo: null, // not specified
+ │ timeSignature: "4/4", // default
+ │ cymbalHint: "hi-hat upstrokes"
+ │ }
+ ▼
+[Tempo resolver] ──▶ tempo: 174
+ │ (priority: explicit in prompt > Reaper project BPM > uploaded audio BPM > 120 default)
+ ▼
+[Pattern engine] ──▶ MIDI events[] (loaded from skank-beat template, expanded to 4 bars at 174 BPM)
+ │
+ ▼
+[MIDI generator] ──▶ .mid file (Blob in browser)
+ │
+ ├─▶ [Download to user]
+ │
+ └─▶ [Audio service request]
+ POST /generate-sample
+ { prompt: "skank beat, hi-hat upstrokes, brutal drums", duration: 8 }
+ │
+ ▼
+ [SA3 + LoRA inference] ──▶ audio buffer
+ │
+ ▼
+ [Response] ──▶ .wav file (Blob in browser)
+ │
+ └─▶ [Download to user]
+```
+
+### Voice response flow
+
+After generation, PatternTalk speaks back:
+
+```
+"Skank beat, hi-hat upstrokes on the upbeats, 4 bars at 174 BPM.
+ MIDI ready. Sample ready. Say 'play' to preview, 'regenerate' to try again,
+ or 'download' to save the MIDI."
+```
+
+### Reaper sync flow
+
+```
+Web app mounts → checks for Reaper Web Control at localhost:8080
+ │
+ ├─ present → fetch /_/project/tempo → use as tempo default
+ │
+ └─ absent → use uploaded audio BPM or 120 default
+```
+
+## Folder structure
+
+```
+patterntalk/
+├── apps/
+│ └── web/ # Next.js app
+│ ├── app/ # App Router pages
+│ │ ├── page.tsx # Main voice UI
+│ │ ├── library/ # Pattern library
+│ │ └── layout.tsx
+│ ├── components/
+│ │ ├── voice/ # Voice UI primitives
+│ │ ├── grid/ # Visual grid (optional)
+│ │ └── ui/ # Radix wrappers
+│ ├── lib/
+│ │ ├── parser/ # Prompt + onomatopoeia parser
+│ │ ├── patterns/ # Pattern engine + template loader
+│ │ ├── midi/ # MIDI generation (@tonejs/midi)
+│ │ ├── audio/ # Web Audio, Meyda analyzer
+│ │ └── reaper/ # Reaper Web Control client
+│ ├── data/
+│ │ ├── patterns/ # JSON pattern templates
+│ │ └── onomatopoeia.json # Onomatopoeia mapping table
+│ ├── public/
+│ └── package.json
+├── services/
+│ └── audio/ # FastAPI service
+│ ├── sa3/ # SA3 wrapper
+│ │ ├── inference.py
+│ │ ├── lora.py
+│ │ └── server.py
+│ ├── training/ # LoRA fine-tuning scripts
+│ │ └── train_lora.py
+│ ├── models/ # SA3 base weights (gitignored)
+│ ├── loras/ # Trained LoRA adapters
+│ ├── cache/ # Generated sample cache
+│ └── requirements.txt
+├── data/
+│ └── training/ # Brutal drum samples for LoRA
+│ ├── oneshots/ # Kick, snare, china, etc.
+│ ├── loops/ # Short brutal loops
+│ └── manifest.yaml # Training data manifest
+├── docs/ # This directory
+├── scripts/
+│ ├── reaper/ # ReaScript helpers
+│ └── verify/ # Accessibility + smoke tests
+├── .github/
+│ └── workflows/ # CI (axe-core, lint, build)
+├── package.json # Workspace root
+└── README.md
+```
+
+## Why monorepo
+
+- Single repo for web + audio service + training data
+- Shared types between TS and Python (via JSON Schema + codegen, or just hand-written TS interfaces mirrored in Pydantic)
+- Single CI pipeline
+- Easier to ship as one artifact at the demo
+
+**Tooling:** pnpm workspaces + a simple Python venv per service. Avoid Turborepo/Nx overhead for a 2-day project.
+
+## Deployment
+
+| Component | Target |
+|---|---|
+| Web app | Vercel (free tier) |
+| Audio service | RunPod / Vast.ai during hackathon, optional Fly.io / Modal for inference |
+| Models + LoRA weights | HuggingFace Hub (public, for the LoRA at least) |
+| Training data | Small dataset, commit directly to repo or HuggingFace dataset |
+
+## Key technical decisions
+
+### Decision 1: Voice-first, not visual-first
+
+The voice UI is the primary surface. The visual grid is optional and layered.
+
+**Rationale:** Differentiates from every other drum plugin, hits the accessibility theme head-on, and matches how drummers actually think.
+
+### Decision 2: Pattern engine decoupled from audio engine
+
+The pattern (MIDI events) is hand-coded from templates. The audio (samples) is generated by SA3.
+
+**Rationale:** Your domain expertise lives in the patterns. SA3's strength is sample quality. Don't conflate them. Each can be evaluated independently.
+
+### Decision 3: Cloud GPU for inference during demo
+
+Vega 56 can run SA3 small but slowly. Use cloud for demo-day inference to guarantee snappy response.
+
+**Rationale:** Live inference during a 3-minute demo is high-risk if hardware is slow. A $20 cloud spend buys reliability.
+
+### Decision 4: Open weights and open code
+
+MIT code, public LoRA weights, public training manifest.
+
+**Rationale:** Stability challenge explicitly rewards "open development." Showing the weights and training data is itself part of the demo.
+
+### Decision 5: Reaper-first DAW integration
+
+Web Control surface + MIDI export, not a full VST/CLAP.
+
+**Rationale:** Web Control + drag-MIDI is 80% of the value at 20% of the work. CLAP wrap is stretch.
+
+## What this architecture doesn't do
+
+- No multi-user real-time collaboration (out of scope for 2 days)
+- No pattern saving to cloud accounts (localStorage only for now)
+- No mobile-first UI (desktop browser is the target)
+- No offline mode (Vega inference can run offline, but the demo assumes network for cloud inference)
+- No AU plugin format (Mac pain, not worth it for the demo)
+
+## Open architectural questions
+
+1. **Where does the pattern library live?** Browser-only (static JSON) vs. served from the audio service? *Lean: browser-only, simpler.*
+2. **Should variations be pre-generated or on-demand?** Pre-generated is faster demo, on-demand is more impressive. *Lean: pre-generate for safety, on-demand as a "show your work" feature.*
+3. **Real-time WebSocket for inference progress, or just polling?** WebSocket is nicer UX, polling is simpler. *Lean: WebSocket if time, polling if not.*
diff --git a/docs/03-data-model.md b/docs/03-data-model.md
new file mode 100644
index 0000000000000000000000000000000000000000..07bef6803080e68081e63d905912f0da9824da90
--- /dev/null
+++ b/docs/03-data-model.md
@@ -0,0 +1,400 @@
+# 03 — Data Model
+
+## Overview
+
+PatternTalk's core data is small and human-editable. Most of it lives in JSON files under `apps/web/data/` and `data/training/`.
+
+```
+Prompt (user voice/text)
+ │
+ ▼
+ParsedRequest ◄── onomatopoeia.json + prompt grammar
+ │
+ ▼
+Pattern (loaded from patterns/*.json, expanded to bars + tempo)
+ │
+ ▼
+MIDI events
+ │
+ ▼
+.mid file
+```
+
+## Prompt grammar
+
+User input is freeform text, but parses into a structured `ParsedRequest`.
+
+```typescript
+interface ParsedRequest {
+ // Source pattern
+ onomatopoeia?: string; // e.g. "tupatupatupa"
+ patternId?: string; // e.g. "d-beat", "blast-traditional"
+ patternName?: string; // e.g. "D-Beat", user-friendly
+
+ // Length
+ bars: number; // default 4
+ beats?: number; // override pattern beats per bar
+
+ // Tempo
+ tempo?: number; // explicit BPM
+ tempoSource: "prompt" | "reaper" | "audio" | "default";
+ tempoDefault: number; // 120 if nothing else
+
+ // Time signature
+ timeSignature: {
+ numerator: number; // default 4
+ denominator: number; // default 4
+ };
+
+ // Cymbal/voice hints
+ cymbal?: {
+ type: "crash" | "ride" | "china" | "hihat-open" | "hihat-closed" | "splash";
+ pattern?: "8ths" | "quarters" | "bell" | "wash" | "upstrokes";
+ };
+
+ // Accent overrides
+ accents?: ("1" | "and-of-2" | "3" | "and-of-4" | string)[];
+
+ // Style modifiers
+ feel?: "straight" | "swing" | "half-time" | "double-time";
+ intensity?: "soft" | "medium" | "brutal" | "brutal-max";
+}
+```
+
+## Onomatopoeia mapping
+
+`apps/web/data/onomatopoeia.json` is the lookup table the parser checks first.
+
+```json
+{
+ "version": 1,
+ "entries": [
+ {
+ "id": "skank-beat",
+ "patterns": ["tupatupatupa", "tupa-tupa-tupa", "chka-chka-chka"],
+ "patternId": "skank",
+ "defaultCymbal": { "type": "hihat-open", "pattern": "upstrokes" },
+ "confidence": 0.9,
+ "notes": "Ska-style offbeat hi-hat upstrokes."
+ },
+ {
+ "id": "blast-beat",
+ "patterns": ["krrk-krrk-krrk", "BLAM-BLAM-BLAM", "krkrkrkrk"],
+ "patternId": "blast-traditional",
+ "defaultIntensity": "brutal",
+ "confidence": 0.85,
+ "notes": "Traditional blast beat. Kick-snare alternation."
+ },
+ {
+ "id": "boom-bap",
+ "patterns": ["boom-bap", "boom-bap-boom-bap"],
+ "patternId": "hiphop-basic",
+ "confidence": 0.95
+ },
+ {
+ "id": "china-accent",
+ "patterns": ["tss", "chka", "BLAM"],
+ "patternId": "china-accent",
+ "defaultCymbal": { "type": "china", "pattern": "wash" }
+ },
+ {
+ "id": "ride-bell",
+ "patterns": ["ding-ding-ding", "ding-ding-ding-ding"],
+ "patternId": "ride-bell-8ths",
+ "defaultCymbal": { "type": "ride", "pattern": "bell" }
+ }
+ ]
+}
+```
+
+### Matching logic
+
+1. Normalize input: lowercase, strip punctuation, collapse whitespace
+2. Phonetic match: Soundex or simple Levenshtein against entries
+3. Syllable count: "tupatupatupa" = 4 syllables → likely 8th-note hi-hat
+4. Confidence threshold: 0.7 to auto-map, lower to ask user
+
+### User-defined onomatopoeias
+
+Users can add their own mappings via the UI. Stored in `localStorage` per device.
+
+```typescript
+interface UserOnomatopoeia {
+ phrase: string;
+ patternId: string;
+ cymbal?: CymbalHint;
+ createdAt: number;
+}
+```
+
+## Pattern templates
+
+`apps/web/data/patterns/*.json` — one file per pattern. Hand-authored. **This is your moat.**
+
+```typescript
+interface PatternTemplate {
+ id: string; // unique slug
+ name: string; // "D-Beat"
+ description: string; // screenreader-friendly text
+ tags: string[]; // searchable
+ genre: "metal" | "rock" | "punk" | "pop" | "jazz" | "funk" | "latin" | "hiphop";
+ defaultTempo: number; // BPM
+ defaultBars: number; // how many bars this template covers
+ swingRatio?: number; // 0.0 = straight, 0.33 = shuffle
+ hits: Hit[];
+}
+
+interface Hit {
+ position: number; // 0.0 to 1.0, fraction of one bar
+ limb: Limb;
+ velocity: number; // 0-127 MIDI velocity
+ accent?: boolean; // visual + audio emphasis
+}
+
+type Limb =
+ | "kick" // right foot
+ | "snare" // left hand (default) or right hand (cross-handed)
+ | "hihat" // right hand (default) or left hand (cross-handed)
+ | "hihat-open" // same as hihat but with open hi-hat sample
+ | "ride"
+ | "ride-bell"
+ | "crash"
+ | "china"
+ | "splash"
+ | "tom-1" // highest
+ | "tom-2"
+ | "tom-3" // lowest floor tom
+ | "cross-stick"; // rim click
+```
+
+### Example: D-Beat
+
+`apps/web/data/patterns/d-beat.json`:
+
+```json
+{
+ "id": "d-beat",
+ "name": "D-Beat",
+ "description": "D-beat pattern at 180 BPM. Kick plays on every beat with 8th-note doubles, snare hits on 2 and 4, ride bell plays steady 8ths throughout. Common in Discharge, Entombed, and Scandinavian crust punk.",
+ "tags": ["metal", "punk", "discharge", "entombed", "crust", "fast"],
+ "genre": "metal",
+ "defaultTempo": 180,
+ "defaultBars": 1,
+ "swingRatio": 0,
+ "hits": [
+ { "position": 0.0, "limb": "kick", "velocity": 110 },
+ { "position": 0.125, "limb": "kick", "velocity": 80 },
+ { "position": 0.25, "limb": "snare", "velocity": 100 },
+ { "position": 0.375, "limb": "kick", "velocity": 80 },
+ { "position": 0.5, "limb": "kick", "velocity": 110 },
+ { "position": 0.625, "limb": "kick", "velocity": 80 },
+ { "position": 0.75, "limb": "snare", "velocity": 100 },
+ { "position": 0.875, "limb": "kick", "velocity": 80 },
+ { "position": 0.0, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.125, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.25, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.375, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.5, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.625, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.75, "limb": "ride-bell", "velocity": 70 },
+ { "position": 0.875, "limb": "ride-bell", "velocity": 70 }
+ ]
+}
+```
+
+### Pattern library — starting list
+
+**Metal (priority, 8 patterns):**
+- `d-beat.json` — D-beat
+- `blast-traditional.json` — traditional blast (kick-snare)
+- `blast-hammer.json` — hammer-and-tongs blast
+- `blast-hyper.json` — hyperblast (double kick 16ths)
+- `half-time-metal.json` — Mastodon/Baroness half-time
+- `djent-polyrhythm.json` — Meshuggah-style 4-over-3 kick
+- `doom-slow.json` — Sleep/Electric Wizard doom
+- `groove-metal.json` — mid-tempo groove metal
+
+**Punk/Hardcore (4 patterns):**
+- `punk-rock.json` — Ramones/Misfits 4-on-floor
+- `hardcore.json` — Black Flag/Converge
+- `skank.json` — ska skank beat (hi-hat upstrokes)
+- `reggae-one-drop.json` — reggae
+
+**Rock/Pop (4 patterns):**
+- `rock-basic.json` — AC/DC/Beatles standard rock
+- `rock-half-time.json` — half-time shuffle
+- `pop-groove.json` — modern pop
+- `country-train.json` — train beat
+
+**Other genres (4 patterns):**
+- `bossa-nova.json` — bossa nova
+- `jazz-swing.json` — jazz ride swing
+- `funk-new-orleans.json` — NOLA funk
+- `hiphop-basic.json` — boom-bap
+
+**Target: 18–20 patterns before hackathon, more as time permits.**
+
+## Pattern engine
+
+Lives in `apps/web/lib/patterns/engine.ts`.
+
+### Inputs
+- `PatternTemplate`
+- `bars: number`
+- `tempoBpm: number`
+- `timeSignature: { numerator, denominator }`
+- Optional `cymbal` override (replaces the default cymbal)
+- Optional `accents` (boosts velocity on specified positions)
+- Optional `feel` modifier
+
+### Process
+
+```
+1. Load template
+2. Expand to requested bars:
+ - If template.defaultBars === requested bars: use as-is
+ - Else: repeat the pattern N times, optionally vary velocity (humanize)
+3. Apply cymbal override:
+ - If cymbal specified: replace ride/hihat hits with cymbal type
+ - Velocity scaled: cymbal typically quieter than snare
+4. Apply accents:
+ - Find positions matching accent specs
+ - Boost velocity by +20
+5. Apply feel modifier:
+ - "swing": delay 8th notes on the "and" by swingRatio
+ - "half-time": halve the effective tempo of snare/hihat
+ - "double-time": double it
+6. Convert positions (0.0-1.0) to absolute ticks at tempoBpm
+7. Return array of MIDI events
+```
+
+### Output
+
+```typescript
+interface MidiEvent {
+ tick: number; // absolute tick position
+ limb: Limb;
+ velocity: number; // 0-127
+ duration: number; // ticks (typically 1-10 for drums)
+}
+```
+
+## MIDI generation
+
+`apps/web/lib/midi/generator.ts` uses `@tonejs/midi`.
+
+```typescript
+import { Midi } from "@tonejs/midi";
+
+function eventsToMidi(events: MidiEvent[]): Uint8Array {
+ const midi = new Midi();
+ midi.header.setTempo(events.bpm); // bpm on master track
+ midi.header.timeSignature = [4, 4];
+
+ const drums = midi.addTrack();
+ drums.name = "PatternTalk Drums";
+
+ for (const ev of events) {
+ drums.addNote({
+ midi: limbToGeneralMidi(ev.limb),
+ ticks: ev.tick,
+ durationTicks: ev.duration,
+ velocity: ev.velocity / 127,
+ });
+ }
+
+ return midi.toArray();
+}
+
+function limbToGeneralMidi(limb: Limb): number {
+ // GM Drum Map
+ const map: Record = {
+ "kick": 36,
+ "snare": 38,
+ "cross-stick": 37,
+ "hihat": 42,
+ "hihat-open": 46,
+ "ride": 51,
+ "ride-bell": 53,
+ "crash": 49,
+ "china": 52,
+ "splash": 55,
+ "tom-1": 50,
+ "tom-2": 47,
+ "tom-3": 45,
+ };
+ return map[limb];
+}
+```
+
+## Audio sample prompts
+
+When PatternTalk calls the audio service, the prompt includes both the pattern context and a style hint:
+
+```typescript
+interface AudioPrompt {
+ pattern: string; // pattern name
+ limb?: Limb; // specific sample to generate
+ styleHints: string[]; // ["brutal drums", "18-inch china", "trashy"]
+ durationSeconds: number; // 1-5 for one-shots, 5-15 for loops
+ intensity: "soft" | "medium" | "brutal" | "brutal-max";
+ loraAdapter?: string; // "brutal-drums" by default
+}
+```
+
+Examples:
+- Limb-specific one-shot: `{ pattern: "D-Beat", limb: "china", styleHints: ["trashy", "18-inch", "panic-attack"], durationSeconds: 3, intensity: "brutal-max" }`
+- Loop preview: `{ pattern: "D-Beat", styleHints: ["brutal drums", "tight snare"], durationSeconds: 8, intensity: "brutal" }`
+
+## Training data manifest
+
+`data/training/manifest.yaml` describes every audio file used to train the brutal-drum LoRA.
+
+```yaml
+version: 1
+lora:
+ name: patterntalk-brutal-drums
+ base_model: stable-audio-3-small
+ training_steps: 1500
+ learning_rate: 1e-4
+ rank: 32
+
+samples:
+ - id: kick-001
+ path: oneshots/kick-tight.wav
+ category: kick
+ tags: [tight, clicky, triggered]
+ source: original-recording
+ license: CC-BY
+ duration_seconds: 1.2
+
+ - id: snare-discharge-001
+ path: oneshots/snare-trashy.wav
+ category: snare
+ tags: [trashy, snappy, crust]
+ source: freesound.org/user-x
+ license: CC-BY
+ duration_seconds: 0.8
+
+ - id: loop-dbeat-discharge-style
+ path: loops/dbeat-180.wav
+ category: loop
+ tags: [d-beat, 180bpm, discharge-style]
+ source: original-recording
+ license: CC-BY
+ duration_seconds: 4.0
+ bpm: 180
+```
+
+**License discipline:** All samples must be CC0, CC-BY, or original recordings. No unlicensed material. Documented in the manifest, surfaced in the model card.
+
+## What we're explicitly not modeling (yet)
+
+- User accounts (out of scope)
+- Cloud-saved pattern libraries (localStorage only)
+- Pattern remixing/chaining
+- Time-signature patterns other than 4/4 (the parser supports it but templates are 4/4)
+- Polymeter / metric modulation
+- Genre mixing (one pattern per request, not hybrids)
+
+These are all reasonable post-hackathon extensions.
diff --git a/docs/04-ux-voice-first.md b/docs/04-ux-voice-first.md
new file mode 100644
index 0000000000000000000000000000000000000000..c659bef371a8a044d77d72fe25e797f9f5f7ac7a
--- /dev/null
+++ b/docs/04-ux-voice-first.md
@@ -0,0 +1,388 @@
+# 04 — UX: Voice-First Design
+
+## Core principle
+
+**Voice is the primary surface. The visual grid is optional and layered.**
+
+A drummer with their hands full should be able to use PatternTalk without putting down sticks. A blind drummer using NVDA should be able to use it without sighted help. Sighted users get a parallel visual experience, but it never blocks the voice flow.
+
+## Voice technology
+
+### Speech-to-Text (STT)
+
+**Web Speech API: `SpeechRecognition`**
+
+```typescript
+const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
+recognition.continuous = true;
+recognition.interimResults = true;
+recognition.lang = "en-US";
+
+recognition.onresult = (event) => {
+ const last = event.results[event.results.length - 1];
+ if (last.isFinal) {
+ onFinalTranscript(last[0].transcript.trim());
+ } else {
+ onInterimTranscript(last[0].transcript);
+ }
+};
+```
+
+**Compatibility:** Chrome, Edge, Safari. Firefox has it but disabled by default. For the hackathon demo, Chrome is the assumption.
+
+**Accessibility note:** Screenreaders (NVDA, VoiceOver) do NOT consume Web Speech API STT output — they have their own voice input path. So STT is for users *without* a screenreader or for users who want hands-free. Screenreader users will type or use their screenreader's voice control. Both paths converge on the same prompt parser.
+
+### Text-to-Speech (TTS)
+
+**Web Speech API: `SpeechSynthesis`**
+
+```typescript
+function speak(text: string, opts: { interrupt?: boolean } = {}) {
+ if (opts.interrupt) speechSynthesis.cancel();
+ const utterance = new SpeechSynthesisUtterance(text);
+ utterance.rate = 1.1; // slightly faster than default for snappy feel
+ utterance.pitch = 1.0;
+ speechSynthesis.speak(utterance);
+}
+```
+
+**Voice selection:** Use the system default voice. No fancy custom voices — keeps the demo reliable across machines.
+
+**Accessibility note:** When a screenreader is active, `SpeechSynthesis` is muted by the screenreader (it doesn't double-speak). The text is read by the screenreader using the same DOM. This is exactly what we want.
+
+## The conversation loop
+
+PatternTalk maintains a state machine for voice interactions:
+
+```
+states: idle → listening → parsing → generating → ready → (next action)
+```
+
+### State diagram
+
+```
+[IDLE]
+ │ user clicks "Start" or presses Space
+ ▼
+[LISTENING]
+ │ mic captures audio
+ │ interim transcripts shown
+ │ user pauses or says a wake word
+ ▼
+[PARSING]
+ │ onomatopoeia matcher → pattern
+ │ prompt parser → ParsedRequest
+ │ tempo resolver → final tempo
+ ▼
+[GENERATING]
+ │ pattern engine → MIDI events
+ │ audio service → sample (async)
+ │ speak: "Generating skank beat, 4 bars..."
+ ▼
+[READY]
+ │ speak: "Skank beat ready. MIDI and sample generated.
+ │ Say 'play' to preview, 'regenerate' to try again,
+ │ 'download MIDI', 'download sample',
+ │ or 'new pattern' to start over."
+ │
+ ├─ user: "play" → [PLAYING]
+ ├─ user: "regenerate" → [GENERATING] (4 variations possible)
+ ├─ user: "download MIDI" → trigger download
+ ├─ user: "download sample" → trigger download
+ └─ user: "new pattern" → [LISTENING]
+```
+
+### Wake word
+
+A simple wake word avoids the "always listening" creep:
+
+- **Wake word:** "PatternTalk" (or user-configurable)
+- After wake word, the system listens for one command
+- Optional: continuous listening mode for power users
+
+For the demo, "always listening with a push-to-talk toggle" is simpler. Use Spacebar or click to start/stop listening.
+
+## Voice commands
+
+### Generation commands
+
+| User says | System does |
+|---|---|
+| "Tupatupatupa on the hihat, 4 bars" | Generates skank beat (4 bars) |
+| "4 bars of d-beat riding the crash at 180" | Generates d-beat (4 bars, 180 BPM, ride-bell → crash override) |
+| "8 bars of skank beat" | Generates skank (8 bars, default tempo) |
+| "Triplet-driven double bass with accents on the one and the three" | Generates djent-polyrhythm (4 bars, default tempo, accents on 1 and 3) |
+| "Match my project tempo" | Sets `tempoSource = "reaper"` |
+| "Set tempo to 160" | Overrides tempo |
+| "Half-time feel" | Applies half-time modifier |
+| "Swing it" | Applies swing feel |
+
+### Action commands
+
+| User says | System does |
+|---|---|
+| "Play" / "Preview" | Plays the generated sample |
+| "Stop" | Stops playback |
+| "Regenerate" / "Try again" | Generates new variation |
+| "Variations" | Generates 4 variations |
+| "Pick number 2" | Selects variation 2 |
+| "Download MIDI" | Downloads .mid |
+| "Download sample" | Downloads .wav |
+| "New pattern" / "Start over" | Resets to listening |
+| "Help" | Reads command list |
+| "Repeat" | Re-speaks the last response |
+
+### Sample-generation commands
+
+| User says | System does |
+|---|---|
+| "Give me a trashy china" | Generates china one-shot |
+| "Snare sample, brutal" | Generates snare one-shot (intensity=brutal) |
+| "Loop preview" | Generates 8-second loop of current pattern |
+| "Tighter snare" | Generates snare variant |
+
+## Visual language — hardware / metallic reference
+
+The rack is styled as a hardware sampler. Source-of-truth visual reference:
+
+- **`toggle-switch.webp`** (repo root) — the metallic toggle-switch aesthetic to
+ match. Classic industrial 2-position toggle:
+ - **Chrome ball knob** on a **hexagonal metal housing** (reads as a polished
+ steel nut), seated in a **brushed-aluminum plate**.
+ - **Blue ON / red OFF** labels (colored inserts in the metal plate).
+ - Depth cues that sell it: specular highlight on the knob, drop shadow of the
+ knob onto the housing, inner shading on the hex recess, soft outer shadow on
+ the plate, clean bold sans-serif labels.
+- The existing engine rocker (`apps/web/app/globals.css` `.engine-toggle`) is the
+ in-app analog — brushed-steel two-position rocker. Keep any new toggle/switch
+ in the same metallic language (ball-on-hex for a literal toggle, brushed steel
+ for a rocker), not flat web styling.
+
+## Screen layout (visual fallback)
+
+When the user is sighted, the visual layout has these regions:
+
+```
+┌─────────────────────────────────────────────────────┐
+│ PatternTalk [help] [×] │
+├─────────────────────────────────────────────────────┤
+│ │
+│ 🎤 [Listening...] │
+│ │
+│ You said: "tupatupatupa on the hihat, 4 bars" │
+│ │
+│ ┌───────────────────────────────────────────────┐ │
+│ │ Pattern: Skank Beat │ │
+│ │ Tempo: 120 BPM (say "match project") │ │
+│ │ Bars: 4 │ │
+│ │ Status: Ready │ │
+│ └───────────────────────────────────────────────┘ │
+│ │
+│ ┌───────────────────────────────────────────────┐ │
+│ │ [Visual grid — bars of colored dots] │ │
+│ │ ●K ●K ●S ●K ●K ●K ●S ●K │ │
+│ │ ●H ●H ●H ●H ●H ●H ●H ●H │ │
+│ └───────────────────────────────────────────────┘ │
+│ │
+│ [Play] [Download MIDI] [Download Sample] │
+│ [Regenerate] [Variations (4)] [New Pattern] │
+│ │
+└─────────────────────────────────────────────────────┘
+```
+
+**The visual grid is hidden by default** in voice-first mode. Sighted users can toggle it on. It's also hidden when a screenreader is detected (to reduce clutter, since the screenreader will read the description anyway).
+
+## Visual grid component
+
+For each bar in the pattern, render a row of dots:
+
+```
+Bar 1 of 4 — Skank Beat at 120 BPM
+● · ● · ● · ● · ← kick (red, larger)
+· ● · ● · ● · ● ← snare (blue, smaller)
+● ● ● ● ● ● ● ● ← hi-hat (yellow, medium)
+```
+
+Each limb has:
+- A color (configurable)
+- A size (kick = large, snare = medium, hi-hat = small)
+- A label tooltip on hover
+
+**Accessibility:** The grid has an `aria-label` summarizing the pattern:
+
+```html
+
+
+
+```
+
+## Keyboard navigation
+
+Every action has a keyboard equivalent. No mouse required.
+
+| Key | Action |
+|---|---|
+| `Space` | Start/stop listening |
+| `Enter` | Confirm current selection / play preview |
+| `Tab` | Move focus through controls |
+| `1`-`4` | Pick variation N |
+| `M` | Download MIDI |
+| `S` | Download sample |
+| `R` | Regenerate |
+| `V` | Show variations |
+| `N` | New pattern |
+| `?` | Help (read command list) |
+| `Esc` | Cancel current action |
+
+Focus is always visible (high-contrast outline). When the screen announces something, focus moves to it so the screenreader reads it.
+
+## Pattern library page (`/library`)
+
+A separate page listing all patterns, with search and filter:
+
+- Search by name, tag, or genre
+- Filter by genre, tempo range
+- Click to load into the main UI
+- Share URL for any pattern (`/library?pattern=d-beat`)
+- "Fork" button: duplicate a pattern into the user's localStorage library
+
+**Accessibility:** Search is keyboard-driven. Results announced via `aria-live="polite"`. Filter chips have visible labels and ARIA states.
+
+## Variations UI
+
+When user asks for variations, show 4 mini-grids side by side:
+
+```
+┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
+│ Var 1 │ │ Var 2 │ │ Var 3 │ │ Var 4 │ [press 1-4]
+│ ●K ●K ●S │ │ ●K ●K ●K │ │ ●K ●K ●S │ │ ●K ●K ●S │
+│ · ·S · │ │ ·S · ·S │ │ · ·S · │ │ · ·S · │
+│ ●H ●H ●H │ │ ●H ●H ●H │ │ ●H ●H ●H │ │ ●H ●H ●H │
+└──────────┘ └──────────┘ └──────────┘ └──────────┘
+```
+
+Each variation has:
+- A different velocity humanization
+- A different micro-timing variation
+- Sometimes a different fill at the end of the bar
+- The patternId is the same; the variations are deterministic seeds of `engine.humanize()`
+
+For the demo, pre-generate 4 variations and store them so the picker is instant.
+
+## Sample preview player
+
+Audio playback uses the Web Audio API:
+
+```typescript
+async function playPreview(audioBuffer: ArrayBuffer) {
+ const ctx = new AudioContext();
+ const decoded = await ctx.decodeAudioData(audioBuffer);
+ const source = ctx.createBufferSource();
+ source.buffer = decoded;
+ source.connect(ctx.destination);
+ source.start(0);
+}
+```
+
+The preview loops until the user says "stop" or presses Esc.
+
+## Error handling
+
+Voice systems fail. The UX must handle it gracefully.
+
+| Failure | UX response |
+|---|---|
+| Mic permission denied | Show "Enable microphone in browser settings" + manual text input |
+| SpeechRecognition unavailable | Fall back to text input, show banner |
+| No onomatopoeia match | "I heard 'xyz', but I'm not sure what you mean. Try saying it differently, or pick from the list." |
+| Pattern not found | "Pattern 'xyz' not found. Did you mean 'd-beat'?" |
+| Audio service timeout | "Sample generation is taking longer than expected. Using cached sample. Say 'regenerate' to retry." |
+| Audio service error | "Couldn't generate a sample. MIDI is still ready. Say 'download MIDI' to save." |
+| Reaper not detected | "I don't see Reaper running. Say 'set tempo' to specify BPM manually." |
+
+In all cases, the user is told what's happening. No silent failures.
+
+## Performance budget
+
+| Metric | Target |
+|---|---|
+| First meaningful paint | < 1.5s |
+| Time to interactive (voice ready) | < 3s |
+| Prompt → MIDI generation | < 500ms (in-browser, no I/O) |
+| Prompt → sample ready | < 30s (cloud inference), < 5min acceptable (Vega inference) |
+| Voice transcript latency | < 500ms |
+| MIDI file download | < 200ms |
+
+## Demo flow (3–4 minutes)
+
+This is the rehearsal script:
+
+```
+[Open PatternTalk inside Reaper, mic icon visible]
+
+DRUMMER: "PatternTalk, tupatupatupa on the hihat, 4 bars"
+
+APP: "Skank beat, hi-hat upstrokes on the upbeats, 4 bars at 120 BPM.
+ MIDI ready. Sample ready."
+
+[Sighted users see grid + buttons. Blind drummer presses Tab to navigate.]
+
+DRUMMER: "Match my project tempo"
+
+APP: "Tempo set to 174 from Reaper project. Regenerating."
+
+APP: "Done. 4 bars at 174 BPM. Say 'play' to preview."
+
+DRUMMER: "Play"
+
+[Audio plays through speakers]
+
+DRUMMER: "Download MIDI"
+
+APP: "MIDI downloaded."
+
+[Drummer drags MIDI from browser downloads into Reaper track]
+
+DRUMMER: "Regenerate, brutal"
+
+APP: "Brutal mode engaged. Generating 4 variations."
+
+[4 mini-grids appear]
+
+DRUMMER: "Pick number 3"
+
+APP: "Variation 3 selected. Preview playing."
+
+DRUMMER: "New pattern. 4 bars of d-beat riding the crash at 180."
+
+APP: "D-beat with crash ride, 4 bars at 180. Generating."
+
+[Generation completes]
+
+DRUMMER: "Show me the grid."
+
+[Visual grid appears]
+
+[Switch on NVDA. Demo screenreader navigation.]
+
+DRUMMER: "Tab."
+
+NVDA: "Generate button. Tab. Regenerate button. Tab. Variations button..."
+
+DRUMMER: "Read the pattern."
+
+NVDA: "D-beat pattern. Kick plays on every beat with 8th-note doubles.
+ Snare hits on 2 and 4. Ride bell — no, crash — plays steady 8ths
+ throughout. 4 bars at 180 BPM."
+
+DRUMMER: "Download MIDI."
+
+[Done]
+```
+
+## What this UX is not
+
+- Not a chat interface. No typing back-and-forth. Voice is push-to-talk with clear states.
+- Not a visual-first app with voice features. Voice IS the app.
+- Not accessible as a feature. Accessibility is the design constraint that shaped voice-first.
+- Not a music theory tutor. We don't explain what a blast beat *means*, we just play it.
diff --git a/docs/05-accessibility.md b/docs/05-accessibility.md
new file mode 100644
index 0000000000000000000000000000000000000000..b87fad26d186b7055526f87924e743fd044ab97b
--- /dev/null
+++ b/docs/05-accessibility.md
@@ -0,0 +1,294 @@
+# 05 — Accessibility Strategy
+
+## Why this isn't an afterthought
+
+PatternTalk's founder runs [Narwall.tech](https://narwall.tech), an accessibility testing tool that uses a real screenreader to find compliance gaps. The hackathon's Accessibility & Inclusion theme is listed as a first-class track. The target users include blind and visually impaired producers, a population currently locked out of almost every AI music tool.
+
+So accessibility is **load-bearing**, not a checklist. The voice-first design (see [`04-ux-voice-first.md`](04-ux-voice-first.md)) was driven by accessibility, not the other way around.
+
+## Principles
+
+1. **Test with real assistive technology, not just linters.** axe-core catches ~30% of issues. NVDA and VoiceOver catch the rest.
+2. **Voice-first is screenreader-first.** Building the voice layer well means the screenreader layer works for free.
+3. **Every action has a keyboard equivalent.** No mouse-only paths.
+4. **Every pattern has a textual description.** Not an afterthought caption — the description is the data.
+5. **No information conveyed by color alone.** The visual grid uses size, position, and label too.
+6. **Works at 200% zoom and at high contrast.** Tested with Windows High Contrast Mode.
+
+## Standards we comply with
+
+- **WCAG 2.2 AA** as the floor. AAA where feasible.
+- **WAI-ARIA 1.2** for semantic structure.
+- **Section 508** (US federal) — implicit via WCAG.
+- **EN 301 549** (EU) — implicit via WCAG.
+- **VPAT 2.4** — generated for the demo.
+
+## Screenreader support
+
+### Tested screenreaders
+
+| Screenreader | OS | Browser | Status |
+|---|---|---|---|
+| NVDA 2024.x | Windows | Firefox + Chrome | Primary test target |
+| VoiceOver | macOS | Safari | Primary test target |
+| JAWS 2024 | Windows | Chrome | Stretch target |
+| TalkBack | Android | Chrome | Post-hackathon |
+| VoiceOver | iOS | Safari | Post-hackathon |
+
+### Test cadence
+
+- **Before each commit to `main`** that touches the UI: axe-core in CI blocks on AA violations
+- **Once per day during the hackathon**: manual NVDA run-through of the full flow
+- **Before demo**: NVDA + VoiceOver run-through, both screenshare-able
+
+## Keyboard navigation
+
+Every interactive element is reachable via Tab. Focus order matches visual order. Focus is always visible (high-contrast outline).
+
+```css
+:focus-visible {
+ outline: 3px solid var(--focus-color, #FFD700);
+ outline-offset: 2px;
+}
+```
+
+Skip links for repeated UI:
+
+```html
+Skip to main content
+Skip to pattern grid
+Skip to controls
+```
+
+### Roving tabindex for the visual grid
+
+The grid is a 2D structure. Arrow keys navigate within it; Tab enters/exits.
+
+```typescript
+function onGridKeydown(e: KeyboardEvent, row: number, col: number) {
+ switch (e.key) {
+ case "ArrowRight": moveFocus(row, col + 1); break;
+ case "ArrowLeft": moveFocus(row, col - 1); break;
+ case "ArrowDown": moveFocus(row + 1, col); break;
+ case "ArrowUp": moveFocus(row - 1, col); break;
+ case "Home": moveFocus(row, 0); break;
+ case "End": moveFocus(row, lastCol); break;
+ case "Enter": announceCurrentCell(); break;
+ }
+}
+```
+
+## Voice interaction as accessibility
+
+### How voice maps to screenreaders
+
+When PatternTalk speaks via `SpeechSynthesis`:
+
+- **No screenreader active:** audio plays through speakers, user hears it
+- **NVDA active:** NVDA mutes the speech synthesis output and reads the same text via its own voice, using the live region's text content
+- **VoiceOver active:** same behavior as NVDA
+
+This is the magic: a single voice output layer works for everyone.
+
+### How STT maps to screenreaders
+
+Screenreaders don't expose their STT input to web pages. So:
+
+- **Sighted users without screenreader:** use Web Speech API STT
+- **Screenreader users:** type into a regular text input, or use their screenreader's voice control (e.g., Dragon, Voice Access on Android, Voice Control on macOS)
+
+Both paths converge on the same prompt parser. The UI offers both visibly:
+
+```
+[🎤 Hold Space to talk] or [Type a prompt: ____________]
+```
+
+### ARIA live regions
+
+When PatternTalk wants to announce something (a new pattern generated, an error, a status change), it uses ARIA live regions:
+
+```html
+
+
+ Skank beat, 4 bars at 174 BPM. MIDI ready.
+
+
+
+
+
+```
+
+**Polite** for routine status (don't interrupt). **Assertive** for errors (interrupt).
+
+## Pattern description format
+
+Every pattern has a screenreader-friendly text description. The format:
+
+```
+{Pattern name}. {Genre} at {tempo} BPM.
+{Limb-by-limb description}.
+{Notable characteristics}.
+{Tags/context}.
+```
+
+Examples:
+
+**D-Beat:**
+```
+D-Beat. Metal at 180 BPM. Kick plays on every beat with
+8th-note doubles. Snare hits on 2 and 4. Ride bell plays
+steady 8th notes throughout. Common in Discharge, Entombed,
+and Scandinavian crust punk.
+```
+
+**Skank Beat:**
+```
+Skank Beat. Ska at 120 BPM. Kick on 1 and 3. Snare on
+2 and 4. Hi-hat plays on every upbeat — the "and" of each
+beat. Ska upstroke feel.
+```
+
+**Blast Traditional:**
+```
+Traditional Blast Beat. Metal at 200 BPM. Kick and snare
+alternate in 8th notes throughout. Hi-hat plays continuous
+8th notes. High intensity.
+```
+
+**Djent Polyrhythm:**
+```
+Djent Polyrhythm. Metal at 140 BPM. Kick plays a 4-over-3
+polyrhythm against the 4/4 pulse. Snare on 2 and 4.
+Ride cymbal plays 8th notes. Polyphonic, Meshuggah-style.
+```
+
+The description is generated from the template at runtime. Sighted users see it in a panel; screenreaders read it on demand.
+
+## High contrast and theming
+
+```css
+:root {
+ --bg: #ffffff;
+ --fg: #1a1a1a;
+ --accent: #0066cc;
+ --focus: #ffd700;
+ --limb-kick: #d62828;
+ --limb-snare: #1d3557;
+ --limb-hihat: #f4a261;
+ --limb-ride: #2a9d8f;
+ --limb-crash: #e76f51;
+ --limb-china: #8338ec;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1a1a1a;
+ --fg: #f0f0f0;
+ --accent: #66b2ff;
+ }
+}
+
+@media (prefers-contrast: more) {
+ :root {
+ --fg: #000000;
+ --bg: #ffffff;
+ --accent: #0000ee;
+ --focus: #ff00ff;
+ }
+}
+```
+
+## Reduced motion
+
+Some users get sick from animation. Respect `prefers-reduced-motion`:
+
+```css
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+```
+
+The visual grid has subtle "pulse" animations when patterns play back. With reduced motion, those are disabled — only color changes indicate playback position.
+
+## Captions and transcripts
+
+Every audio sample generated by SA3 gets a textual caption (derived from the prompt + pattern context):
+
+```
+Sample: brutal china crash, 18-inch, trashy.
+Duration: 3.0 seconds. Intensity: brutal-max.
+```
+
+Captions are:
+- Visible by default in the UI
+- Read by screenreaders when focus moves to the sample player
+- Exportable as a sidecar text file with the .wav download
+
+## CI accessibility checks
+
+```yaml
+# .github/workflows/ci.yml
+name: CI
+on: [push, pull_request]
+jobs:
+ a11y:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ - run: npm ci
+ - run: npm run build
+ - run: npm run test:a11y # axe-core via @axe-core/playwright
+```
+
+`npm run test:a11y` runs Playwright with axe-core against every page. Fails on any WCAG AA violation.
+
+## Manual screenreader test protocol
+
+Before each demo:
+
+1. **Cold start:** Close browser, open PatternTalk, immediately start NVDA
+2. **Full flow via keyboard only:** Generate a pattern, audition it, download MIDI, generate variations, pick one, start over
+3. **Verify announcements:** Every state change should be announced within 1 second
+4. **Verify focus:** Focus should never get stuck or disappear
+5. **Verify grid:** Tab to grid, arrow-navigate, every cell announces its limb and velocity
+6. **Verify download:** Download MIDI and sample, verify file names announced
+7. **Verify errors:** Trigger an error path (e.g., Reaper not running), verify error is announced politely
+8. **VoiceOver pass:** Repeat steps 1–7 with VoiceOver on macOS
+
+A short screen recording of this is part of the demo deliverables.
+
+## Reduced-physical-load mode (stretch)
+
+For drummers with RSI, chronic pain, or temporary injury. The plugin generates the physically demanding parts (blast beats, double bass) and the drummer plays the parts they can play.
+
+Implementation: split each pattern into "AI plays" vs "you play" subsets. Export two MIDI tracks. Drummer mutes the AI track they want to play themselves.
+
+Not in scope for the 2-day hackathon, but the data model supports it (each `Hit` has a `playableBy: "ai" | "human"` flag).
+
+## Haptic metronome (stretch)
+
+A paired PWA on the drummer's phone vibrates on the beat. Different vibration patterns for downbeat, backbeat, fills.
+
+Out of scope for the 2-day hackathon. Post-hackathon product.
+
+## Real-time audio captioning (stretch)
+
+Capture audio from the interface, run lightweight audio classification, output live captions: "kick, snare, kick-snare fill, china crash."
+
+Out of scope for the 2-day hackathon. This is what would make the tool usable for deaf producers collaborating with hearing producers.
+
+## Why this matters for the hackathon
+
+Jury includes Andrew Huang, who has explicitly designed for accessibility in past work. Zack Zukowski (Stability AI) is known for caring about inclusive design. The "Accessibility & Inclusion" theme is a first-class track. The MUTEK Festival, where winners present, has an audience that includes accessibility advocates.
+
+A demo that opens with "let me show you the screenreader view" is memorable. A demo that says "and we tested this with NVDA daily" is credible.
+
+## Narwall integration (post-hackathon)
+
+Long-term, PatternTalk could be tested *by* Narwall as part of its workflow — using Narwall's real-screenreader-based testing to continuously verify that no UI regression breaks the screenreader experience. That's a natural fit for the founder's other product and would make PatternTalk the only music tool with continuous accessibility testing baked in.
+
+For the hackathon: mention this as the long-term plan in the demo, don't build the integration.
diff --git a/docs/06-stable-audio-integration.md b/docs/06-stable-audio-integration.md
new file mode 100644
index 0000000000000000000000000000000000000000..85a7da35a2183a8a6312fb0601ee252b7efe2db6
--- /dev/null
+++ b/docs/06-stable-audio-integration.md
@@ -0,0 +1,466 @@
+# 06 — Stable Audio 3 Integration
+
+## What we're using Stable Audio 3 for
+
+PatternTalk uses SA3 for two things, both explicitly listed in the Stability AI challenge brief:
+
+1. **One-shot sample generation** — "give me a trashy 18-inch china"
+2. **Loop preview generation** — 5–15 second previews of patterns with brutal drum styling
+
+Plus one bonus use case:
+
+3. **LoRA fine-tuning** — train a small adapter on brutal drum samples so the model actually understands the genre
+
+The MIDI patterns themselves are NOT generated by SA3. They're hand-coded templates. This is a deliberate split: SA3's strength is sample quality, not structured MIDI generation.
+
+## Why local inference, not API
+
+The Stability AI challenge explicitly rewards *"showing the strengths of local open models."* Using the open weights locally:
+
+- Proves we actually use the open model, not a black-box API
+- Eliminates rate limits and surprise outages during the demo
+- Lets us fine-tune and ship the LoRA weights as a deliverable
+- Makes the inference reproducible and inspectable
+
+We pay for this with slower inference on consumer hardware. Mitigation: cloud GPU for the heavy work.
+
+## Hardware reality
+
+### Vega 56 (your machine) — honest assessment
+
+**Specs:** 8GB HBM2, GCN 5th gen, ROCm-supported but old.
+
+**Inference (SA3 small):**
+- ✅ Fits in 8GB VRAM in fp16
+- ⚠️ Tight memory headroom — no concurrent inference, no batching
+- ⚠️ ROCm + PyTorch + audio models = some setup friction
+- ⚠️ Throughput roughly **1/4 to 1/6 of an RTX 4090** on diffusion models
+- ⚠️ A 30-second sample at 100 denoising steps: expect 2–10 minutes wall time
+
+**Fine-tuning (LoRA):**
+- ❌ Not practical. Gradient buffers + optimizer state blow past 8GB.
+- ⏱️ If forced: 10–50× slower than cloud. Full LoRA training: hours to days, not feasible in a hackathon.
+
+**Verdict:** Use Vega for development and testing the inference path. Use cloud GPU for fine-tuning and demo-day inference.
+
+### Cloud GPU (RunPod / Vast.ai / Modal)
+
+**Recommended for the hackathon:**
+
+| Provider | GPU | Cost/hr | Notes |
+|---|---|---|---|
+| RunPod | RTX 4090 | $0.40 | Easiest UX, instant deploy |
+| RunPod | A4000 | $0.30 | Slightly slower than 4090 |
+| Vast.ai | RTX 3090 | $0.20 | Cheaper, more setup |
+| Modal | A10G | $0.50 | Serverless, pay per second |
+| Lambda Labs | A100 | $1.10 | Overkill but fast |
+
+**Recommendation:** RunPod with a 4090 for ~6 hours total = **~$3**. Fine-tune during one session, run demo inference during another.
+
+## Setup
+
+### Step 1: Get the model weights
+
+```bash
+# Clone the repo
+git clone https://github.com/Stability-AI/stable-audio-3.git
+cd stable-audio-3
+
+# Request access on HuggingFace (gated)
+# https://huggingface.co/stabilityai/stable-audio-3-small
+# https://huggingface.co/stabilityai/stable-audio-3-medium
+
+# Login
+huggingface-cli login
+
+# Download weights (use small on Vega, medium on cloud)
+python scripts/download_weights.py --model small
+```
+
+### Step 2: Install dependencies (local, Vega)
+
+ROCm is finicky. Use a pre-built PyTorch container if possible.
+
+```bash
+# Option A: Use the official Stability AI Docker image
+docker pull stabilityai/stable-audio-tools:latest
+
+# Option B: Manual install (if Docker fails)
+pip install torch==2.4.0 --index-url https://download.pytorch.org/whl/rocm5.7
+pip install stable-audio-tools
+pip install fastapi uvicorn
+```
+
+**Reality check:** On Vega, expect 1–3 hours of yak-shaving to get inference running the first time. Budget for this.
+
+### Step 3: Verify inference works
+
+```python
+# scripts/smoke_test.py
+from stable_audio_tools import get_pretrained_model
+from stable_audio_tools.inference.generation import generate_diffusion_cond
+
+model, config = get_pretrained_model("stabilityai/stable-audio-3-small")
+
+# Time this — establishes your baseline
+import time
+start = time.time()
+output = generate_diffusion_cond(
+ model,
+ steps=100,
+ cfg_scale=7,
+ conditioning=[{"prompt": "d-beat drum loop, 180 BPM, brutal", "seconds_start": 0, "seconds_total": 8}],
+ batch_size=1,
+ sample_size=44100 * 8,
+ device="cuda",
+)
+elapsed = time.time() - start
+print(f"Generated in {elapsed:.1f}s")
+# Save output
+import torchaudio
+torchaudio.save("smoke_test.wav", output.squeeze().cpu(), 44100)
+```
+
+If this takes < 2 minutes on Vega, you're fine. If it takes > 10 minutes, commit to cloud GPU for the demo.
+
+## Fine-tuning: brutal-drum LoRA
+
+### Why fine-tune
+
+Off-the-shelf SA3 doesn't know "brutal" drums. Your testing confirmed it. Fine-tuning on a curated dataset makes the model actually understand the genre vocabulary.
+
+### Training data curation
+
+**Minimum viable dataset (start with this):**
+
+- 20–40 one-shots:
+ - 5–10 kicks (tight, clicky, triggered)
+ - 5–10 snares (snappy, trashy, mid-range)
+ - 3–5 chinas (trashy, dark, bright variants)
+ - 3–5 crashes (various sizes/washes)
+ - 2–3 hi-hats (closed, open, stack)
+ - 2–3 rides (dry, washy, bell-forward)
+- 10–20 loops (3–10 seconds each):
+ - 3–5 d-beat loops at various tempos
+ - 3–5 blast beat loops (traditional, hammer, hyperblast)
+ - 2–3 half-time grooves
+ - 2–3 punk rock / hardcore loops
+ - 2–3 djent polyrhythm loops
+
+**Sources (in order of preference):**
+
+1. **Your own recordings** — best, no licensing issues, your taste
+2. **Freesound.org** — filter to CC0 or CC-BY, document the user
+3. **Splice / Loopcloud** — if you have a subscription, export with licensing documented
+4. **Bandcamp / label sample packs** — check license terms, some are CC-BY
+5. **Your band's existing recordings** — if you produced them, you own them
+
+**Discipline:** Every file in `data/training/` has a corresponding entry in `data/training/manifest.yaml` with source, license, duration, BPM (for loops), and tags.
+
+### Pre-processing
+
+All samples normalized to:
+- WAV format
+- 44100 Hz sample rate (or 48000 — match SA3's expected rate)
+- Mono (drums don't need stereo for LoRA training; stereo adds noise)
+- Loudness normalized to ~ -14 LUFS
+- Trimmed silence at start/end
+- Loops: aligned to bar boundaries, ideally with a single downbeat
+
+```python
+# services/audio/training/preprocess.py
+import torchaudio
+import pyloudnorm as pyln
+
+def preprocess(input_path: str, output_path: str):
+ waveform, sr = torchaudio.load(input_path)
+ # Resample
+ if sr != 44100:
+ waveform = torchaudio.functional.resample(waveform, sr, 44100)
+ # To mono
+ waveform = waveform.mean(dim=0, keepdim=True)
+ # Loudness normalize
+ meter = pyln.Meter(44100)
+ loudness = meter.integrated_loudness(waveform.numpy().T)
+ normalized = pyln.normalize.loudness(waveform.numpy().T, loudness, -14.0)
+ waveform = torch.from_numpy(normalized.T).unsqueeze(0)
+ torchaudio.save(output_path, waveform, 44100)
+```
+
+### LoRA training script
+
+Stability's repo supports LoRA via `stable_audio_tools.training.lora`. Use their example as a base.
+
+```python
+# services/audio/training/train_lora.py
+import torch
+from stable_audio_tools.models import create_model_from_config
+from stable_audio_tools.training.lora import LoRADataset, LoRATrainer
+from stable_audio_tools.data.utils import load_training_manifest
+
+# Load base model
+model, config = create_model_from_config("model_config.json")
+model.load_state_dict(torch.load("stable_audio_3_small.safetensors"))
+
+# Load training manifest
+dataset = LoRADataset(
+ manifest_path="data/training/manifest.yaml",
+ audio_dir="data/training/",
+)
+
+# LoRA config
+lora_config = {
+ "rank": 32,
+ "alpha": 32,
+ "dropout": 0.05,
+ "target_modules": ["to_q", "to_k", "to_v", "to_out.0"], # SA3 specifics
+}
+
+# Train
+trainer = LoRATrainer(
+ model=model,
+ dataset=dataset,
+ lora_config=lora_config,
+ learning_rate=1e-4,
+ batch_size=2,
+ gradient_accumulation=4,
+ max_steps=1500,
+ save_every=500,
+ output_dir="loras/brutal-drums",
+ device="cuda",
+)
+
+trainer.train()
+```
+
+**Expected training time on RTX 4090:** 30–90 minutes for 1500 steps with rank 32.
+
+**Output:**
+- `loras/brutal-drums/adapter.safetensors` — the LoRA weights (~50MB)
+- `loras/brutal-drums/checkpoints/` — intermediate checkpoints
+- `loras/brutal-drums/training_log.json` — loss curve
+
+### Publishing the LoRA
+
+After training, push to HuggingFace:
+
+```python
+# services/audio/training/publish.py
+from huggingface_hub import HfApi
+
+api = HfApi()
+api.create_repo("your-username/patterntalk-brutal-drums", repo_type="model")
+
+api.upload_folder(
+ folder_path="loras/brutal-drums/",
+ repo_id="your-username/patterntalk-brutal-drums",
+ commit_message="Initial LoRA trained on brutal drum samples",
+)
+
+# Upload model card
+api.upload_file(
+ path_or_fileobj="loras/brutal-drums/README.md",
+ path_in_repo="README.md",
+ repo_id="your-username/patterntalk-brutal-drums",
+)
+```
+
+**Model card must include:**
+- Base model reference
+- Training data summary (with manifest link)
+- License
+- Intended use
+- Limitations (it's a LoRA, not general-purpose)
+- Citation to Stability AI and PatternTalk
+
+## Inference service
+
+### Architecture
+
+```python
+# services/audio/sa3/server.py
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel
+import torch
+from stable_audio_tools import get_pretrained_model
+from stable_audio_tools.inference.generation import generate_diffusion_cond
+import torchaudio
+import io
+import hashlib
+
+app = FastAPI()
+
+# Load model once at startup
+model, config = get_pretrained_model("stabilityai/stable-audio-3-small")
+model = model.to("cuda")
+model.eval()
+
+# Load LoRA adapter
+model.load_adapter("loras/brutal-drums/adapter.safetensors", adapter_name="brutal")
+
+# Sample cache (LRU + disk)
+cache_dir = Path("cache/")
+cache_dir.mkdir(exist_ok=True)
+
+class GenerateRequest(BaseModel):
+ prompt: str
+ duration_seconds: float = 8.0
+ intensity: str = "medium" # soft | medium | brutal | brutal-max
+ use_lora: bool = True
+ cfg_scale: float = 7.0
+ steps: int = 100
+
+class GenerateResponse(BaseModel):
+ audio_url: str # URL to download the WAV
+ prompt: str
+ duration: float
+ cached: bool
+
+@app.post("/generate", response_model=GenerateResponse)
+async def generate(req: GenerateRequest):
+ # Cache key
+ cache_key = hashlib.sha256(
+ f"{req.prompt}|{req.duration_seconds}|{req.intensity}|{req.use_lora}".encode()
+ ).hexdigest()[:16]
+
+ cache_path = cache_dir / f"{cache_key}.wav"
+
+ if cache_path.exists():
+ return GenerateResponse(
+ audio_url=f"/cache/{cache_key}.wav",
+ prompt=req.prompt,
+ duration=req.duration_seconds,
+ cached=True,
+ )
+
+ # Build conditioning
+ conditioning = [{
+ "prompt": req.prompt,
+ "seconds_start": 0,
+ "seconds_total": req.duration_seconds,
+ }]
+
+ # Apply LoRA
+ if req.use_lora:
+ model.set_adapter("brutal")
+ else:
+ model.disable_adapters()
+
+ # Generate
+ try:
+ output = generate_diffusion_cond(
+ model,
+ steps=req.steps,
+ cfg_scale=req.cfg_scale,
+ conditioning=conditioning,
+ sample_size=int(44100 * req.duration_seconds),
+ device="cuda",
+ )
+ except Exception as e:
+ raise HTTPException(500, f"Generation failed: {e}")
+
+ # Save
+ torchaudio.save(str(cache_path), output.squeeze().cpu(), 44100)
+
+ return GenerateResponse(
+ audio_url=f"/cache/{cache_key}.wav",
+ prompt=req.prompt,
+ duration=req.duration_seconds,
+ cached=False,
+ )
+
+@app.get("/cache/{key}.wav")
+async def get_cached(key: str):
+ path = cache_dir / f"{key}.wav"
+ if not path.exists():
+ raise HTTPException(404)
+ return FileResponse(path)
+```
+
+### Intensity → prompt engineering
+
+Map user-friendly intensity to model-friendly prompts:
+
+```python
+INTENSITY_PROMPTS = {
+ "soft": "clean studio drums, polished, tight, controlled",
+ "medium": "live room drums, punchy, present",
+ "brutal": "brutal drums, trashy, aggressive, raw, distorted",
+ "brutal-max": "ultra-brutal drums, completely destroyed, blown-out, panic-attack intensity",
+}
+
+def build_audio_prompt(req: AudioPrompt) -> str:
+ parts = [
+ req.pattern,
+ req.styleHints.join(", "),
+ INTENSITY_PROMPTS[req.intensity],
+ "drum recording, close-mic'd",
+ ]
+ if req.limb:
+ parts.append(f"single {req.limb} hit")
+ return ", ".join(filter(None, parts))
+```
+
+## Prompting strategy for SA3
+
+Stable Audio 3 responds well to:
+- Genre terms ("d-beat", "black metal", "brutal")
+- Tempo ("180 BPM")
+- Recording style ("close-mic'd", "room mic", "triggered")
+- Specific instrument descriptors ("tight snare", "trashy china")
+- Intensity adjectives ("brutal", "raw", "polished")
+
+SA3 responds poorly to:
+- Vague aesthetic terms ("cool", "interesting")
+- Mixing many genres ("jazz-black-metal-funk")
+- Trying to specify timing ("snare on the and of 2")
+
+Our brutal-drum LoRA biases the model toward:
+- Recognizing metal subgenres
+- Generating physically realistic drum sounds
+- Avoiding pop/EDM patterns
+
+## Demo strategy
+
+For the live demo, we want generation to be fast and reliable. Pre-generate a few showcase samples:
+
+```bash
+# Pre-generate demo cache
+python scripts/pregenerate_demo.py
+# Generates:
+# cache/dbeat-180.wav
+# cache/blast-traditional-200.wav
+# cache/china-trashy.wav
+# cache/kick-brutal.wav
+# cache/skank-120.wav
+# cache/variation-1.wav ... variation-4.wav
+```
+
+During the demo, hit the live API for the "regenerate" command (to show inference happening), but rely on cache for the initial generation to keep the demo snappy.
+
+## What can go wrong
+
+| Failure | Mitigation |
+|---|---|
+| Cloud GPU instance dies | Pre-generated cache + fallback to Vega inference |
+| SA3 produces generic output | LoRA adapter handles this; fallback is to label output "demo variation" |
+| Generation exceeds 30s | Show progress indicator + have pre-generated fallback |
+| Audio quality is bad | Iterate on LoRA training data; have multiple variations to pick from |
+| ROCm issues on Vega | Skip local dev, use cloud-only |
+
+## Cost summary
+
+| Item | Cost |
+|---|---|
+| Cloud GPU (RunPod 4090, 6 hours total) | ~$3 |
+| HuggingFace Pro (if needed for gated weights) | $0 (free tier sufficient) |
+| Total cloud spend | **~$3** |
+
+If your budget is truly $0, do everything on Vega and accept 2–10 minute inference times. The product still works, the demo just needs more buffer time.
+
+## Post-hackathon
+
+- Quantize the model (int8) for faster inference
+- Train additional LoRAs: rock, jazz, funk (one per genre)
+- Host inference on Modal/Replicate for public use
+- Build a "train your own LoRA" UI — the "personal LoRA trainer" use case from the brief
diff --git a/docs/07-reaper-integration.md b/docs/07-reaper-integration.md
new file mode 100644
index 0000000000000000000000000000000000000000..f70d62f2907cdfd28fbe5f36f803a3d5e6dbd267
--- /dev/null
+++ b/docs/07-reaper-integration.md
@@ -0,0 +1,305 @@
+# 07 — Reaper Integration
+
+## Goal
+
+PatternTalk lives alongside Reaper, not inside it (for the 2-day hackathon). Integration happens at three levels:
+
+1. **Auto-detect project tempo** via Reaper's Web Control surface
+2. **MIDI export** that's drag-and-droppable into Reaper
+3. **Optional ReaScript bridge** for richer control (stretch)
+
+The CLAP plugin wrap is a stretch goal — see [`02-architecture.md`](02-architecture.md#decision-5-reaper-first-daw-integration).
+
+## Reaper Web Control surface
+
+Reaper has a built-in HTTP server you can enable in Preferences → Control Surfaces → Web Browser Interface.
+
+**Default URL:** `http://localhost:8080`
+
+**Default endpoints we care about:**
+
+| Endpoint | Returns |
+|---|---|
+| `GET /_/` | HTML control panel (we ignore this) |
+| `GET /_/action?name=...` | Run a named action |
+| `GET /_/set?param=value` | Set a parameter |
+| `WS ws://localhost:8080/_/` | WebSocket for live state |
+
+**Project tempo** is exposed via the WebSocket. On connect:
+
+```json
+{
+ "type": "state",
+ "data": {
+ "tempo": 174,
+ "timesig": [4, 4],
+ "playstate": 0,
+ "position": 0
+ }
+}
+```
+
+We subscribe to `state` updates and use the current tempo as the default for new patterns.
+
+## Client-side Reaper detection
+
+```typescript
+// apps/web/lib/reaper/client.ts
+
+class ReaperClient {
+ private socket: WebSocket | null = null;
+ private listeners: Set<(state: ReaperState) => void> = new Set();
+ private state: ReaperState = {
+ connected: false,
+ tempo: null,
+ timeSignature: null,
+ };
+
+ async connect(): Promise {
+ try {
+ this.socket = new WebSocket("ws://localhost:8080/_/");
+ this.socket.onopen = () => {
+ this.state.connected = true;
+ this.notify();
+ };
+ this.socket.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+ if (msg.type === "state") {
+ this.state.tempo = msg.data.tempo;
+ this.state.timeSignature = msg.data.timesig;
+ this.notify();
+ }
+ };
+ this.socket.onerror = () => {
+ this.state.connected = false;
+ this.notify();
+ };
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ getTempo(): number | null {
+ return this.state.tempo;
+ }
+
+ isConnected(): boolean {
+ return this.state.connected;
+ }
+
+ subscribe(listener: (state: ReaperState) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private notify() {
+ for (const listener of this.listeners) {
+ listener(this.state);
+ }
+ }
+}
+
+interface ReaperState {
+ connected: boolean;
+ tempo: number | null;
+ timeSignature: [number, number] | null;
+}
+
+export const reaper = new ReaperClient();
+```
+
+## Tempo resolution priority
+
+When the user generates a pattern, PatternTalk picks the tempo in this order:
+
+1. **Explicit in prompt:** "set tempo to 160" or "180 BPM" → 160 or 180
+2. **From Reaper project:** If connected → use project tempo
+3. **From uploaded audio:** If user uploaded an audio file → detected BPM
+4. **Default:** 120 BPM
+
+```typescript
+// apps/web/lib/parser/tempo.ts
+
+export async function resolveTempo(
+ parsed: ParsedRequest,
+ reaper: ReaperClient,
+ uploadedAudio: AudioBuffer | null
+): Promise {
+ if (parsed.tempo) {
+ return { tempo: parsed.tempo, source: "prompt" };
+ }
+
+ if (reaper.isConnected() && reaper.getTempo()) {
+ return { tempo: reaper.getTempo()!, source: "reaper" };
+ }
+
+ if (uploadedAudio) {
+ const bpm = await detectBpm(uploadedAudio);
+ if (bpm) {
+ return { tempo: bpm, source: "audio" };
+ }
+ }
+
+ return { tempo: 120, source: "default" };
+}
+```
+
+## MIDI export
+
+The MIDI file is generated in-browser. The user downloads it as a `.mid` file, then drags it onto a Reaper track.
+
+### File naming
+
+```
+patterntalk-{pattern-id}-{bars}bars-{bpm}bpm-{timestamp}.mid
+```
+
+Examples:
+- `patterntalk-d-beat-4bars-180bpm-2026-08-22T1430Z.mid`
+- `patterntalk-skank-4bars-120bpm-2026-08-22T1435Z.mid`
+
+### Drag-and-drop into Reaper
+
+Reaper accepts MIDI files dropped from the file system onto a track. We make this explicit:
+
+```typescript
+// apps/web/components/midi/DownloadButton.tsx
+
+function downloadMidi(events: MidiEvent[], meta: PatternMeta) {
+ const midi = eventsToMidi(events, meta);
+ const blob = new Blob([midi], { type: "audio/midi" });
+ const url = URL.createObjectURL(blob);
+
+ const filename = `patterntalk-${meta.patternId}-${meta.bars}bars-${meta.bpm}bpm-${new Date().toISOString()}.mid`;
+
+ // Trigger download
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+
+ // Cleanup
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+
+ return filename;
+}
+```
+
+### Drag-from-browser directly into Reaper
+
+For the smoothest demo, the MIDI file should be draggable from the browser window directly onto a Reaper track. Reaper accepts this if the file is exposed as a real file (not a Blob URL), which means we need to keep the file in memory and reference it via the DataTransfer API.
+
+```typescript
+function makeMidiDraggable(
+ events: MidiEvent[],
+ meta: PatternMeta,
+ element: HTMLElement
+) {
+ const midi = eventsToMidi(events, meta);
+ const filename = `patterntalk-${meta.patternId}-${meta.bars}bars-${meta.bpm}bpm.mid`;
+
+ element.draggable = true;
+ element.ondragstart = (e) => {
+ const file = new File([midi], filename, { type: "audio/midi" });
+ e.dataTransfer!.files = [file];
+ // Some browsers need this
+ e.dataTransfer!.setData("DownloadURL", `audio/midi:${filename}:${e.dataTransfer!.getData("DownloadURL")}`);
+ };
+}
+```
+
+**Caveat:** Browser support for dragging real files (not blob URLs) into native apps varies. Test in Chrome on the demo machine before relying on this.
+
+If drag-from-browser is flaky, the fallback is "click to download, then drag from Downloads folder to Reaper." Annoying but reliable.
+
+## Verifying MIDI works in Reaper
+
+Before the demo, smoke-test the MIDI export:
+
+1. Open Reaper, create a new project at 180 BPM, 4/4
+2. Add a track, load any drum sampler VST (free options: Drumgizmo, MT Power Drum Kit, or Reaper's built-in ReaDrumCrafter)
+3. Download a PatternTalk MIDI file
+4. Drag onto the track
+5. Hit play — confirm the pattern plays correctly
+
+Common bugs to watch for:
+- Tempo mismatch (Reaper plays at project tempo; MIDI file tempo should match)
+- Wrong GM drum map notes (kick = 36, snare = 38, etc.)
+- Notes too short or too long (drum hits should be very short, ~1-10 ticks)
+- MIDI file doesn't import at all (file format corruption)
+
+## ReaScript bridge (stretch)
+
+For richer integration, a small ReaScript can:
+
+- Auto-create a new track and load the MIDI when PatternTalk generates
+- Set the project tempo to match the pattern
+- Start playback automatically
+
+```lua
+-- scripts/reaper/patterntalk_bridge.lua
+
+-- Receives HTTP requests from PatternTalk
+-- Endpoint: http://localhost:8081/...
+
+-- For each request, execute a Reaper action
+
+function onRequest(method, path, body)
+ if path == "/import-midi" then
+ local filepath = body.filepath
+ local trackIndex = body.trackIndex or 0
+ Reaper.MIDI_InsertMedia(filepath, trackIndex)
+ return { ok = true }
+ end
+
+ if path == "/set-tempo" then
+ local tempo = body.tempo
+ reaper.SetProjectTimeSignature(0, tempo, ...)
+ return { ok = true }
+ end
+end
+```
+
+This is a stretch goal. If we have a teammate who's comfortable with ReaScript, we ship it. If not, the Web Control surface + MIDI drag-and-drop is the integration story.
+
+## Web Control endpoint reference
+
+The full Reaper Web Control API is documented at:
+https://www.reaper.fm/developers/webcontrol.php
+
+Key endpoints for PatternTalk:
+
+| Action | Endpoint |
+|---|---|
+| Get full state | `WS /_/` |
+| Run named action | `GET /_/action?name=` |
+| Set project tempo | `GET /_/set?project_tempo=` |
+| Get current tempo | (via WebSocket state) |
+| Transport play | `GET /_/action?name=40044` (transport: play) |
+| Transport stop | `GET /_/action?name=40044` (toggle, state-dependent) |
+
+**Note:** Reaper's Web Control API is reverse-engineered more than documented. The state format isn't formally specced. Expect some brittleness; verify on the actual Reaper version (currently 7.x).
+
+## Demo integration flow
+
+For the live demo, the integration sequence is:
+
+1. **Reaper is already open** with a project at ~174 BPM (a typical d-beat tempo)
+2. **PatternTalk opens** in a browser window next to Reaper
+3. **PatternTalk detects Reaper**, announces "Reaper connected at 174 BPM"
+4. **User generates a pattern** — PatternTalk uses 174 from Reaper
+5. **User downloads or drags MIDI** — drops into a Reaper track with a drum VST
+6. **Reaper plays** — pattern sounds correct
+7. **Optional:** Change Reaper project tempo to 160, regenerate in PatternTalk, drag new MIDI, plays at new tempo
+
+The whole sequence, with audio, takes about 30 seconds. Memorable.
+
+## What this integration is NOT
+
+- Not a VST/AU/CLAP plugin (stretch goal only)
+- Not bidirectional — PatternTalk reads from Reaper, doesn't write project state
+- Not automatic — user still has to drag the MIDI file in
+- Not a replacement for Reaper's built-in features
+
+This is intentional. The 80/20 here is "auto-detect tempo + clean MIDI export." Everything else is stretch.
diff --git a/docs/08-build-plan.md b/docs/08-build-plan.md
new file mode 100644
index 0000000000000000000000000000000000000000..f5a938c6ecb8be35e7cb4eee6e81d253162eb6b5
--- /dev/null
+++ b/docs/08-build-plan.md
@@ -0,0 +1,244 @@
+# 08 — Build Plan
+
+## Timeline overview
+
+```
+NOW (3 weeks before) Pre-hack prep
+HACK DAY 1 (Sat Aug 22) Build core features
+HACK DAY 2 (Sun Aug 23) Polish, integrate, demo
+POST-HACK Publish, ship, follow up
+```
+
+## Pre-hackathon (you have ~3 weeks)
+
+Goal: arrive at the event with a working skeleton, training data ready, and most patterns written. This compresses day 1 to "polish core, add stretch features" instead of "build from zero."
+
+### Week 1: foundations
+
+**By end of week 1:**
+
+- [ ] Get Stable Audio 3 API access / model weights downloaded
+ - [ ] HuggingFace account created
+ - [ ] Request gated access to `stabilityai/stable-audio-3-small`
+ - [ ] Get inference running locally on Vega (or document why cloud-only)
+ - [ ] Time a generation, establish baseline performance
+- [ ] Set up RunPod account (or chosen cloud GPU provider)
+ - [ ] Test deploying a simple inference container
+ - [ ] Verify you can ssh/exec into it
+- [ ] Stand up the repo
+ - [ ] Next.js + TypeScript + Tailwind initialized
+ - [ ] Folder structure per [`02-architecture.md`](02-architecture.md#folder-structure)
+ - [ ] CI with axe-core for accessibility (even on empty pages, it's a foundation)
+ - [ ] Deploy "coming soon" placeholder to Vercel
+- [ ] Set up audio service skeleton
+ - [ ] FastAPI + SA3 inference wrapper
+ - [ ] Single `/generate` endpoint, no LoRA yet
+- [ ] Install NVDA on Windows, run through Reaper once to baseline familiarity
+- [ ] Set up Reaper with a drum sampler VST for testing MIDI export
+
+### Week 2: data and templates
+
+**By end of week 2:**
+
+- [ ] Curate training data for brutal-drum LoRA
+ - [ ] 20–40 one-shots (recordings or sourced, all licensed)
+ - [ ] 10–20 short loops
+ - [ ] Manifest in `data/training/manifest.yaml`
+ - [ ] All preprocessed to 44100 Hz mono
+- [ ] Write the 6 demo-critical pattern templates
+ - [ ] `d-beat.json`
+ - [ ] `blast-traditional.json`
+ - [ ] `skank.json`
+ - [ ] `half-time-metal.json`
+ - [ ] `djent-polyrhythm.json`
+ - [ ] `punk-rock.json`
+- [ ] Write the onomatopoeia mapping table with at least 10 entries
+- [ ] Implement prompt parser MVP (handles the 6 patterns + 10 onomatopoeias)
+- [ ] Implement pattern engine MVP (loads templates, expands to bars, applies tempo)
+- [ ] Implement MIDI generation (downloads work, drags into Reaper)
+
+### Week 3: integration and rehearsal
+
+**By end of week 3:**
+
+- [ ] Train brutal-drum LoRA on cloud GPU
+ - [ ] First attempt: 500 steps, see how it shapes up
+ - [ ] Iterate: adjust learning rate, dataset, prompt format
+ - [ ] Final: ship-ready LoRA + publish to HuggingFace (draft, don't publish yet)
+- [ ] Implement Reaper Web Control client
+ - [ ] Connect, parse state, extract tempo
+ - [ ] Tempo resolution priority implemented
+- [ ] Implement Web Speech API integration
+ - [ ] STT works in Chrome
+ - [ ] TTS works
+ - [ ] Voice state machine (idle → listening → parsing → generating → ready)
+- [ ] Build basic UI shell
+ - [ ] Voice button, transcript display, status panel
+ - [ ] Action buttons (play, download, regenerate)
+ - [ ] Visual grid component (basic version)
+- [ ] Accessibility pass 1
+ - [ ] Tab through every element
+ - [ ] NVDA run-through of the voice flow
+ - [ ] ARIA labels on every interactive element
+ - [ ] Skip links
+ - [ ] High contrast check
+- [ ] End-to-end rehearsal
+ - [ ] Generate a pattern in PatternTalk
+ - [ ] Drag MIDI into Reaper
+ - [ ] Play it
+ - [ ] Generate sample
+ - [ ] Play sample
+ - [ ] Switch on NVDA, redo the flow
+- [ ] Write the team-pitch and post to Music Hackspace Discord
+
+## Hackathon Day 1 (Saturday Aug 22)
+
+### Morning (9:00 AM – 12:30 PM): setup + team formation
+
+- [ ] Arrive at PHI Centre, get settled
+- [ ] Finalize team composition (if recruiting on-site)
+- [ ] Confirm Reaper + PatternTalk working environment
+- [ ] Connect to venue WiFi, verify cloud GPU access
+
+### Early afternoon (12:30 PM – 3:00 PM): core demo path
+
+**Goal:** End-to-end voice → pattern → MIDI → Reaper working.
+
+- [ ] Finalize pattern engine: tempo, bars, time signature, cymbal overrides
+- [ ] Finalize MIDI export, verify it sounds right in Reaper
+- [ ] Wire up Web Speech API end-to-end
+- [ ] Wire up sample generation from cloud GPU
+- [ ] First smoke test: voice prompt → MIDI + sample → download → Reaper
+
+### Late afternoon (3:00 PM – 6:00 PM): variations and audio context
+
+- [ ] Implement 4-variation engine (humanize velocities, micro-timing, fill variants)
+- [ ] Implement uploaded audio BPM detection (Meyda)
+- [ ] Wire up Reaper tempo sync (Web Control client)
+- [ ] Pre-generate demo variations for the showcase patterns
+
+### Evening (6:00 PM – 9:00 PM): polish + accessibility
+
+- [ ] Visual grid component (sighted users)
+- [ ] Pattern library page (skeleton)
+- [ ] Accessibility pass with NVDA
+ - [ ] Tab through everything
+ - [ ] Voice announcements work
+ - [ ] Error states announced
+- [ ] Demo rehearsal (round 1)
+- [ ] Evening check-in / informal demos at the venue
+
+**Day 1 deliverable:** A working PatternTalk that takes voice prompts, generates MIDI, downloads it, and plays in Reaper. Plus 4-variation picker. Plus screenreader-tested.
+
+## Hackathon Day 2 (Sunday Aug 23)
+
+### Morning (9:00 AM – 12:00 PM): stretch features
+
+Priority order (cut from the bottom if time runs short):
+
+1. [ ] **Sample preview player** with progress indicator
+2. [ ] **Pattern library** with search/filter and shareable URLs
+3. [ ] **User-defined onomatopoeias** (localStorage)
+4. [ ] **CLAP plugin wrap** of the web UI (if a teammate can take it)
+5. [ ] **Real-time audio captioning** (stretch, probably cut)
+
+### Midday (12:00 PM – 2:00 PM): polish
+
+- [ ] Final visual grid polish (animations, color tuning)
+- [ ] Voice response tuning (speak rate, prompts)
+- [ ] Pre-generate all demo cache samples
+- [ ] Pre-stage the LoRA weights download for live demo
+- [ ] Test with cloud GPU off (Vega-only path) as fallback
+
+### Afternoon (2:00 PM – 4:00 PM): demo rehearsal
+
+- [ ] Full demo run-through, timed (target: 3–4 minutes)
+- [ ] NVDA demo run-through, timed
+- [ ] VoiceOver demo run-through, timed
+- [ ] Rehearse the pitch (3 sentences max)
+- [ ] Backup plan: video recording of working demo, in case of network issues
+
+### Late afternoon (4:00 PM – 6:00 PM): present
+
+- [ ] Public demos
+- [ ] Jury presentation
+- [ ] Feedback session
+
+### Evening (6:00 PM onwards): wind down
+
+- [ ] Final cleanup
+- [ ] Publish LoRA to HuggingFace (if not already)
+- [ ] Push code to GitHub, write a proper README
+- [ ] Write blog post / social announcement
+
+## Checkpoints
+
+Use these to catch problems early. If a checkpoint fails, stop and address it before moving on.
+
+| Checkpoint | When | What "passing" means |
+|---|---|---|
+| **C1: Inference works** | Pre-hack week 1 | SA3 small generates a sample on Vega in < 10 min OR cloud GPU works |
+| **C2: Training data ready** | Pre-hack week 2 | 30+ samples in `data/training/` with manifest, all licensed |
+| **C3: 6 patterns authored** | Pre-hack week 2 | All 6 demo patterns load, expand, generate MIDI |
+| **C4: End-to-end MIDI** | Pre-hack week 3 | Voice prompt → MIDI → Reaper plays correctly |
+| **C5: NVDA reads the UI** | Pre-hack week 3 | Screenreader announces every state change |
+| **C6: Demo path works** | Hack day 1 | Full voice → MIDI → sample → Reaper in < 60 seconds |
+| **C7: LoRA published** | Hack day 1 evening | Weights on HuggingFace with model card |
+| **C8: Variations work** | Hack day 2 morning | 4 variations show in UI, picker works |
+| **C9: Demo polished** | Hack day 2 afternoon | Rehearsal runs in 3–4 minutes without mistakes |
+
+## What to cut if time runs out
+
+In priority order (drop from the bottom):
+
+1. ❌ Real-time audio captioning
+2. ❌ Haptic metronome PWA
+3. ❌ Reduced-physical-load mode
+4. ❌ CLAP plugin wrap
+5. ❌ User-defined onomatopoeias
+6. ❌ Pattern library page (if a minimal in-app picker works)
+7. ❌ Audio context BPM detection (fall back to prompt/Reaper-only)
+8. ❌ Visual grid polish (fall back to functional ugly grid)
+9. ❌ Dark mode (fall back to light only)
+
+The must-haves are: voice prompt, pattern generation, MIDI export, sample generation, Reaper tempo sync. If we have those five, we have a demo that lands.
+
+## What to add if time is plentiful
+
+In priority order:
+
+1. ✅ CLAP plugin wrap
+2. ✅ Audio context BPM detection
+3. ✅ Pattern library page with shareable URLs
+4. ✅ User-defined onomatopoeias
+5. ✅ Visual grid polish with animations
+6. ✅ Dark mode + theme support
+7. ✅ Multiple LoRA adapters (one per genre: rock, jazz, funk)
+8. ✅ Real-time audio captioning
+
+## Communication cadence
+
+- **Standup** (15 min) at start of each day
+- **Mid-day check-in** (5 min, async) — what's blocked, what's next
+- **End-of-day retro** (15 min) — what shipped, what's deferred
+- **Demo** (3–4 min) at end of each day for informal feedback
+
+## Post-hackathon
+
+**Within 1 week:**
+- [ ] Publish LoRA weights to HuggingFace
+- [ ] Publish repo on GitHub with full README
+- [ ] Blog post / dev.to article about the project
+- [ ] Submit to Music Hackspace showcase if invited
+
+**Within 1 month:**
+- [ ] Add more pattern templates (target: 30+)
+- [ ] Train LoRAs for additional genres
+- [ ] Add CLAP plugin wrap (if not done at hackathon)
+- [ ] Integrate with Narwall for continuous accessibility testing
+
+**Within 3 months:**
+- [ ] Public launch of the web app
+- [ ] Community pattern library with moderation
+- [ ] "Train your own LoRA" UI
+- [ ] Mobile companion app for haptic metronome
diff --git a/docs/09-risks.md b/docs/09-risks.md
new file mode 100644
index 0000000000000000000000000000000000000000..e1447af26d4e824f008289e8a76f4b6590521ba9
--- /dev/null
+++ b/docs/09-risks.md
@@ -0,0 +1,314 @@
+# 09 — Risks & Mitigations
+
+Risks ranked by impact × likelihood. Each has an owner, a mitigation, and a contingency.
+
+## Risk matrix
+
+| # | Risk | Likelihood | Impact | Severity | Status |
+|---|---|---|---|---|---|
+| 1 | Cloud GPU unavailable during demo | Low | Critical | **HIGH** | Mitigated |
+| 2 | SA3 inference too slow on Vega | High | High | **HIGH** | Mitigated |
+| 3 | LoRA training fails / produces bad output | Medium | High | **HIGH** | Mitigated |
+| 4 | Voice recognition unreliable in noisy venue | High | Medium | **MEDIUM** | Mitigated |
+| 5 | Reaper Web Control API changes / doesn't work | Medium | Medium | **MEDIUM** | Mitigated |
+| 6 | Pattern templates too few / too low quality | Medium | Medium | **MEDIUM** | Mitigated |
+| 7 | Onomatopoeia parser doesn't understand users | Medium | Low | **MEDIUM** | Mitigated |
+| 8 | Screenreader breaks during demo | Low | Medium | **MEDIUM** | Mitigated |
+| 9 | Network drops during demo | Medium | High | **MEDIUM** | Mitigated |
+| 10 | Solo bottleneck — running out of time | High | High | **HIGH** | Mitigated |
+| 11 | Team conflict / teammate leaves | Medium | High | **MEDIUM** | Monitor |
+| 12 | Licensing issues with training data | Low | High | **MEDIUM** | Mitigated |
+| 13 | Browser doesn't support Web Speech API | Low | High | **MEDIUM** | Mitigated |
+| 14 | MIDI export has bugs that only show in Reaper | Medium | Medium | **MEDIUM** | Mitigated |
+| 15 | Audio sample quality is generic | Medium | Medium | **MEDIUM** | Mitigated |
+
+---
+
+## High-severity risks
+
+### Risk 1: Cloud GPU unavailable during demo
+
+**Scenario:** RunPod has an outage, you can't reach your instance, the venue WiFi blocks the connection, or your account gets suspended.
+
+**Mitigation:**
+- Pre-generate ALL demo samples before the event, store in `cache/` directory
+- Have a backup account on a different provider (Vast.ai + RunPod)
+- Test cloud GPU connection from the venue WiFi before the demo
+
+**Contingency:**
+- Run inference on Vega locally (slow but works)
+- Use pre-generated samples only and label them as "preset variations"
+- Record a video of the working demo as ultimate fallback
+
+**Owner:** You. **Verified:** Test cloud GPU from venue network on day 1 morning.
+
+---
+
+### Risk 2: SA3 inference too slow on Vega
+
+**Scenario:** A 30-second sample takes 15 minutes on your Vega. The demo times out.
+
+**Mitigation:**
+- Establish baseline performance in pre-hack week 1 (see C1 in [`08-build-plan.md`](08-build-plan.md))
+- If > 5 min per sample on Vega, commit to cloud-only
+- Pre-generate demo samples on cloud GPU
+- Cache aggressively (LRU + disk)
+
+**Contingency:**
+- Demo with cached samples only, narrate "this was generated earlier by the model"
+- Show inference happening live as a "by the way, look, it's running" secondary moment, not the main demo
+
+**Owner:** You. **Verified:** Run `scripts/smoke_test.py` on Vega before the event.
+
+---
+
+### Risk 3: LoRA training fails / produces bad output
+
+**Scenario:** Training crashes, or the resulting LoRA makes samples worse than the base model.
+
+**Mitigation:**
+- Run a small training (200 steps) first to verify the pipeline
+- Train the real LoRA at least 3 days before the event, so you have time to iterate
+- Keep the base model as a fallback (can disable LoRA at inference time)
+- Document the training hyperparameters so you can reproduce if needed
+
+**Contingency:**
+- Ship with the base SA3 model only, label output "Stable Audio 3, no fine-tuning"
+- Do a quick second training attempt during the hackathon if you have cloud GPU time
+- Be honest with the jury: "we attempted fine-tuning but the base model worked better for the time we had"
+
+**Owner:** You. **Verified:** Complete first training run before pre-hack week 3.
+
+---
+
+### Risk 10: Solo bottleneck — running out of time
+
+**Scenario:** You try to do everything alone, run out of time, ship a half-finished product.
+
+**Mitigation:**
+- Aggressive scope cut (see [`08-build-plan.md`](08-build-plan.md#what-to-cut-if-time-runs-out))
+- Pre-build as much as possible in the 3 weeks before
+- Define the minimum viable demo path and protect it ruthlessly
+- Daily checkpoint reviews to catch overruns early
+
+**Contingency:**
+- Ship a narrower demo: voice prompt → MIDI export only, no samples
+- Have a video walkthrough of the missing features
+- Focus the live demo on the 2–3 things that work perfectly
+
+**Owner:** You. **Verified:** C6 (demo path works) at end of day 1.
+
+---
+
+## Medium-severity risks
+
+### Risk 4: Voice recognition unreliable in noisy venue
+
+**Scenario:** The hackathon venue is loud. Web Speech API gets garbage transcripts. Demo fails.
+
+**Mitigation:**
+- Use a headset mic / close-mic'd setup, not the laptop mic
+- Test voice recognition in a noisy environment before the event
+- Have a fallback path: type the prompt instead of speaking
+- Push-to-talk (Spacebar to start/stop) avoids picking up ambient noise
+
+**Contingency:**
+- Switch to typed prompts for the demo if voice fails
+- Use a quieter corner of the venue for the live demo
+- Have a backup video of working voice mode recorded in a quiet space
+
+**Owner:** You. **Verified:** Test voice in actual venue conditions.
+
+---
+
+### Risk 5: Reaper Web Control API changes / doesn't work
+
+**Scenario:** Reaper updates, the WebSocket format changes, your client breaks.
+
+**Mitigation:**
+- Test against your installed Reaper version (currently 7.x) before the event
+- Hard-pin the WebSocket protocol version in your client
+- Have the fallback path: "type the BPM manually"
+
+**Contingency:**
+- Demo without Reaper integration, just type the BPM
+- Show Reaper integration in a video if live fails
+
+**Owner:** You. **Verified:** Test on your Reaper install before the event.
+
+---
+
+### Risk 6: Pattern templates too few / too low quality
+
+**Scenario:** You write 5 patterns, all of them are slightly wrong. Jury asks for a beat you don't have.
+
+**Mitigation:**
+- Aim for 18–20 patterns before hackathon
+- Write the 6 demo-critical ones first
+- Each pattern tested in Reaper before the event
+- Have a "blank bar" fallback that lets the user define their own
+
+**Contingency:**
+- Generate variations from existing patterns (different bars, different cymbals, half-time)
+- Be honest: "we focused on metal first, here's what we have"
+
+**Owner:** You. **Verified:** C3 (6 patterns authored) at end of week 2.
+
+---
+
+### Risk 7: Onomatopoeia parser doesn't understand users
+
+**Scenario:** User says "tupatupatupa" but the parser doesn't recognize it. Falls back to genre vocabulary, picks the wrong pattern.
+
+**Mitigation:**
+- Curate the onomatopoeia table with multiple variants per pattern
+- Phonetic matching with Levenshtein distance
+- Confidence threshold (0.7) below which the parser asks for confirmation
+- "I heard X, did you mean Y?" response
+
+**Contingency:**
+- The user can always type the pattern name explicitly
+- "Try one of these patterns: [list]" response
+
+**Owner:** You. **Verified:** Test the top 20 onomatopoeia phrases manually.
+
+---
+
+### Risk 8: Screenreader breaks during demo
+
+**Scenario:** NVDA crashes, VoiceOver doesn't pick up the UI, the screenreader demo fails.
+
+**Mitigation:**
+- Test NVDA + Chrome on the demo machine before the event
+- Have a backup screenreader (VoiceOver on Mac, JAWS if you have access)
+- Record the screenreader demo as a backup video
+- Rehearse the keyboard navigation by feel, not by sight
+
+**Contingency:**
+- Show the recorded video of the working screenreader demo
+- Skip the live screenreader demo, focus on the voice mode
+- Describe the accessibility features narratively
+
+**Owner:** You. **Verified:** Test on the demo machine, not your dev machine.
+
+---
+
+### Risk 9: Network drops during demo
+
+**Scenario:** Venue WiFi dies, you can't reach the cloud GPU for inference.
+
+**Mitigation:**
+- Pre-generate all demo samples, store locally
+- Run a local copy of the audio service on the demo machine as backup
+- Have a mobile hotspot as tertiary fallback
+
+**Contingency:**
+- Demo with cached samples only
+- Acknowledge the network issue honestly if it comes up
+
+**Owner:** You. **Verified:** Confirm cloud GPU + local backup both reachable from venue.
+
+---
+
+### Risk 11: Team conflict / teammate leaves
+
+**Scenario:** A teammate you recruited flakes, or there's conflict about direction.
+
+**Mitigation:**
+- Have all critical code paths owned by you as backup
+- Set clear expectations early (what's the demo, what's the deadline)
+- Communicate scope cuts in real time
+
+**Contingency:**
+- Cut features the flaky teammate was working on
+- Lean on the must-haves list
+
+**Owner:** You. **Status:** Monitor.
+
+---
+
+### Risk 12: Licensing issues with training data
+
+**Scenario:** A sample you used is actually copyrighted, you get called out, model card needs to be pulled.
+
+**Mitigation:**
+- Every sample has a documented source and license in the manifest
+- Prefer CC0 / CC-BY / original recordings
+- For Freesound samples, filter to CC0 or CC-BY only, document the user
+- For your own recordings, document date and context
+
+**Contingency:**
+- If a license is questioned, replace that sample and retrain (time permitting)
+- Be transparent in the model card about licensing discipline
+
+**Owner:** You. **Verified:** Manifest reviewed before publishing.
+
+---
+
+### Risk 13: Browser doesn't support Web Speech API
+
+**Scenario:** Demo machine has Firefox, Web Speech API doesn't work.
+
+**Mitigation:**
+- Test on the demo browser before the event
+- Chrome is the assumption — install it on the demo machine
+
+**Contingency:**
+- Switch to typed prompts only
+- Use a different laptop with Chrome
+
+**Owner:** You. **Verified:** Test on demo browser.
+
+---
+
+### Risk 14: MIDI export has bugs that only show in Reaper
+
+**Scenario:** MIDI plays wrong in Reaper, or doesn't import at all.
+
+**Mitigation:**
+- Test every pattern in Reaper before the event
+- Use a known-good GM drum map
+- Set tempo in the MIDI file header to match project tempo
+- Test on the actual Reaper version you'll use at the event
+
+**Contingency:**
+- Fix the bug live (if quick)
+- Show the MIDI in a different DAW if Reaper is broken
+- Generate a Reaper project file (.rpp) that includes the MIDI in a known-good arrangement
+
+**Owner:** You. **Verified:** Full end-to-end test in Reaper.
+
+---
+
+### Risk 15: Audio sample quality is generic
+
+**Scenario:** Even with the LoRA, the samples sound like generic AI drums, not brutal.
+
+**Mitigation:**
+- Iterate on the training dataset (more samples, better curation)
+- Iterate on prompt engineering (try different intensity descriptors)
+- Generate multiple variations and pick the best
+
+**Contingency:**
+- Show the variations, frame as "exploring the space"
+- Be honest: "SA3 with our LoRA is good but not perfect yet — here's what it produces"
+
+**Owner:** You. **Verified:** Listen to samples before the event.
+
+---
+
+## Risk ownership summary
+
+| Owner | Risks |
+|---|---|
+| You (all) | All risks by default |
+| Future teammate (frontend) | R10 (solo bottleneck) — if recruited |
+| Future teammate (ML) | R3 (LoRA), R2 (Vega speed) — if recruited |
+
+## What we're explicitly NOT risking
+
+- Building features that won't be demoed
+- Custom DAW plugins (too risky for the timeframe)
+- Mobile apps
+- Cloud accounts / user authentication
+- Anything that requires a third-party API key beyond SA3
diff --git a/docs/10-team-pitch.md b/docs/10-team-pitch.md
new file mode 100644
index 0000000000000000000000000000000000000000..99f0d80fb3858f765b26a7ca1a1ad1f0f8a7d37f
--- /dev/null
+++ b/docs/10-team-pitch.md
@@ -0,0 +1,239 @@
+# 10 — Team Pitch & Skills Needed
+
+## The pitch (post this to Discord)
+
+> Hey — I'm building **PatternTalk** for the Stability AI challenge at the Montreal Music Hackathon (Aug 22–23). The pitch: every existing DAW plugin is a black box to blind and visually impaired producers, and AI music tools completely lack genre expertise — try getting Stable Audio 3 to make a brutal china crash or a d-beat loop and you'll see what I mean. So I'm building two things in one product: (1) a voice-first, screenreader-native interface ("tupatupatupa on the hihat, 4 bars") that produces downloadable MIDI, generated samples via Stable Audio 3, and a screenreader-friendly description of what's being played; (2) a brutal-drum LoRA fine-tune of Stable Audio 3 so the model actually understands the genre. The whole thing is screenreader-native because I'm using a real screenreader throughout development — same approach I use at my day job running [Narwall.tech](https://narwall.tech), an accessibility testing tool. Looking for 1–2 teammates to make this a real demo, not a slideshow. DMs open.
+
+## Skills we're looking for
+
+### Priority 1: Frontend engineer (1 person, ideal)
+
+**What they'd own:**
+- The voice-first UI (Web Speech API, voice state machine)
+- The visual grid component
+- Pattern library page
+- Accessibility implementation (ARIA, keyboard nav, screenreader testing)
+- Deploy to Vercel
+
+**Must-have:**
+- Strong React/Next.js + TypeScript
+- Comfortable with TailwindCSS or similar
+- Cares about accessibility (ideally has shipped accessible UIs)
+- Available for the full hackathon weekend (Aug 22–23 in Montreal)
+
+**Nice-to-have:**
+- Has used Web Speech API before
+- Familiar with @tonejs/midi or Web Audio API
+- Has worked with a screenreader (NVDA, VoiceOver, JAWS)
+- Drummer or musician (genuine interest in the domain, not required)
+
+**Time commitment:**
+- 3 weeks of light prep (a few hours/week to align on architecture)
+- Full weekend in Montreal, Aug 22–23
+
+---
+
+### Priority 2: ML / audio engineer (1 person, *this is the harder one to fill*)
+
+**What they'd own:**
+- Stable Audio 3 inference server (FastAPI + Python)
+- LoRA fine-tuning pipeline
+- Cloud GPU orchestration (RunPod / Vast.ai)
+- Publishing the LoRA weights to HuggingFace
+
+**Must-have:**
+- Comfortable with PyTorch
+- Has fine-tuned diffusion models before (audio models a strong plus)
+- Can deploy Python services (FastAPI, Docker)
+- Available for the full hackathon weekend
+
+**Nice-to-have:**
+- Has specifically worked with Stable Audio, AudioLDM, MusicGen, or Riffusion
+- Has trained LoRAs before
+- Familiar with ROCm or cloud GPU providers
+- Has worked with audio data (sample rates, normalization, etc.)
+
+**Time commitment:**
+- 3 weeks of prep (more than frontend — needs to establish the SA3 pipeline)
+- Full weekend in Montreal, Aug 22–23
+
+---
+
+### Priority 3: Musician / sound designer (optional but valuable)
+
+**What they'd own:**
+- Curate the brutal-drum training dataset
+- Review generated samples for "does this actually sound brutal"
+- Help write pattern templates if you want to delegate some
+- Provide creative direction during the demo
+
+**Must-have:**
+- Plays drums (or has deep familiarity with drumming)
+- Has strong taste in metal/rock/punk
+- Available for the full hackathon weekend
+
+**Nice-to-have:**
+- Produces records or has production experience
+- Has sample library curation experience
+- Has experience with AI music tools (knows what works and what doesn't)
+
+**Time commitment:**
+- Light prep (curate some samples, share references)
+- Full weekend in Montreal, Aug 22–23
+- *Could be remote for prep, in-person for the hackathon*
+
+---
+
+### Priority 4: Designer (optional)
+
+**What they'd own:**
+- Visual grid polish (animations, color tuning)
+- Logo, demo assets, brand if there's time
+- Possibly the marketing visuals for the post-hack blog post
+
+**Must-have:**
+- Visual design sense, especially for accessibility-conscious design
+- Available for at least hackathon day 2
+
+**Nice-to-have:**
+- Has designed for music applications before
+- Has designed with WCAG AA+ compliance in mind
+
+**Time commitment:**
+- Hackathon weekend only is fine
+- Lower priority than engineers
+
+---
+
+## What you (the founder) bring
+
+To attract good teammates, you need to offer something back. Here's what you bring:
+
+**Credibility:**
+- Active startup ([Narwall.tech](https://narwall.tech)) — this isn't a fantasy project
+- Real screenreader testing practice — unique in the music-tech space
+- Drummer + producer with metal bands — domain expertise, no faking needed
+- Working Stable Audio 3 setup planned (or in progress)
+
+**Preparation:**
+- Complete design docs before the event (this directory!)
+- Working code skeleton before the event
+- Pre-curated training dataset
+- Pre-trained LoRA (or first attempt done)
+
+**Project clarity:**
+- A real plan with checkpoints (see [`08-build-plan.md`](08-build-plan.md))
+- A real architecture (see [`02-architecture.md`](02-architecture.md))
+- Honest risk assessment (see [`09-risks.md`](09-risks.md))
+- Scope discipline (clear must-haves vs stretch)
+
+**The pitch itself** — voice-first AI drummer for metal that works with a screenreader — is distinctive enough that good engineers will be interested.
+
+---
+
+## Where to find teammates
+
+### Music Hackspace channels (primary)
+
+1. **Music Hackspace Discord** — main hub, post the pitch there
+ - Invite link on musichackspace.org
+ - Active community of music tech builders
+2. **Prep calls** (Wed evenings, July 29 – Aug 19)
+ - Show up to all of them
+ - Mention you're looking for teammates
+ - This is literally what the prep calls are for
+3. **Montreal music tech community**
+ - If you can attend any in person before the event, do it
+
+### Other channels (secondary)
+
+4. **MUTEK Discord / community** — overlap with hackathon attendees
+5. **Reddit:**
+ - r/WeAreTheMusicMakers
+ - r/musicproduction
+ - r/Drumming
+ - r/musictech
+6. **HuggingFace Discord** — for the ML engineer specifically
+7. **Reaper forums** — for Reaper integration expertise
+8. **Local university CS / music tech programs** (McGill, Concordia in Montreal)
+
+### What to avoid
+
+- Cold-DM-ing strangers without context
+- Promising ownership or revenue (this is a hackathon, set expectations clearly)
+- Recruiting people who don't have the full weekend available
+- Recruiting people who can't be in Montreal in person (remote-only is hard for a hackathon)
+
+---
+
+## How to evaluate candidates
+
+When someone responds to the pitch, ask:
+
+1. **"Can you be in Montreal August 22–23?"** (eliminates 50% of replies)
+2. **"What's the most interesting thing you've built recently?"** (gauge depth)
+3. **"Have you worked with screenreaders before?"** (for frontend; "have you fine-tuned audio models?" for ML)
+4. **"How do you feel about working from a detailed plan rather than improvising?"** (sets expectations)
+5. **"What would you want to own in this project?"** (alignment check)
+
+If they pass those, share the docs (this directory), give them 24 hours to read, then have a 30-minute call to align.
+
+---
+
+## Team formation timeline
+
+| Date | Action |
+|---|---|
+| Now (3 weeks out) | Post pitch to Discord + Reddit |
+| July 29 | Attend prep call #1, mention looking for teammates |
+| Aug 1 | Follow up with interested candidates |
+| Aug 5 | Attend prep call #2, share progress, recruit more |
+| Aug 8 | Finalize team composition |
+| Aug 12 | Attend prep call #3 (with team if formed) |
+| Aug 19 | Attend prep call #4 (final pre-hack alignment) |
+| Aug 22 | Hackathon day 1 |
+
+---
+
+## What if you can't find teammates
+
+Solo is a valid path. The plan is designed to be solo-shippable, with cuts clearly defined.
+
+**If solo:**
+- Focus ruthlessly on the must-haves (voice prompt, MIDI export, sample generation, Reaper sync)
+- Pre-build as much as possible in the 3 weeks
+- Use pre-generated samples and pre-cached variations for the demo
+- Be honest with the jury about the scope
+
+**If you find one teammate (frontend):**
+- You own audio service + training data + LoRA + Reaper integration
+- They own web app + voice UI + visual grid + accessibility
+- This is the ideal 2-person team
+
+**If you find two teammates (frontend + ML):**
+- You own: prompt parser, pattern engine, training data curation, demo, project lead
+- Frontend: web app, voice UI, accessibility
+- ML: audio service, LoRA training, cloud GPU
+- This is the dream team — you have time for stretch features
+
+**If you find a musician-only team:**
+- Less ideal — you'd be doing all the code
+- But the musician adds value on the training data curation and the demo
+
+---
+
+## Post-hackathon: what happens to the team?
+
+This is a hackathon, not a startup. After Aug 23, the team disbands by default. If people want to keep building:
+
+- **You** (the founder) own the IP per the hackathon rules
+- Contributors retain credit in the README and commit history
+- Open-source repo means anyone can fork and continue
+- If the project gains traction, consider:
+ - Adding collaborators as maintainers
+ - Forming a small LLC or collective if commercializing
+ - Or just letting it be an open-source project
+
+Set these expectations at the start: "we own what we build, you get credit, and we can decide post-event whether to continue together."
+
+The Music Hackspace IP rules are explicit: *"Your team keeps 100% ownership of what you create during the hackathon."*
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..f0b20708fb86b90e5424ee42bc2b316f85c950eb
--- /dev/null
+++ b/docs/CONTRIBUTING.md
@@ -0,0 +1,160 @@
+# Contributing
+
+## Code of conduct
+
+Be kind. Accessibility-first means inclusive by default. See [the hackathon's CoC](https://musichackspace.org) for the baseline.
+
+## Repo conventions
+
+### Languages
+
+- **TypeScript** for the web app (`apps/web/`)
+- **Python 3.10+** for the audio service (`services/audio/`)
+- **JSON** for pattern templates and onomatopoeia tables
+- **YAML** for training data manifests
+
+### Style
+
+**TypeScript:**
+- ESLint with `@typescript-eslint/recommended`
+- Prettier for formatting
+- No `any` unless absolutely necessary (and then comment why)
+- Functional components, hooks, no class components
+- Avoid `useEffect` for derived state — use `useMemo` or compute inline
+
+**Python:**
+- `ruff` for linting (replaces flake8, isort, etc.)
+- `black` for formatting
+- Type hints everywhere
+- Pydantic for data models
+
+### Naming
+
+- Components: `PascalCase.tsx`
+- Hooks: `useCamelCase.ts`
+- Utilities: `camelCase.ts`
+- Constants: `UPPER_SNAKE_CASE`
+- Files match exports (one default export per file preferred)
+
+### Git
+
+**Commit format** (Conventional Commits):
+
+```
+():
+
+[optional body]
+
+[optional footer]
+```
+
+Types:
+- `feat` — new feature
+- `fix` — bug fix
+- `docs` — documentation only
+- `style` — formatting, no code change
+- `refactor` — code change that neither fixes a bug nor adds a feature
+- `perf` — performance improvement
+- `test` — adding or fixing tests
+- `chore` — build, CI, tooling
+
+Examples:
+```
+feat(parser): add onomatopoeia matcher with confidence scoring
+fix(midi): correct GM drum map for china (should be 52, was 49)
+docs(architecture): clarify Reaper integration is not a plugin
+chore(ci): add axe-core to GitHub Actions
+```
+
+**Branch naming:**
+- `feat/`
+- `fix/`
+- `docs/`
+- `chore/`
+
+Examples:
+- `feat/voice-stt`
+- `fix/midi-tempo-header`
+- `docs/architecture-update`
+
+### Pull requests
+
+- One feature per PR
+- PR description explains *what* and *why*
+- Screenshots / screen recordings for UI changes
+- Accessibility check included (NVDA test notes, axe-core results)
+- All CI checks passing
+
+### Accessibility requirements for every PR
+
+If your PR touches the UI:
+
+- [ ] Run axe-core locally (`npm run test:a11y`) — no AA violations
+- [ ] Tab through the changed flow, verify focus order
+- [ ] Test with NVDA (or document why you couldn't)
+- [ ] All interactive elements have ARIA labels
+- [ ] Color contrast meets WCAG AA (4.5:1 for normal text, 3:1 for large)
+- [ ] No information conveyed by color alone
+
+If your PR is docs-only or backend-only, skip these.
+
+## Adding a new pattern template
+
+1. Create `apps/web/data/patterns/.json`
+2. Follow the schema in [`03-data-model.md`](03-data-model.md#pattern-templates)
+3. Test in the web app: `npm run dev`, load the pattern, generate MIDI, drag into Reaper
+4. Verify the description reads naturally with a screenreader
+5. Update the pattern list in [`03-data-model.md`](03-data-model.md#pattern-library--starting-list) if adding to the starting list
+
+## Adding a new onomatopoeia
+
+1. Edit `apps/web/data/onomatopoeia.json`
+2. Add the entry with `patterns` (array of variants), `patternId`, and any defaults
+3. Test the phonetic matcher: `npm run test:onomatopoeia`
+4. Manually speak the variants, verify they map correctly
+
+## Adding training data
+
+1. Place audio in `data/training/oneshots/` or `data/training/loops/`
+2. Preprocess: 44100 Hz mono, normalized to -14 LUFS
+3. Add an entry to `data/training/manifest.yaml` with:
+ - `id`, `path`, `category`, `tags`
+ - `source`, `license`, `duration_seconds`
+ - `bpm` (for loops only)
+4. Run `python services/audio/training/preprocess.py` to regenerate the preprocessed versions
+5. Verify the file plays correctly and the license is documented
+
+## Accessibility testing protocol
+
+Before opening a PR that touches UI:
+
+1. **axe-core:** `npm run test:a11y` — must pass with no violations
+2. **NVDA (Windows + Chrome/Firefox):**
+ - Tab through the entire flow
+ - Verify every state change is announced
+ - Verify focus never disappears
+ - Verify error states are announced
+3. **VoiceOver (macOS + Safari):**
+ - Repeat the NVDA flow
+ - Especially test the grid navigation with VO + arrow keys
+4. **Keyboard-only:**
+ - Complete the entire demo flow without using the mouse
+ - Verify every action has a keyboard equivalent
+5. **High contrast:**
+ - Enable Windows High Contrast Mode
+ - Verify the UI is still usable
+6. **200% zoom:**
+ - Zoom the browser to 200%
+ - Verify no content is cut off or unreachable
+
+If any of these fail, the PR is not ready.
+
+## Communication
+
+- **Issues** — use GitHub Issues for bugs, feature requests, design questions
+- **Discussions** — use GitHub Discussions for broader questions
+- **Discord** — for real-time chat during the hackathon
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the project's MIT license (code) or CC-BY (sample data where applicable).
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
new file mode 100644
index 0000000000000000000000000000000000000000..76e737c48c2a1c7bb757060b420d6863cd485c30
--- /dev/null
+++ b/docs/HANDOFF.md
@@ -0,0 +1,144 @@
+# HANDOFF — stable-jam resume guide
+
+> This file exists so a fresh session can `cd D:/CODE/stable-jam` and pick up
+> exactly where the previous one left off — no loss of context. Read this first,
+> then the linked docs.
+
+**Product:** Jam Buddy (consolidated here as **stable-jam**). "You start playing,
+it joins in." A call-and-response AI music companion using Stable Audio 3,
+built for the Stability AI Challenge at Music Hackspace Montreal (Aug 22–23 2026).
+
+---
+
+## What this repo is (and is not)
+
+`stable-jam/` is the **source + docs + tests** for the Jam Buddy product. It does
+NOT contain the heavy vendored model repos — those stay in the previous
+location and are referenced by env vars (below). Don't expect to `git clone` and
+get a GPU model; the SA3 weights are a separate download.
+
+**Copied from** `D:/CODE/unstable-drums/` (the previous working dir, still intact
+if you need to recover anything). This dir was created to give the hackathon a
+clean, product-accurate home.
+
+## Product state (what actually works, verified this session)
+
+- **Web app** (`apps/web`): hardware-sampler rack UI. Instrument pads (bass/lead/
+ rhythm/synth/drums), Genre/Mood/Tempo knobs, a "Your take" section with BOTH a
+ MIDI file input and an audio file input, JOIN IN + PLAY BOTH buttons, status
+ panel (aria-live) showing "Buddy tempo: N BPM", and an audio player. Every
+ response is saved to `generations/` at the repo root.
+- **SA3 pipeline** (`tools/jam_buddy.py`): the CLI that does the generation.
+ Two input modes:
+ - `--midi take.mid` — detects tempo (exact) + response length from the take,
+ generates a complementary melodic part at that tempo. **Ignores the groove**
+ (SA3 can't parse MIDI notes).
+ - `--wav take.wav` — TRUE audio-to-audio via `init_audio` + `init_noise_level`
+ (default 0.4). The buddy actually HEARS the groove and responds rhythmically.
+ - `--bpm N` / `--model small-music|small-sfx` / `--genre` / `--duration`.
+ - Drums force `small-sfx` (clean isolated hits); everything else `small-music`.
+- **API route** (`apps/web/app/api/jambuddy/route.ts`): POST /api/jambuddy with
+ `{knobs, bpm?, midi?, audio?, duration?}`, shells to the python, returns the
+ WAV + `X-Jam-Buddy-BPM` header. Writes output to `/generations/`.
+- **Prompt builder** (`apps/web/lib/jambuddy/prompt.ts`): pure function building
+ the SA3 prompt from the knobs per the official SA3 prompting guide
+ (TrackType: Instrument, instrument, genre, mood, BPM, studio recording).
+ Negative prompt steers away from a full mix. `MODEL_FOR_INSTRUMENT` maps
+ drums→small-sfx.
+- **Player** (`apps/web/lib/jambuddy/player.ts`): browser Web Audio. `playTogether`
+ renders the MIDI take as oscillators and plays it with the buddy WAV on the
+ same clock. **Note: this was "banked" — the user reported PLAY BOTH only
+ played the generated part, not the MIDI synth. It's parked, not debugged.**
+
+## Verified this session (proof)
+
+- Web: `pnpm run test` -> 31/31 pass (prompt, player, engine, conversation);
+ `tsc --noEmit` clean; page serves the rack.
+- CLI: `jam_buddy.py --midi ` -> detected 158 BPM, 24.3s response matching
+ the take. Audio-to-audio on a 5s take -> ~10s wall, valid WAV (peak 0.43,
+ rms 0.08). A ~378s take times out on CPU — keep audio takes short.
+- The generations dir fills with timestamped WAVs.
+
+## Key environment / paths
+
+SA3 (the engine) now lives IN this repo at `stable-audio-3/`. The venv's
+editable-install `.pth` was repointed to this location (was `D:/CODE/unstable-drums/...`).
+The `/api/jambuddy` route auto-resolves it via `join(repoRoot, "stable-audio-3", ...)`.
+
+| Var | Value |
+|---|---|
+| `JAM_BUDDY_ROOT` | `D:/CODE/stable-jam` (repo root; route walks up to find `tools/jam_buddy.py`) |
+| `JAM_BUDDY_PYTHON` | `D:/CODE/stable-jam/stable-audio-3/.venv/Scripts/python.exe` (the SA3 venv) |
+| SA3 weights | cached in `stable-audio-3/.venv` + HF cache on G:/AI/models/huggingface |
+| `HF_TOKEN` | in old repo's `.env` (needed for gated SA3 model access) — copy if regenerating weights |
+
+The SA3 venv was extended with `mido` and `librosa`
+(`uv pip install --python .../stable-audio-3/.venv/Scripts/python.exe mido librosa`).
+A fresh session must use that venv or recreate it.
+
+## How to run (fresh session)
+
+```bash
+cd D:/CODE/stable-jam
+# 1. install web deps (node_modules was NOT copied)
+cd apps/web && pnpm install && cd ../..
+
+# 2. run tests + typecheck
+cd apps/web && npx vitest run && npx tsc --noEmit
+
+# 3. dev server
+cd apps/web && pnpm dev # -> http://localhost:3000
+
+# 4. python pipeline (SA3 venv is in THIS repo)
+./stable-audio-3/.venv/Scripts/python.exe tools/jam_buddy.py --midi take.mid --instrument bass --out out.wav
+```
+
+## Docs (all in `docs/`)
+
+- `01-vision.md` — Jam Buddy vision (rewritten from the old PatternTalk framing).
+- `02-architecture.md` — note: may still describe the old service layout; the
+ real arch is: Next.js web + `tools/jam_buddy.py` + SA3 venv.
+- `05-accessibility.md` — the a11y strategy (still relevant; the product is
+ screenreader-compatible by design).
+- `06-stable-audio-integration.md` — SA3 setup, audio-to-audio, Vega notes.
+- `HARNESS.md` — agent harness commands + test contract (may still say
+ "PatternTalk" in places; the real product is Jam Buddy).
+
+## What's deliberately NOT in this repo (and why)
+
+- `text2midi/` (2.8G vendored model) — not part of the Jam Buddy product; it
+ stays in the previous working dir if needed.
+- The SA3 weights / `.venv` are inside `stable-audio-3/` here but **gitignored**
+ (large, regenerable, license-gated). See `.gitignore`.
+- `node_modules/`, `.next/` — regenerable, not committed.
+- `generations/*.wav` — gitignored (regenerable output).
+- Source song/GP files + scratch WAVs in the old `tools/` — not product source.
+
+## Pending / next steps (from the last session)
+
+1. **PLAY BOTH** — unbank / fix: the MIDI synth part wasn't audible. Likely the
+ object URL or the oscillator gain. That's the "co-play" wow moment.
+2. **Metadata sidecar** — add a `.json` next to each `generations/*.wav`
+ with prompt / negative / model / BPM / duration / source. Answers "what did it
+ go off on."
+3. **Re-render the docs** `02-architecture.md`, `03-data-model.md`,
+ `04-ux-voice-first.md`, `07-reaper-integration.md` to match Jam Buddy (they
+ still describe the old PatternTalk drum-machine in places).
+4. **LoRA / underfit** (style trainer) is a real path but needs a GPU — not shipped.
+5. **Demo script** — the judge's-eye review (session bg_161745) recommended a
+ 3-min demo script; see `docs/01-vision.md` "What success looks like".
+
+## The judge's review (session `bg_161745` — hackathon judge design review)
+
+Key finding: **docs and product were telling different stories** (PatternTalk vs
+Jam Buddy). That's why this repo was consolidated to `stable-jam`. Scoring
+rubric + recs live in that session. The #1 fix was "kill the PatternTalk framing"
+— largely done in README + 01-vision; the deeper docs still need the same pass.
+
+## The SA3 tempo/time-signature reality (don't re-learn it)
+
+Stable Audio 3 has NO tempo or time-signature conditioning channel. "BPM" is a
+weak semantic hint in the prompt. So "make SA3 follow a time signature" is not
+possible with the stock model. The answer is MIDI-first (timing from MIDI/audio
+detection) + SA3 as the timbre generator. Full reasoning is in the skill ref
+`sa3-no-tempo-control.md`. The LoRA style path is separate.
diff --git a/docs/HARNESS.md b/docs/HARNESS.md
new file mode 100644
index 0000000000000000000000000000000000000000..2f23bffaddd97ae0862055e55cbf33a2731966a5
--- /dev/null
+++ b/docs/HARNESS.md
@@ -0,0 +1,55 @@
+# stable-jam — Agent Harness
+
+**Jam Buddy** — "you start playing, it joins in." An AI music companion for the
+**Music Hackspace Montreal** hackathon (Aug 22–23, 2026, Stability AI challenge).
+A Next.js web rack + a Python SA3 pipeline that listens to a MIDI or audio take
+and responds at your tempo in the instrument you pick.
+
+## Roles
+
+- **Web app** — `apps/web/` (Next.js + TS + Tailwind + Vitest). The hardware
+ sampler rack: instrument pads, genre/mood/tempo knobs, MIDI/audio take input,
+ JOIN IN + PLAY BOTH, status panel, and `generations/` output.
+- **SA3 pipeline** — `tools/jam_buddy.py` (Python). Detects tempo + duration
+ from a MIDI take (or runs audio-to-audio from a WAV), builds the SA3 prompt
+ from the knobs, generates the response WAV via the `stable-audio-3` venv.
+- **Prompt builder** — `apps/web/lib/jambuddy/prompt.ts` (pure, tested).
+- **Player** — `apps/web/lib/jambuddy/player.ts` (Web Audio; play take + buddy
+ together). Note: PLAY BOTH is banked / not fully debugged.
+- **Legacy converters** — `gp5_to_keyswitched_mid.py` / `midi_to_gp5.py` /
+ `midjson_to_mid.py` (GP5↔MIDI tooling from the earlier iteration, still present).
+
+## Commands
+
+```bash
+# Web app (node_modules NOT committed — install first)
+cd apps/web && pnpm install && npx vitest run # 31 tests
+cd apps/web && npx tsc --noEmit # typecheck
+pnpm dev # local Next.js server -> :3000
+
+# Python pipeline (SA3 venv is in THIS repo)
+./stable-audio-3/.venv/Scripts/python.exe tools/jam_buddy.py --midi take.mid --instrument bass --out out.wav
+
+# Conversion CLIs (if needed)
+python gp5_to_keyswitched_mid.py path/to/song.gp5
+```
+
+## Test contract
+
+- Web: `apps/web/__tests__/` — `prompt.test.ts` (prompt builder),
+ `player.test.ts` (midi parsing/player), `engine.test.ts`, `conversation.test.ts`.
+- Python: `tests/` — GP5 conversion against `tests/expected/baseline.yaml`.
+
+## Conventions
+
+- TS for web, Python 3.10+ for audio/conversion.
+- Accessibility-first (screenreader-native): axe-core, NVDA + VoiceOver checks.
+- Conventional Commits, `feat/*`/`fix/*`/`docs/*`/`chore/*` branches.
+
+## Not-in-git (deliberately)
+
+- `stable-audio-3/` + `text2midi/` (vendored models) live in the previous dir;
+ referenced via `JAM_BUDDY_PYTHON`. Don't copy them into this repo.
+- `node_modules/`, `.next/`, `generations/*.wav` — regenerable.
+
+See `docs/HANDOFF.md` for the full resume guide and current product state.
diff --git a/gp5_to_keyswitched_mid.py b/gp5_to_keyswitched_mid.py
new file mode 100644
index 0000000000000000000000000000000000000000..39a230d36f3922e4c548b4ba90855735e2acd9a1
--- /dev/null
+++ b/gp5_to_keyswitched_mid.py
@@ -0,0 +1,1123 @@
+#!/usr/bin/env python3
+"""
+Guitar Pro 5 (.gp5) -> Sforzando Keyswitched MIDI Converter
+============================================================
+
+Reads a .gp5 file, detects common guitar articulations (palm mute, staccato,
+harmonic, slide, bend) on every note of every guitar track, and emits a Type 1
+MIDI file with the corresponding Sforzando keyswitch notes inserted exactly one
+tick before each articulated note.
+
+Bass tracks are skipped by default. Pass --include-bass to also process them.
+
+All guitar tracks of the same instrument kind (e.g. two rhythm guitars) are
+merged into a single MIDI track on a single channel, so Sforzando receives a
+clean instrument-shaped stream.
+
+INSTALL
+-------
+ pip install PyGuitarPro mido
+
+USAGE
+-----
+ python gp5_to_keyswitched_mid.py path/to/song.gp5
+ python gp5_to_keyswitched_mid.py path/to/song.gp5 output.mid
+ python gp5_to_keyswitched_mid.py song.gp5 -b # also process bass
+ python gp5_to_keyswitched_mid.py song.gp5 -V 100 -r # velocity 100, emit sustain reset
+
+Edit the KEYSWITCH_MAP dictionary below to change the keyswitch note numbers
+to match the keyswitches declared in your .sfz instrument.
+"""
+
+import argparse
+import os
+import re
+import sys
+
+import guitarpro
+import mido
+
+
+__all__ = [
+ "KEYSWITCH_MAP",
+ "INCLUDE_BASS",
+ "MERGE_BY_INSTRUMENT",
+ "EMIT_SUSTAIN_RESET",
+ "SUSTAIN_KEYSWITCH",
+ "DEFAULT_VELOCITY",
+ "detect_techniques",
+ "convert",
+ "main",
+]
+
+
+# ---------------------------------------------------------------------------
+# EDITABLE SETTINGS
+# ---------------------------------------------------------------------------
+
+# Map detected technique name -> Sforzando keyswitch MIDI note number.
+# THIS IS A TEMPLATE. The actual keyswitch numbers depend on the SFZ
+# instrument you load in Sforzando. Verify each value by:
+# 1. Open the .sfz file in a text editor.
+# 2. Search for the technique name (e.g. 'sustain', 'palm_mute').
+# 3. Find the sw_lokey/sw_hikey range that selects it; the keyswitch
+# note is the value inside that range.
+# 4. Or just play the note in Sforzando and listen.
+# Below is one plausible mapping for Unreal Instruments METAL-GTX, based
+# on the reabank metadata in the .sfz. Treat it as a starting point, not
+# a guarantee. You can override at the CLI with --keyswitch-map FILE.
+# sustain F1 / 17 — Sustain Down
+# palm_mute G#1 / 20 — Palm Mute Down
+# slide C1 / 24 — Slide Up
+# harmonic A0 / 9 — Natural Harmonics
+# bend G6 / 91 — Bending semi
+# Staccato is intentionally NOT mapped: on a real guitar it is a
+# duration/timing property, not a separate articulation. The original
+# note's envelope is preserved by NOT emitting a staccato key.
+KEYSWITCH_MAP = {
+ "sustain": 17,
+ "palm_mute": 20,
+ "harmonic": 9,
+ "slide_up": 24,
+ "slide_down": 23,
+ "slide_in": 27,
+ "hammer": 26,
+ "bend": 91,
+}
+
+# The keyswitch note number used to (re)assert the sustain/clean state.
+# SusUp (F#0 = 18) is the cleaner-sounding of the two sustain keys in
+# METAL-GTX — SusDown (F0 = 17) has a noticeable palm-mute-like quality.
+# When a note in the GP file has no articulation, the script emits this
+# key at the same tick so the sampler always knows what to play.
+SUSTAIN_KEYSWITCH = 18
+
+# Length of a keyswitch note (in ticks, at 960 PPQ). 960 ticks = one quarter
+# note, which is large enough to be visible in any DAW's piano roll and
+# long enough that no host will drop it. The actual delivered duration is
+# further clamped to the gap until the next event on the same channel so
+# two consecutive keyswitches never overlap.
+KEYSWITCH_DURATION_TICKS = 960
+
+# MIDI note numbers used as keyswitches. Used to truncate each keyswitch
+# note_off to the gap before the next event on the same channel.
+
+# General MIDI drum programs. A track is treated as drums if it uses one of
+# these programs on a non-channel-9 channel, OR if its name says "drum".
+_GM_DRUM_PROGRAMS = set(range(8, 17)) # 8..16 (drums 8..15 + reverse cymbal 16)
+
+# General MIDI guitar programs (24..31 cover acoustic/electric/clean/distortion/
+# overdriven/lead/etc., plus the muted guitar 28 and overdriven 29).
+_GM_GUITAR_PROGRAMS = set(range(24, 32))
+
+# General MIDI bass programs (32..39).
+_GM_BASS_PROGRAMS = set(range(32, 40))
+
+_GUITAR_NAME_TOKENS = (
+ "guitar", "rhythm", "lead", "clean", "harmony", "distort",
+)
+_BASS_NAME_TOKENS = (
+ "bass", "fretless", "slap",
+)
+_DRUM_NAME_TOKENS = (
+ "drum", "kit", "percussion",
+)
+
+# Program numbers that, when seen alone, are ambiguous (e.g. program 0 =
+# "Acoustic Grand Piano" in GM, but GP5 also uses 0 for drums when the
+# channel is 9). The classifier consults the track name in that case.
+
+# Process bass tracks too. Override at the CLI with --include-bass.
+INCLUDE_BASS = False
+
+# When False (default), every included GP track becomes its own output MIDI
+# track on its own channel, the channel the track had in the Guitar Pro file.
+# This is the right mode for selecting a single track by name (--only) and
+# for the common case of driving several Sforzando instances at once.
+# Set to True to merge tracks of the same instrument kind into one MIDI
+# track on a single deterministic channel.
+MERGE_BY_INSTRUMENT = False
+
+# When True (default), every non-articulated note in the GP file emits a
+# sustain key (SUSTAIN_KEYSWITCH) at the same tick. This guarantees the
+# sampler is always in a known state, regardless of what the previous
+# sample held. Turn off with --no-sustain-reset on the CLI if you want
+# explicit keyswitches only at articulated notes.
+EMIT_SUSTAIN_RESET = True
+
+# When True (default), trim leading silence so the first event lands at
+# tick 0. Empty intro bars in the GP file are removed from the MIDI
+# output. Useful when the GP arrangement has a long count-in or rests
+# before the first note.
+TRIM_SILENCE = True
+
+# Velocity used when a note has no explicit velocity or its value is 0.
+DEFAULT_VELOCITY = 100
+
+
+# ---------------------------------------------------------------------------
+# TRACK CLASSIFICATION
+# ---------------------------------------------------------------------------
+# PyGuitarPro does not always populate `track.instrument.kind`. The reliable
+# signals are: (1) the GM program number on `track.channel` (mute-proof, this
+# is what the .gp5 file actually stores), and (2) the track name. We use
+# both so that contrived tracks ("Julien Bass") are recognised even when the
+# program number happens to be 0.
+
+def _channel_1based(gp_track):
+ """Return the 1-based MIDI channel of a GP track, or None if unknown."""
+ ch = getattr(gp_track, "channel", None)
+ raw = getattr(ch, "channel", None) if ch is not None else None
+ if raw is None:
+ return None
+ return int(raw) + 1 # GP is 0-based, but here we want 1-based for the
+ # common "channel 9 = drums" convention.
+
+def _program_number(gp_track):
+ """Return the GM program number 0-127 for a GP track, or None if unknown."""
+ ch = getattr(gp_track, "channel", None)
+ return getattr(ch, "instrument", None) if ch is not None else None
+
+def _track_name(gp_track):
+ n = getattr(gp_track, "name", None) or ""
+ return n.strip().lower()
+
+def _classify_track(gp_track):
+ """
+ Return (category, kind_label) for a GP track.
+
+ category is one of: 'guitar', 'bass', 'drums', 'other'.
+ kind_label is a short string used for channel hashing (e.g.
+ 'electricGuitar', 'fretlessBass').
+ """
+ name = _track_name(gp_track)
+ name_lower = name.lower()
+ chan_1b = _channel_1based(gp_track)
+ prog = _program_number(gp_track)
+
+ # Channel 9 in GP5 is always drums in the files we care about.
+ if chan_1b == 10:
+ return ("drums", "drums")
+
+ # Name-based overrides win early — GP5 files often use inconsistent
+ # program numbers (e.g. program 0 for "Julien Bass") and the name is
+ # the most reliable signal.
+ if any(tok in name_lower for tok in _DRUM_NAME_TOKENS):
+ return ("drums", "drums")
+ if any(tok in name_lower for tok in _BASS_NAME_TOKENS):
+ return ("bass", _classify_bass_label(name, prog))
+ if any(tok in name_lower for tok in _GUITAR_NAME_TOKENS):
+ return ("guitar", _classify_guitar_label(name, prog))
+
+ # Fall back to program number.
+ if prog is not None:
+ if prog in _GM_DRUM_PROGRAMS:
+ return ("drums", "drums")
+ if prog in _GM_BASS_PROGRAMS:
+ return ("bass", _classify_bass_label(name, prog))
+ if prog in _GM_GUITAR_PROGRAMS:
+ return ("guitar", _classify_guitar_label(name, prog))
+
+ return ("other", "other")
+
+
+_GUITAR_PROGRAM_LABEL = {
+ 24: "nylonGuitar", 25: "steelGuitar", 26: "jazzGuitar",
+ 27: "cleanGuitar", 28: "mutedGuitar", 29: "overdrivenGuitar",
+ 30: "distortionGuitar", 31: "harmonicsGuitar",
+}
+_BASS_PROGRAM_LABEL = {
+ 32: "acousticBass", 33: "fingeredBass", 34: "pickedBass",
+ 35: "fretlessBass", 36: "slapBass1", 37: "slapBass2",
+ 38: "synthBass1", 39: "synthBass2",
+}
+
+
+def _classify_guitar_label(name, prog):
+ if prog is not None and prog in _GUITAR_PROGRAM_LABEL:
+ return _GUITAR_PROGRAM_LABEL[prog]
+ # Press fragile names: 'lead' / 'rhythm' / 'clean' / 'harmony' / 'distort'
+ nl = name.lower()
+ for tok in ("lead", "rhythm", "clean", "harmony", "distort", "acoustic"):
+ if tok in nl:
+ return f"{tok}Guitar"
+ return "electricGuitar"
+
+
+def _classify_bass_label(name, prog):
+ if prog is not None and prog in _BASS_PROGRAM_LABEL:
+ return _BASS_PROGRAM_LABEL[prog]
+ return "bassGuitar"
+
+
+# Reserve channel 9 (GM percussion) for drums. Sforzando expects the
+# instrument on a pitched channel.
+_RESERVED_CHANNELS = {9}
+
+
+def _midi_channel_for(kind_name):
+ """
+ Return a deterministic MIDI channel 0-15 for a given instrument kind.
+ Channel 9 is skipped (GM percussion). Identical kind names always resolve
+ to the same channel, so merging tracks of the same kind keeps the
+ resulting overlapping notes on the same channel.
+ """
+ if not kind_name:
+ return 0
+ h = sum(ord(c) for c in kind_name)
+ ch = h % 16
+ if ch in _RESERVED_CHANNELS:
+ ch = (ch + 1) % 16
+ return ch
+
+
+def _bucket_tracks_by_kind(guitar_tracks, bass_included):
+ """
+ Group included tracks into (kind_label, [gp_track, ...]) buckets.
+
+ Guitars are split by their kind label (e.g. 'cleanGuitar' becomes its
+ own bucket), so a file with rhythm + lead + clean guitars gets three
+ separate MIDI tracks. Bass tracks, when included, land in a single
+ 'Bass' bucket.
+
+ Returns a list of (bucket_label, kind_name, [gp_track...]) tuples in the
+ order they should appear in the output MIDI.
+ """
+ guitar_buckets = {}
+ for t in guitar_tracks:
+ kind = _classify_track(t)[1]
+ guitar_buckets.setdefault(kind, []).append(t)
+
+ buckets = []
+ for kind in sorted(guitar_buckets):
+ n = len(guitar_buckets[kind])
+ if len(guitar_buckets) == 1 and len(guitar_tracks) > 1:
+ label = "Guitar"
+ elif n == 1:
+ label = f"Guitar: {kind}"
+ else:
+ label = f"Guitar: {kind}"
+ buckets.append((label, kind, guitar_buckets[kind]))
+
+ if bass_included:
+ if len(bass_included) == 1:
+ label = "Bass"
+ kind = _classify_track(bass_included[0])[1]
+ else:
+ kind = _classify_track(bass_included[0])[1]
+ label = f"Bass: {kind}"
+ buckets.append((label, kind, bass_included))
+
+ return buckets
+
+
+# ---------------------------------------------------------------------------
+# FILENAME HELPERS
+# ---------------------------------------------------------------------------
+_FILENAME_FORBIDDEN = re.compile(r"[\\/:\*\?\"<>\|]+")
+_FILENAME_WHITESPACE = re.compile(r"\s+")
+_FILENAME_EDGEPUNCT = "\u2024\u2025\u2026\ufeff. "
+
+
+def _sanitize_filename(name, default="output"):
+ """Make a track name safe for use as a filename on Windows / macOS / Linux."""
+ cleaned = _FILENAME_FORBIDDEN.sub(" ", name or "")
+ cleaned = _FILENAME_WHITESPACE.sub(" ", cleaned).strip()
+ cleaned = cleaned.rstrip(". ")
+ cleaned = cleaned.strip(_FILENAME_EDGEPUNCT)
+ if not cleaned:
+ cleaned = default
+ return cleaned
+
+
+def _song_subfolder(input_path):
+ """
+ Derive a subfolder name from the .gp5 input file. The folder holds
+ per-track MIDI files so exports from different songs don't mix in
+ the same directory.
+ """
+ head, tail = os.path.split(input_path)
+ base, _ = os.path.splitext(tail)
+ return _sanitize_filename(base, default="song")
+
+
+def _auto_output_path(only_filters, input_path, only_index=None):
+ """
+ Build a default output path. Files are organized as
+ /_keyswitched.mid so exports from
+ different songs don't collide in the same directory.
+
+ Rules:
+ - No --only filter: fall back to /output_keyswitched.mid.
+ - --only matches exactly one track (or --only-index picks one):
+ /_keyswitched.mid.
+ - --only matches zero tracks: fall back to the default; the caller
+ will report 'no tracks found' anyway.
+ - --only matches multiple tracks and no --only-index: return None
+ to signal the caller to require an explicit output path.
+ """
+ subfolder = _song_subfolder(input_path)
+ if not only_filters:
+ return os.path.join(subfolder, "output_keyswitched.mid")
+ song = guitarpro.parse(input_path)
+ matched = [t for t in song.tracks
+ if any(f.lower() in _track_name(t) for f in only_filters)]
+ n_matched = len(matched)
+ if only_index is not None:
+ if 1 <= only_index <= n_matched:
+ matched = [matched[only_index - 1]]
+ else:
+ return None
+ if len(matched) == 1:
+ # When --only matched multiple tracks but --only-index picked one,
+ # include the index in the filename so the file is uniquely named.
+ suffix = f" ({only_index})" if only_index is not None and n_matched > 1 else ""
+ return os.path.join(
+ subfolder,
+ f"{_sanitize_filename(matched[0].name)}{suffix}_keyswitched.mid",
+ )
+ if len(matched) == 0:
+ return os.path.join(subfolder, "output_keyswitched.mid")
+ return None # ambiguous: caller must supply an output path
+
+
+# ---------------------------------------------------------------------------
+# ARTICULATION DETECTION
+# ---------------------------------------------------------------------------
+def detect_techniques(note):
+ """
+ Return a list of (technique_name, params_dict) tuples found on a note.
+
+ The technique name is the key into KEYSWITCH_MAP. A technique that
+ doesn't need to be split (e.g. palm_mute, harmonic) returns an empty
+ params dict. Slides return a 'direction' parameter ('up' or 'down')
+ based on the source pitch versus the next note on the same string.
+ """
+ techniques = []
+ effect = getattr(note, "effect", None)
+ if effect is None:
+ return techniques
+
+ # Palm mute: boolean flag.
+ if getattr(effect, "palmMute", False):
+ techniques.append(("palm_mute", {}))
+
+ # Staccato: boolean flag.
+ if getattr(effect, "staccato", False):
+ techniques.append(("staccato", {}))
+
+ # Harmonic: HarmonicEffect object when present (natural, artificial,
+ # pinch, tap, semi, feedback). We collapse all harmonic flavors to one
+ # 'harmonic' keyswitch; split by effect.harmonic.type if your SFZ needs
+ # finer resolution.
+ if getattr(effect, "harmonic", None) is not None:
+ techniques.append(("harmonic", {}))
+
+ # Hammer-on / pull-off: boolean flag. The METAL-GTX SFZ has a
+ # dedicated Hammer-On patch triggered by keyswitch D1 (MIDI 26).
+ if getattr(effect, "hammer", False):
+ techniques.append(("hammer", {}))
+
+ # Grace notes: stored as a GraceEffect on the note's effect. The
+ # transition field tells us whether the grace slides, hammers, or
+ # bends into the main note. In METAL-GTX, slide-in graces map to
+ # D#1 (MIDI 27) and bend-in graces map to the bend keyswitch.
+ grace = getattr(effect, "grace", None)
+ if grace is not None:
+ transition = getattr(grace, "transition", None)
+ if transition is not None:
+ t_name = getattr(transition, "name", str(transition))
+ if t_name == "slide":
+ techniques.append(("slide_in", {}))
+ elif t_name == "hammer":
+ techniques.append(("hammer", {}))
+ elif t_name == "bend":
+ techniques.append(("bend", {}))
+
+ # Slides: iterable of slide types. Direction is determined by the
+ # caller (see _resolve_slide_direction, which compares the slide
+ # note's pitch to the next note on the same string). If direction
+ # can't be determined, the slide defaults to 'slide_up'.
+ slides = getattr(effect, "slides", None)
+ if slides is not None:
+ try:
+ if len(slides) > 0:
+ techniques.append(("slide", {}))
+ except TypeError:
+ techniques.append(("slide", {}))
+
+ # Bend: BendEffect with a `points` list when present.
+ if getattr(effect, "bend", None) is not None:
+ techniques.append(("bend", {}))
+
+ return techniques
+
+
+def _resolve_slide_direction(note, track_notes, note_index):
+ """
+ Determine whether a slide note goes up or down.
+
+ The PYGuitarPro note carries a slide flag but not the target pitch.
+ The target is the next note (in time) on the same string. The caller
+ passes a flat list of all notes in the track and the current note's
+ index in that list.
+
+ Returns 'up', 'down', or None (when direction cannot be determined).
+ """
+ if note.string is None:
+ return None
+ src_pitch = note.realValue
+ # Look ahead for the next note on the same string.
+ for later in track_notes[note_index + 1:]:
+ if getattr(later, "string", None) == note.string:
+ if later.realValue > src_pitch:
+ return "up"
+ if later.realValue < src_pitch:
+ return "down"
+ return None # same pitch: ambiguous
+ return None
+
+
+def _expand_slide(tech, params, direction):
+ """
+ Resolve a 'slide' technique into 'slide_up' or 'slide_down' based on
+ the pre-computed direction. For other techniques, return the name
+ unchanged so the keyswitch-map lookup works.
+ """
+ if tech != "slide":
+ return tech
+ if direction == "down":
+ return "slide_down"
+ return "slide_up" # default + 'up'
+
+
+def _build_tie_chain_map(track):
+ """
+ Walk every note in the track and compute the absolute end-tick of
+ each note's envelope. Tied notes share the same attack as their
+ progenitor, so the progenitor's note_off is set to the end of the
+ last tied continuation. The map is keyed by id(note).
+
+ Returns a dict {id(note): end_tick} where end_tick is the absolute
+ tick at which the note's note_off should fire.
+ """
+ note_end = {}
+ # Walk beats in time order, tracking the most recent non-tied note
+ # on each (string, pitch) pair.
+ active = {} # (string, pitch) -> id(note)
+ for measure in track.measures:
+ for voice in measure.voices:
+ if voice.isEmpty:
+ continue
+ for beat in voice.beats:
+ beat_start = int(beat.start)
+ beat_dur = int(beat.duration.time)
+ for note in beat.notes:
+ if note.type == guitarpro.NoteType.dead:
+ continue
+ key = (note.string, note.realValue)
+ note_dur = int(round(beat_dur * float(note.durationPercent)))
+ if note.type == guitarpro.NoteType.tie:
+ # Continuation of the active note on this key.
+ progenitor = active.get(key)
+ if progenitor is not None:
+ # Extend the progenitor's end tick.
+ note_end[progenitor] = beat_start + note_dur
+ # Note: tied notes themselves don't get their own
+ # note_on/note_off — they share the attack.
+ else:
+ # New attack. Register it as the active note on
+ # this key, and finalize its end tick (for now).
+ note_end[id(note)] = beat_start + note_dur
+ active[key] = id(note)
+ return note_end
+
+
+# ---------------------------------------------------------------------------
+# CONVERSION
+# ---------------------------------------------------------------------------
+def _clamp(value, lo, hi):
+ """Clamp an int-castable value to [lo, hi]."""
+ return max(lo, min(hi, int(value)))
+
+
+def convert(gp5_path, output_path="output_keyswitched.mid",
+ keyswitch_map=None, include_bass=None,
+ emit_sustain_reset=None, trim_silence=None,
+ fallback_velocity=None, only=None, only_index=None, verbose=True):
+ """
+ Parse a Guitar Pro 5 file and write a keyswitched MIDI file.
+
+ Parameters
+ ----------
+ gp5_path : str
+ Path to the .gp5 input file.
+ output_path : str
+ Path for the generated MIDI file. Default: 'output_keyswitched.mid'.
+ keyswitch_map : dict, optional
+ Override the default technique -> keyswitch note mapping.
+ include_bass : bool, optional
+ If True, also process bass tracks. Defaults to INCLUDE_BASS.
+ emit_sustain_reset : bool, optional
+ If True, emit a sustain keyswitch (SUSTAIN_KEYSWITCH) at tick 0 of
+ every processed track. Defaults to EMIT_SUSTAIN_RESET.
+ fallback_velocity : int, optional
+ Velocity used when a note has no explicit velocity. Defaults to
+ DEFAULT_VELOCITY.
+ only : str | iterable[str] | None, optional
+ If given, only GP tracks whose name contains the supplied substring
+ (case-insensitive) are considered. Pass a list for multiple filters.
+ verbose : bool
+ If True, print a summary of which tracks were processed / skipped.
+ """
+ if keyswitch_map is None:
+ keyswitch_map = dict(KEYSWITCH_MAP)
+ if include_bass is None:
+ include_bass = INCLUDE_BASS
+ if emit_sustain_reset is None:
+ emit_sustain_reset = EMIT_SUSTAIN_RESET
+ if trim_silence is None:
+ trim_silence = TRIM_SILENCE
+ if fallback_velocity is None:
+ fallback_velocity = DEFAULT_VELOCITY
+ if only is None:
+ only_filters = None
+ elif isinstance(only, str):
+ only_filters = [only]
+ else:
+ only_filters = list(only)
+ if only_index is None:
+ only_index = None # explicit
+ # When only_index is used, switch to exact-match filtering so the
+ # picked track is unique (substring match would still match the
+ # other same-named tracks).
+ only_exact = False
+ only_track = None # pinned track object when only_index is set
+
+ song = guitarpro.parse(gp5_path)
+
+ if only_filters is not None and only_index is not None:
+ matched = [t for t in song.tracks
+ if any(f.lower() in _track_name(t).lower() for f in only_filters)]
+ if 1 <= only_index <= len(matched):
+ # Pin to a specific track by id() so the partition loop
+ # can match exactly one track (since name-based filters would
+ # still match both same-named tracks).
+ only_filters = None # disable name filter
+ only_track = matched[only_index - 1]
+ else:
+ print(f"ERROR: --only-index {only_index} out of range "
+ f"(only {len(matched)} matches found)", file=sys.stderr)
+ return None # let caller handle ambiguous / out-of-range
+
+ # PyGuitarPro stores timing on the parent beat:
+ # beat.start -> absolute tick of the beat
+ # beat.duration.time -> duration in ticks at 960 PPQ (already
+ # accounts for dotted/tuplet)
+ # note.durationPercent -> how much of the beat the note occupies
+ # The MIDI file uses 960 ticks per beat to match that domain exactly,
+ # so the 1-tick keyswitch offset is preserved precisely.
+ mid = mido.MidiFile(type=1, ticks_per_beat=960)
+
+ # ---- Track 0: conductor (tempo + time signature) --------------------
+ conductor = mid.add_track("Conductor")
+ # mido's set_tempo takes microseconds per quarter note, not BPM.
+ # Guitar Pro stores the song tempo as BPM, so we convert.
+ bpm = int(song.tempo) if getattr(song, "tempo", None) else 120
+ tempo = int(60_000_000 / bpm)
+ conductor.append(mido.MetaMessage("set_tempo", tempo=tempo, time=0))
+
+ if song.tracks and song.tracks[0].measures:
+ first_ts = song.tracks[0].measures[0].timeSignature
+ if first_ts is not None:
+ # `denominator` is a Duration struct whose `.value` is the
+ # actual denominator (4 for quarter, 8 for eighth, 16 for
+ # sixteenth). It is NOT a 2** enum like in some older GP
+ # versions. The MIDI `time_signature` meta uses the same
+ # denominator number directly.
+ actual_denom = int(first_ts.denominator.value)
+ conductor.append(mido.MetaMessage(
+ "time_signature",
+ numerator=int(first_ts.numerator),
+ denominator=actual_denom,
+ time=0,
+ ))
+
+ # ---- Partition tracks: included (guitar / bass) vs. skipped --------
+ included_guitar = []
+ included_bass = []
+ skipped_tracks = []
+ for gp_track in song.tracks:
+ name = _track_name(gp_track)
+ raw_name = getattr(gp_track, "name", None) or ""
+ if only_track is not None:
+ # Only the pinned track passes.
+ if gp_track is not only_track:
+ skipped_tracks.append(gp_track)
+ continue
+ elif only_filters is not None:
+ if only_exact:
+ # Match against the raw (case-preserving) track name.
+ if raw_name not in only_filters:
+ skipped_tracks.append(gp_track)
+ continue
+ else:
+ if not any(f.lower() in name for f in only_filters):
+ skipped_tracks.append(gp_track)
+ continue
+ category, _kind = _classify_track(gp_track)
+ if category == "guitar":
+ included_guitar.append(gp_track)
+ elif category == "bass" and include_bass:
+ included_bass.append(gp_track)
+ else:
+ skipped_tracks.append(gp_track)
+
+ if MERGE_BY_INSTRUMENT:
+ buckets = _bucket_tracks_by_kind(included_guitar, included_bass)
+ else:
+ # One output MIDI track per GP track. The channel the track had
+ # in the .gp5 file is preserved on the message events; the MIDI
+ # track itself is named after the GP track.
+ buckets = []
+ for t in included_guitar + included_bass:
+ ch_obj = getattr(t, "channel", None)
+ raw_ch = getattr(ch_obj, "channel", None)
+ channel = int(raw_ch) if raw_ch is not None else 0
+ if channel == 9: # never reuse GM percussion
+ channel = 0
+ label = t.name or "Guitar"
+ buckets.append((label, f"track:{label}", [t]))
+
+ if not buckets:
+ if verbose:
+ print("No guitar"
+ + ("/bass" if include_bass else "")
+ + " tracks found in this file.")
+ parent = os.path.dirname(output_path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ mid.save(output_path)
+ return output_path
+
+ # ---- Per-bucket processing ----------------------------------------
+ # Each bucket produces exactly one MIDI track on a single channel.
+ # Notes from multiple GP tracks within a bucket are merged in absolute
+ # tick space, then sorted, so the 1-tick-before keyswitch relationship
+ # is preserved across tracks.
+ written_buckets = []
+ for bucket_idx, (label, kind_name, tracks) in enumerate(buckets, start=1):
+ if MERGE_BY_INSTRUMENT:
+ channel = _midi_channel_for(kind_name)
+ else:
+ # When unmerged, the channel is the GP track's own channel so
+ # the output matches how the track was originally authored.
+ ch_obj = getattr(tracks[0], "channel", None)
+ raw_ch = getattr(ch_obj, "channel", None)
+ channel = int(raw_ch) if raw_ch is not None else 0
+ if channel == 9:
+ channel = 0
+
+ # Collect (absolute_tick, mido.Message) pairs for this bucket.
+ events = []
+ # Set of (tick, channel) where a default sustain key has already
+ # been queued for this bucket. Prevents one sustain key per
+ # note in a chord (e.g. notes 50, 45, 38 at one tick emit a
+ # single sustain key, not three).
+ pending_default_sustain = set()
+
+ for gp_track in tracks:
+ # Pre-collect every note in the track in time order. After
+ # the full list is built, compute slide direction for each
+ # note that has a slide — direction is determined by comparing
+ # the slide note's pitch to the next note on the same string
+ # (the slide target).
+ track_notes = []
+ for measure in gp_track.measures:
+ for voice in measure.voices:
+ if voice.isEmpty:
+ continue
+ for beat in voice.beats:
+ for note in beat.notes:
+ track_notes.append(note)
+ slide_direction = {}
+ for idx, note in enumerate(track_notes):
+ if getattr(note.effect, "slides", None):
+ slide_direction[id(note)] = _resolve_slide_direction(
+ note, track_notes, idx)
+ # Pre-compute the end-tick of every note's envelope, taking
+ # tied continuations into account. Without this, a note with
+ # tied continuations gets its note_off at the end of the
+ # first beat, cutting the tail short.
+ note_end = _build_tie_chain_map(gp_track)
+
+ for measure in gp_track.measures:
+ for voice in measure.voices:
+ if voice.isEmpty:
+ continue
+ for beat in voice.beats:
+ for note in beat.notes:
+ # Tied continuations do not replay a pitched
+ # note (they share the previous attack's
+ # envelope), but they CAN carry new
+ # articulations — e.g. a tied note with a
+ # bend means the bend starts on the tied
+ # side, not the original attack. Emit
+ # keyswitches for tied notes with techniques,
+ # but skip the pitched note_on/note_off.
+ if note.type == guitarpro.NoteType.dead:
+ continue
+ tied_loud = (note.type == guitarpro.NoteType.tie)
+ techniques_early = []
+ if tied_loud:
+ techniques_early = [
+ (t, p) for (t, p) in detect_techniques(note)
+ if keyswitch_map.get(_expand_slide(t, p, slide_direction.get(id(note)))) is not None
+ ]
+ if not techniques_early:
+ continue
+ # Fall through: emit keyswitches only.
+
+ # Timing lives on the beat; the note can only
+ # shorten its slot (durationPercent). When the
+ # note has a tied continuation, the note_end
+ # map gives the absolute end-tick of the
+ # entire tie chain (the note's envelope
+ # extends through all tied continuations).
+ start = int(beat.start)
+ note_end_tick = note_end.get(id(note))
+ if note_end_tick is not None:
+ duration = note_end_tick - start
+ else:
+ duration = int(round(beat.duration.time
+ * float(note.durationPercent)))
+ if duration < 1:
+ duration = 1
+ pitch = _clamp(note.realValue, 0, 127)
+
+ velocity = int(getattr(note, "velocity", 0) or 0)
+ if velocity < 1:
+ velocity = fallback_velocity
+ velocity = _clamp(velocity, 1, 127)
+
+ # ---- Emit explicit keyswitches per note ----
+ # Every note gets an explicit keyswitch(es)
+ # at `start` (the SAME tick as the pitched
+ # note's note_on). The keyswitch is appended
+ # to the event list BEFORE the pitched note_on,
+ # so within the same tick it arrives first in
+ # the MIDI stream — Sforzando accepts this
+ # and behaves identically to a 1-tick-before
+ # trigger. The natural off time is start +
+ # KEYSWITCH_DURATION_TICKS (a quarter note so
+ # it shows up in the DAW piano roll), then a
+ # later post-process pass clamps it to the
+ # next event on the same channel so two
+ # consecutive keyswitches never overlap.
+ #
+ # If the note has no articulation (e.g. a
+ # clean chord) AND the user has enabled
+ # EMIT_SUSTAIN_RESET, tag this note as
+ # 'default-sustain-candidate'. Per-tick, we
+ # emit a single default sustain key on the
+ # first such note — this guarantees the sampler
+ # is always in a known state, regardless of
+ # what the previous sample held (which fixes
+ # the common case where a palm-mute figure
+ # ends and the next clean chord is played in
+ # the wrong still-palm-muted state), and
+ # avoids duplicate sustain keys on chords.
+ techniques = detect_techniques(note)
+ if any(ks_notes_emitted for _ in []): # placeholder
+ pass
+ ks_notes_emitted = []
+ has_articulation = False
+ for tech, params in techniques:
+ if tech == "slide":
+ direction = slide_direction.get(id(note))
+ tech = "slide_down" if direction == "down" else "slide_up"
+ ks_note = keyswitch_map.get(tech)
+ if ks_note is None:
+ continue
+ if ks_note == 26:
+ import sys as _sys
+ ks_note = _clamp(ks_note, 0, 127)
+ ks_notes_emitted.append(ks_note)
+ has_articulation = True
+
+ if not has_articulation and emit_sustain_reset:
+ # Defer: emit one default sustain per
+ # (tick, channel) below, after the loop.
+ if (start, channel) not in pending_default_sustain:
+ pending_default_sustain.add((start, channel))
+ ks_notes_emitted.append(_clamp(SUSTAIN_KEYSWITCH, 0, 127))
+
+ for ks_note in ks_notes_emitted:
+ ks_on = start
+ ks_off = ks_on + KEYSWITCH_DURATION_TICKS
+ events.append((
+ ks_on,
+ mido.Message("note_on", channel=channel,
+ note=ks_note, velocity=velocity,
+ time=0),
+ ))
+ events.append((
+ ks_off,
+ ("keyswitch_off", ks_note, channel),
+ ))
+
+ # ---- The actual pitched note --------------
+ # Skip note_on/note_off for tied notes (the
+ # pitch is already sounding from the
+ # original attack).
+ if not tied_loud:
+ events.append((
+ start,
+ mido.Message("note_on", channel=channel,
+ note=pitch, velocity=velocity,
+ time=0),
+ ))
+ events.append((
+ start + duration,
+ mido.Message("note_off", channel=channel,
+ note=pitch, velocity=0,
+ time=0),
+ ))
+ if not events:
+ if verbose:
+ print(f" [{bucket_idx}] {label}: no notes, skipping")
+ continue
+
+ # ---- Resolve keyswitch_off placeholders into real note_off ----
+ # ---- messages with clamped ticks. Three constraints: ----
+ # ---- 1. keyswitch note_off must arrive at or before ----
+ # ---- key_on_tick + KEYSWITCH_DURATION_TICKS ----
+ # ---- 2. successive keyswitch note_on on the same channel/note-
+ # ---- is treated as a re-trigger: the previous key closes ----
+ # ---- *at the tick of the next note_on* and a new key opens. -
+ # ---- 3. Any non-sentinel note_on / note_off on the same ----
+ # ---- channel/note also closes the open key (same as 2). ----
+ keyswitch_notes = {v for v in KEYSWITCH_MAP.values() if v is not None}
+ keyswitch_notes.add(SUSTAIN_KEYSWITCH)
+ import sys as _sys
+ n26_post_loop = sum(1 for t, p in events
+ if hasattr(p, 'note') and p.note == 26
+ and p.type == 'note_on' and getattr(p, 'velocity', 0) > 0)
+ events.sort(key=lambda e: e[0])
+
+ # DEBUG: count 26 in events
+ n26 = sum(1 for t, p in events
+ if hasattr(p, 'note') and p.note == 26
+ and p.type == 'note_on' and getattr(p, 'velocity', 0) > 0)
+ n26_tup = sum(1 for t, p in events
+ if isinstance(p, tuple) and p[0] == 'keyswitch_off' and p[1] == 26)
+ if n26 > 0:
+ pass
+ def _close_oldest_open(ch, note, at_tick, out):
+ stack = open_keyswitch_stacks.get((ch, note))
+ if not stack:
+ return
+ ks_on = stack.pop(0)
+ if not open_keyswitch_stacks[(ch, note)]:
+ del open_keyswitch_stacks[(ch, note)]
+ cap = ks_on + KEYSWITCH_DURATION_TICKS
+ clamped = min(at_tick, cap)
+ if clamped < ks_on:
+ clamped = ks_on
+ out.append((clamped, mido.Message(
+ "note_off", channel=ch, note=note, velocity=0, time=0)))
+
+ resolved = []
+ open_keyswitch_stacks = {}
+ for tick, payload in events:
+ if isinstance(payload, tuple) and payload and payload[0] == "keyswitch_off":
+ _, ks_note, ch = payload
+ _close_oldest_open(ch, ks_note, tick, resolved)
+ elif isinstance(payload, mido.Message) and payload.type == "note_on" \
+ and payload.note in keyswitch_notes:
+ # Close any still-open key on the same channel/note at
+ # this tick (re-trigger semantics).
+ _close_oldest_open(payload.channel, payload.note, tick, resolved)
+ resolved.append((tick, payload))
+ open_keyswitch_stacks.setdefault(
+ (payload.channel, payload.note), []).append(tick)
+ elif isinstance(payload, mido.Message) and payload.type == "note_off" \
+ and payload.note in keyswitch_notes:
+ # An explicit note_off from the emitter (e.g. the user
+ # inserted one) closes the open key.
+ _close_oldest_open(payload.channel, payload.note, tick, resolved)
+ else:
+ resolved.append((tick, payload))
+ events = resolved
+
+ midi_track = mid.add_track(label)
+ last_tick = 0
+ for abs_tick, msg in events:
+ msg.time = max(0, abs_tick - last_tick)
+ last_tick = abs_tick
+ midi_track.append(msg)
+
+ written_buckets.append((label, kind_name, len(tracks), channel))
+
+ # ---- Trim leading silence -------------------------------------------
+ # If the user asked for trim_silence, find the earliest absolute tick
+ # across all tracks and shift every event so the first one lands at
+ # tick 0. This removes empty intro bars.
+ if trim_silence:
+ earliest = None
+ for tr in mid.tracks:
+ abs_t = 0
+ for msg in tr:
+ abs_t += msg.time
+ if isinstance(msg, mido.MetaMessage) and msg.type == "time_signature":
+ if abs_t > 0 and (earliest is None or abs_t < earliest):
+ earliest = abs_t
+ if isinstance(msg, mido.Message) and msg.type in (
+ "note_on", "note_off"):
+ if earliest is None or abs_t < earliest:
+ earliest = abs_t
+ if earliest is not None and earliest > 0:
+ for tr in mid.tracks:
+ # Walk events, accumulate times, rewrite each with the
+ # shift. Events that fall before the earliest tick are
+ # dropped or kept at time 0 (meta only).
+ rebuilt = []
+ abs_t = 0
+ first_real_done = False
+ for msg in tr:
+ new_abs = abs_t - earliest
+ abs_t += msg.time
+ if new_abs < 0:
+ # Drop note events; keep meta (set_tempo, track_name,
+ # time_signature, end_of_track) at time 0. Use
+ # copy(time=0) to preserve the original meta values.
+ if isinstance(msg, mido.MetaMessage):
+ rebuilt.append(msg.copy(time=0))
+ continue
+ # Update the delta time so the event lands at new_abs.
+ if not first_real_done:
+ msg.time = new_abs
+ first_real_done = True
+ rebuilt.append(msg)
+ tr.clear()
+ for m in rebuilt:
+ tr.append(m)
+ parent = os.path.dirname(output_path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ mid.save(output_path)
+
+ # ---- Summary --------------------------------------------------------
+ if verbose:
+ for label, kind_name, n_gp, channel in written_buckets:
+ if n_gp == 1:
+ print(f" {label} [{kind_name}] channel {channel}")
+ else:
+ print(f" {label} [{kind_name}] channel {channel} "
+ f"(merged {n_gp} GP tracks)")
+ if skipped_tracks:
+ print(f"Skipped {len(skipped_tracks)} other track(s):")
+ for t in skipped_tracks:
+ category, kind = _classify_track(t)
+ print(f" - {t.name or '(unnamed)'} "
+ f"[{kind} -> {category}]")
+
+ return output_path
+
+
+# ---------------------------------------------------------------------------
+# CLI ENTRY POINT
+# ---------------------------------------------------------------------------
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ prog="gp5_to_keyswitched_mid",
+ description=(
+ "Convert a Guitar Pro 5 file to a Sforzando keyswitched MIDI. "
+ "Only guitar tracks are processed by default; pass --include-bass "
+ "to also process bass tracks."
+ ),
+ )
+ parser.add_argument("input",
+ help="Path to the input .gp5 file")
+ parser.add_argument("output",
+ nargs="?",
+ default=None,
+ help="Path for the output MIDI file. If omitted, the "
+ "tool derives a name from the GP track name "
+ "when --only matches one track; otherwise it "
+ "uses 'output_keyswitched.mid'.")
+ parser.add_argument("-b", "--include-bass",
+ action="store_true",
+ help="Also process bass tracks (skipped by default).")
+ parser.add_argument("-V", "--velocity",
+ type=int,
+ default=DEFAULT_VELOCITY,
+ help="Fallback velocity 1-127 for notes missing one "
+ "(default: %(default)s).")
+ parser.add_argument("-R", "--no-sustain-reset",
+ action="store_true",
+ help="Do NOT emit a sustain key on every non-"
+ "articulated note. By default, every note "
+ "without an articulation carries a sustain "
+ "key so the sampler is always in a known "
+ "state (no stale palm-mute bleeding into "
+ "clean chords).")
+ parser.add_argument("-q", "--quiet",
+ action="store_true",
+ help="Suppress the per-track summary.")
+ parser.add_argument("-o", "--only",
+ action="append",
+ default=None,
+ help="Only process GP tracks whose name contains this "
+ "substring (case-insensitive). Repeat for "
+ "multiple names, e.g. -o Roberto -o DANY.")
+ parser.add_argument("-T", "--no-trim-silence",
+ action="store_true",
+ help="Do NOT trim leading silence. By default the "
+ "first event lands at tick 0, so empty intro "
+ "bars from the GP file are removed from the "
+ "MIDI output.")
+ parser.add_argument("--only-index", type=int, default=None,
+ help="When --only matches multiple tracks, pick "
+ "the Nth (1-based) match. Useful when several "
+ "tracks share a name.")
+ args = parser.parse_args(argv)
+
+ # Resolve output path: explicit > auto-derived from --only > default.
+ if args.output is None:
+ args.output = _auto_output_path(args.only, args.input, only_index=args.only_index)
+ if args.output is None:
+ print("ERROR: --only matched multiple tracks; please supply an "
+ "explicit output path or use --only-index to disambiguate.",
+ file=sys.stderr)
+ return 4
+
+ try:
+ result = convert(
+ args.input,
+ args.output,
+ include_bass=args.include_bass,
+ emit_sustain_reset=not args.no_sustain_reset,
+ trim_silence=not args.no_trim_silence,
+ fallback_velocity=args.velocity,
+ only=args.only,
+ only_index=args.only_index,
+ verbose=not args.quiet,
+ )
+ except FileNotFoundError:
+ print(f"ERROR: input file not found: {args.input}", file=sys.stderr)
+ return 2
+ except Exception as exc: # noqa: BLE001
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
+ return 3
+
+ if result is None:
+ # convert() couldn't resolve the track selection.
+ return 4
+
+ print(f"OK: wrote {result}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/gp_to_keyswitched_mid.js b/gp_to_keyswitched_mid.js
new file mode 100644
index 0000000000000000000000000000000000000000..06d85d49095443c86ac6dcb08427c24d56956db3
--- /dev/null
+++ b/gp_to_keyswitched_mid.js
@@ -0,0 +1,284 @@
+#!/usr/bin/env node
+/**
+ * gp_to_keyswitched_mid.js
+ * ========================
+ * Convert a Guitar Pro 7/8 (.gp) file to per-track Sforzando keyswitched MIDI.
+ *
+ * PyGuitarPro only reads up to GP5, so this uses alphaTab (which reads GP7/8
+ * .gp natively) to extract notes + articulations in absolute-tick space, then
+ * emits a JSON event stream that a tiny mido-based writer turns into a Type-1
+ * MIDI file. The keyswitch mapping and sustain-reset behaviour mirror
+ * gp5_to_keyswitched_mid.py exactly so both tools produce compatible output.
+ *
+ * INSTALL
+ * npm install @coderline/alphatab (in this directory)
+ *
+ * USAGE
+ * node gp_to_keyswitched_mid.js [--include-drums]
+ * [--no-sustain-reset] [--no-trim-silence] [--velocity N]
+ *
+ * One _keyswitched.mid is written per non-drum track into .
+ * Tracks that share a name get a "(N)" suffix so files never collide.
+ *
+ * The script prints a JSON object to stdout that the配套 Python writer
+ * consumes; humans can read the per-track summary at the bottom of the output.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const at = require('@coderline/alphatab');
+
+// ---- keyswitch map (same as gp5_to_keyswitched_mid.py) ---------------------
+const KEYSWITCH_MAP = {
+ sustain: 17,
+ palm_mute: 20,
+ harmonic: 9,
+ slide_up: 24,
+ slide_down: 23,
+ slide_in: 27,
+ hammer: 26,
+ bend: 91,
+};
+const SUSTAIN_KEYSWITCH = 18; // SusUp (cleaner sustain)
+const KEYSWITCH_DURATION_TICKS = 960; // one quarter, clamped later
+const DEFAULT_VELOCITY = 100;
+
+// alphaTab Dynamics enum -> MIDI velocity
+// Off=0, ppp=1, pp=2, p=3, mp=4, mf=5, f=6, ff=7, fff=8
+const DYN_VEL = { 1: 16, 2: 32, 3: 48, 4: 64, 5: 80, 6: 96, 7: 112, 8: 127 };
+
+const args = process.argv.slice(2);
+if (args.length < 2) {
+ console.error('Usage: node gp_to_keyswitched_mid.js [options]');
+ process.exit(1);
+}
+const inputPath = args[0];
+const outDir = args[1];
+const opts = {
+ includeDrums: args.includes('--include-drums'),
+ sustainReset: !args.includes('--no-sustain-reset'),
+ trimSilence: !args.includes('--no-trim-silence'),
+ fallbackVel: parseInt(args.find((a, i) => args[i - 1] === '--velocity')) || DEFAULT_VELOCITY,
+};
+
+function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v | 0)); }
+
+function loadScore(file) {
+ const bytes = fs.readFileSync(file);
+ return at.importer.ScoreLoader.loadScoreFromBytes(bytes, new at.Settings());
+}
+
+function isDrumTrack(t) {
+ if (t.isPercussionTrack) return true;
+ const pi = t.playbackInfo || {};
+ if (pi.primaryChannel === 9) return true;
+ return /drum|kit|percussion/i.test(t.name || '');
+}
+
+function velocityFor(note, beat) {
+ const d = note.dynamics ?? beat.dynamics;
+ if (d && DYN_VEL[d]) return DYN_VEL[d];
+ return opts.fallbackVel;
+}
+
+// --- articulation detection -------------------------------------------------
+function detectTechniques(note, beat) {
+ const techs = [];
+ if (note.isPalmMute || beat.isPalmMute) techs.push('palm_mute');
+ if (note.harmonicType) techs.push('harmonic');
+ if (note.isHammerPullOrigin) techs.push('hammer');
+ if (note.slideInType) techs.push('slide_in');
+ // slides out / to a target -> directional slide
+ if (note.slideOutType || note.slideTarget) {
+ let dir = 'up';
+ if (note.slideTarget) {
+ dir = note.slideTarget.realValue > note.realValue ? 'up' :
+ note.slideTarget.realValue < note.realValue ? 'down' : 'up';
+ } else if (note.slideOutType === 2) dir = 'down';
+ techs.push(dir === 'down' ? 'slide_down' : 'slide_up');
+ }
+ if (note.bendType || (note.bendPoints && note.bendPoints.length)) techs.push('bend');
+ return techs;
+}
+
+// --- tie-chain end-tick map -------------------------------------------------
+// Mirrors _build_tie_chain_map from the GP5 tool: the origin note's note_off
+// extends through every tied continuation on the same (string, pitch).
+function buildNoteEndMap(track, absStartOf) {
+ const noteEnd = new Map(); // id(note) -> absolute end tick
+ const active = new Map(); // "string|pitch" -> id(origin note)
+ for (const st of track.staves) {
+ for (let bi = 0; bi < st.bars.length; bi++) {
+ const bar = st.bars[bi];
+ const barStart = absStartOf(bar);
+ for (const voice of bar.voices) {
+ if (voice.isEmpty) continue;
+ for (const beat of voice.beats) {
+ const start = barStart + (beat.playbackStart || 0);
+ const dur = beat.playbackDuration || 0;
+ for (const note of beat.notes) {
+ if (note.isDead) continue;
+ const key = note.string + '|' + note.realValue;
+ const nd = Math.max(1, Math.round(dur * (note.durationPercent || 1)));
+ if (note.isTieDestination) {
+ const origin = active.get(key);
+ if (origin !== undefined) noteEnd.set(origin, start + nd);
+ // tied notes don't get their own note_on/off
+ } else {
+ noteEnd.set(note.id, start + nd);
+ active.set(key, note.id);
+ }
+ }
+ }
+ }
+ }
+ }
+ return noteEnd;
+}
+
+function buildTrackEvents(track, absStartOf) {
+ const channel = (track.playbackInfo && track.playbackInfo.primaryChannel != null)
+ ? track.playbackInfo.primaryChannel : 0;
+ const ch = channel === 9 ? 0 : channel; // never reuse GM percussion
+
+ const noteEnd = buildNoteEndMap(track, absStartOf);
+
+ // raw events: { tick, kind:'note_on'|'note_off', note, velocity, ks?:bool }
+ const events = [];
+ const pendingSustain = new Set(); // "tick" -> already emitted sustain at this tick
+
+ for (const st of track.staves) {
+ for (let bi = 0; bi < st.bars.length; bi++) {
+ const bar = st.bars[bi];
+ const barStart = absStartOf(bar);
+ for (const voice of bar.voices) {
+ if (voice.isEmpty) continue;
+ for (const beat of voice.beats) {
+ const start = barStart + (beat.playbackStart || 0);
+ for (const note of beat.notes) {
+ if (note.isDead) continue;
+ const tied = note.isTieDestination;
+ const techs = detectTechniques(note, beat);
+ const ksNotes = [];
+ let hasArticulation = false;
+ for (const t of techs) {
+ const ks = KEYSWITCH_MAP[t];
+ if (ks != null) { ksNotes.push(ks); hasArticulation = true; }
+ }
+ // Tied notes share the previous attack's envelope. They only
+ // emit keyswitches when they carry a NEW articulation (e.g. a
+ // bend starting on the tied side). A tied note with no
+ // articulation emits nothing — no default sustain either.
+ if (!tied && !hasArticulation && opts.sustainReset) {
+ if (!pendingSustain.has(start)) {
+ pendingSustain.add(start);
+ ksNotes.push(SUSTAIN_KEYSWITCH);
+ }
+ }
+ const vel = clamp(velocityFor(note, beat), 1, 127);
+ for (const ks of ksNotes) {
+ events.push({ tick: start, kind: 'note_on', note: ks, velocity: vel, ks: true });
+ // sentinel: close at start+DUR (clamped later)
+ events.push({ tick: start + KEYSWITCH_DURATION_TICKS, kind: 'keyswitch_off', note: ks, velocity: 0, ks: true });
+ }
+ if (!tied) {
+ const end = noteEnd.has(note.id) ? noteEnd.get(note.id) : (start + Math.max(1, Math.round((beat.playbackDuration || 0) * (note.durationPercent || 1))));
+ const pitch = clamp(note.realValue, 0, 127);
+ events.push({ tick: start, kind: 'note_on', note: pitch, velocity: vel, ks: false });
+ events.push({ tick: end, kind: 'note_off', note: pitch, velocity: 0, ks: false });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (!events.length) return null;
+
+ // --- clamp keyswitch_off + re-trigger (mirrors the GP5 tool) ------------
+ const ksSet = new Set(Object.values(KEYSWITCH_MAP));
+ ksSet.add(SUSTAIN_KEYSWITCH);
+ events.sort((a, b) => a.tick - b.tick);
+
+ const openStacks = new Map(); // "ch|note" -> [onTick,...]
+ const resolved = [];
+ function closeOldest(ch, note, atTick) {
+ const key = ch + '|' + note;
+ const stack = openStacks.get(key);
+ if (!stack || !stack.length) return;
+ const onTick = stack.shift();
+ if (!stack.length) openStacks.delete(key);
+ const cap = onTick + KEYSWITCH_DURATION_TICKS;
+ let clamped = Math.min(atTick, cap);
+ if (clamped < onTick) clamped = onTick;
+ resolved.push({ tick: clamped, kind: 'note_off', note, velocity: 0, ks: true });
+ }
+ for (const e of events) {
+ if (e.kind === 'keyswitch_off') {
+ closeOldest(ch, e.note, e.tick);
+ } else if (e.kind === 'note_on' && e.ks && ksSet.has(e.note)) {
+ closeOldest(ch, e.note, e.tick); // re-trigger closes previous
+ resolved.push(e);
+ const key = ch + '|' + e.note;
+ if (!openStacks.has(key)) openStacks.set(key, []);
+ openStacks.get(key).push(e.tick);
+ } else if (e.kind === 'note_off' && e.ks && ksSet.has(e.note)) {
+ closeOldest(ch, e.note, e.tick);
+ } else {
+ resolved.push(e);
+ }
+ }
+ let out = resolved;
+
+ // --- trim leading silence ---------------------------------------------
+ if (opts.trimSilence) {
+ let earliest = null;
+ for (const e of out) {
+ if (e.kind === 'note_on' || e.kind === 'note_off') {
+ if (earliest === null || e.tick < earliest) earliest = e.tick;
+ }
+ }
+ if (earliest != null && earliest > 0) {
+ out = out
+ .filter(e => e.tick - earliest >= 0)
+ .map(e => ({ ...e, tick: e.tick - earliest }));
+ }
+ }
+ out.sort((a, b) => a.tick - b.tick);
+ return { name: track.name || 'Guitar', channel: ch, events: out };
+}
+
+function main() {
+ const score = loadScore(inputPath);
+ const mbStart = new Map(); // bar.index -> start tick
+ for (const mb of score.masterBars) mbStart.set(mb.index, mb.start || 0);
+ const absStartOf = (bar) => mbStart.get(bar.index) || 0;
+
+ // tempo + time sig from first master bar
+ let tempo = score.tempo || 120;
+ const tempoAuto = score.masterBars[0] && score.masterBars[0].tempoAutomations;
+ if (tempoAuto && tempoAuto.length && tempoAuto[0].value) tempo = tempoAuto[0].value.bpm || tempo;
+ let tsNum = 4, tsDen = 4;
+ const mb0 = score.masterBars[0];
+ if (mb0) { tsNum = mb0.timeSignatureNumerator || 4; tsDen = mb0.timeSignatureDenominator || 4; }
+
+ const tracks = [];
+ const nameCount = {};
+ for (const t of score.tracks) {
+ if (isDrumTrack(t) && !opts.includeDrums) continue;
+ const built = buildTrackEvents(t, absStartOf);
+ if (!built) continue;
+ // disambiguate duplicate names
+ let name = built.name;
+ nameCount[name] = (nameCount[name] || 0) + 1;
+ if (nameCount[name] > 1) name = `${name} (${nameCount[name]})`;
+ tracks.push({ ...built, name });
+ }
+
+ const result = { input: inputPath, tempo: Math.round(tempo), timeSignature: { numerator: tsNum, denominator: tsDen }, tracks };
+ process.stdout.write(JSON.stringify(result));
+}
+
+main();
\ No newline at end of file
diff --git a/midi_to_gp5.py b/midi_to_gp5.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8044fe540abb234db16feee18e30526e0535030
--- /dev/null
+++ b/midi_to_gp5.py
@@ -0,0 +1,413 @@
+#!/usr/bin/env python3
+"""
+Convert a keyswitched MIDI file (output of gp5_to_keyswitched_mid.py) back
+to a Guitar Pro 5/6 file.
+
+This is the inverse of gp5_to_keyswitched_mid.py. The forward script reads
+GP5 articulations from note.effect.* and emits keyswitch notes; this
+script reads keyswitch notes and reconstructs note.effect.* on each
+pitched note.
+
+Supported keyswitches (Metal-GTX mapping):
+ MIDI 9 (A-1) -> Harmonic (natural)
+ MIDI 17 (F0) -> Sustain (default, no effect)
+ MIDI 18 (F#0) -> Sustain (default, no effect)
+ MIDI 20 (G#0) -> Palm Mute
+ MIDI 23 (B0) -> Slide Down
+ MIDI 24 (C1) -> Slide Up
+ MIDI 26 (D1) -> Hammer
+ MIDI 27 (D#1) -> Slide In (grace note)
+ MIDI 91 (G6) -> Bend
+
+Limitations:
+ - Bent note shape is not reconstructed (pitch wheel automation is
+ rarely present in keyswitched MIDI; we use a default 1-semitone bend).
+ - Slide direction is inferred from the next pitched note on the same
+ channel.
+ - Slide-in grace notes are placed at the same tick as the main note,
+ with a 32nd-note duration.
+ - String/fret selection picks the lowest playable fret on the
+ appropriate string (heuristic).
+"""
+import argparse
+import os
+import sys
+from dataclasses import dataclass, field
+
+import guitarpro
+import mido
+
+
+# ---------------------------------------------------------------------------
+# Constants — must match gp5_to_keyswitched_mid.py
+# ---------------------------------------------------------------------------
+DEFAULT_KEYSWITCH_MAP = {
+ 17: "sustain",
+ 18: "sustain",
+ 20: "palm_mute",
+ 9: "harmonic",
+ 23: "slide_down",
+ 24: "slide_up",
+ 26: "hammer",
+ 27: "slide_in",
+ 91: "bend",
+}
+
+# Standard guitar tuning (low to high): E2 A2 D3 G3 B3 E4
+# In MIDI notes: 40 45 50 55 59 64
+DEFAULT_GUITAR_TUNING = (40, 45, 50, 55, 59, 64)
+
+# Standard bass tuning (low to high): E1 A1 D2 G2
+DEFAULT_BASS_TUNING = (28, 33, 38, 43)
+
+
+# ---------------------------------------------------------------------------
+# Data structures
+# ---------------------------------------------------------------------------
+@dataclass
+class PitchedNote:
+ """A pitched note (MIDI note > 30) reconstructed from the MIDI."""
+ pitch: int
+ start_tick: int
+ duration_ticks: int
+ velocity: int
+ channel: int
+ articulation: str = "sustain" # default; overrides by keyswitch
+ slide_target_pitch: int = None # for slide_up / slide_down
+
+
+# ---------------------------------------------------------------------------
+# MIDI parsing
+# ---------------------------------------------------------------------------
+def _parse_midi(midi_path):
+ """Read a MIDI file and return (track_name, channel, PitchedNote list).
+
+ Keyswitch notes (< 30) are matched to the next pitched note on the
+ same channel at the same tick (or within a few ticks). The keyswitch
+ determines the articulation of that pitched note.
+ """
+ mid = mido.MidiFile(midi_path)
+ if len(mid.tracks) < 2:
+ raise ValueError(
+ f"MIDI file has only {len(mid.tracks)} tracks; "
+ "expected at least 2 (conductor + audio)."
+ )
+
+ audio_track = mid.tracks[1]
+ name = audio_track.name or "Track"
+
+ # Collect all (tick, event) pairs across the audio track.
+ events = []
+ abs_t = 0
+ for msg in audio_track:
+ abs_t += msg.time
+ if hasattr(msg, "note"):
+ events.append((abs_t, msg))
+
+ # Sort by tick. Within a tick, keep original order (stable).
+ events.sort(key=lambda e: e[0])
+
+ # Walk events and pair on/off.
+ # Track open notes per (channel, pitch).
+ open_notes = {}
+ pending_keyswitches = {} # (channel, pitch) -> first tick it appeared
+
+ pitched = []
+ abs_t = 0
+ for msg in audio_track:
+ abs_t += msg.time
+ if not hasattr(msg, "note"):
+ continue
+ if msg.type == "note_on" and msg.velocity > 0:
+ if msg.note < 30:
+ # Keyswitch note_on.
+ pending_keyswitches[(msg.channel, msg.note)] = abs_t
+ else:
+ # Pitched note_on.
+ pitch = msg.note
+ key = (msg.channel, pitch)
+ # Find the keyswitch that precedes this note on the same channel.
+ # Prefer the most recent keyswitch on this channel.
+ ks_pitch = None
+ for ks_p_note, ks_tick in pending_keyswitches.items():
+ if ks_p_note[0] == msg.channel and ks_tick <= abs_t:
+ if ks_pitch is None or ks_tick > pending_keyswitches.get((msg.channel, ks_pitch), 0):
+ ks_pitch = ks_p_note[1]
+ articulation = DEFAULT_KEYSWITCH_MAP.get(ks_pitch, "sustain")
+ # Consume the keyswitch for this channel.
+ if ks_pitch is not None:
+ pending_keyswitches.pop((msg.channel, ks_pitch), None)
+ # Record as open note.
+ open_notes[key] = PitchedNote(
+ pitch=pitch,
+ start_tick=abs_t,
+ duration_ticks=0, # filled on note_off
+ velocity=msg.velocity,
+ channel=msg.channel,
+ articulation=articulation,
+ )
+ elif msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
+ key = (msg.channel, msg.note)
+ if key in open_notes:
+ note = open_notes.pop(key)
+ note.duration_ticks = abs_t - note.start_tick
+ if note.duration_ticks < 1:
+ note.duration_ticks = 1
+ pitched.append(note)
+
+ # Resolve slide direction: for each slide_up / slide_down note, find
+ # the next pitched note on the same channel and compare pitches.
+ for i, note in enumerate(pitched):
+ if note.articulation not in ("slide_up", "slide_down"):
+ continue
+ for later in pitched[i + 1:]:
+ if later.channel == note.channel:
+ if later.pitch > note.pitch:
+ note.slide_target_pitch = later.pitch
+ # Direction stays as inferred by keyswitch.
+ elif later.pitch < note.pitch:
+ note.slide_target_pitch = later.pitch
+ break
+
+ return name, mid.tracks[0], pitched
+
+
+# ---------------------------------------------------------------------------
+# GP5 construction
+# ---------------------------------------------------------------------------
+def _pick_string_fret(pitch, tuning):
+ """Pick the lowest playable fret on the appropriate string.
+
+ Handles notes below the lowest open string by using negative frets
+ (Guitar Pro supports frets 0-24, but our tuning may not reach low
+ enough notes for some songs). If the pitch is below the lowest open
+ string, we drop-tune by transposing the string value down.
+ """
+ best = None
+ for string_idx, open_note in enumerate(tuning):
+ fret = pitch - open_note
+ if fret < 0 or fret > 24:
+ continue
+ # Prefer the lowest fret (closest to nut).
+ if best is None or fret < best[1]:
+ best = (string_idx, fret)
+ if best is not None:
+ return best
+ # Pitch is below all strings. Drop-tune: transpose the lowest string
+ # down so the note is reachable. This is a hack for non-standard
+ # tunings; ideally the user would pass the right tuning.
+ lowest_open = tuning[0]
+ fret = pitch - (lowest_open - 12) # drop the lowest string by 12 semitones
+ if fret < 0 or fret > 24:
+ return None, None
+ return (0, fret)
+
+
+def _articulation_to_effect(note, gp_note):
+ """Set gp_note.effect fields based on the articulation."""
+ art = note.articulation
+ if art == "palm_mute":
+ gp_note.effect.palmMute = True
+ elif art == "harmonic":
+ gp_note.effect.harmonic = guitarpro.NaturalHarmonic()
+ elif art in ("slide_up", "slide_down"):
+ gp_note.effect.slides = [guitarpro.SlideType.shiftSlideTo]
+ elif art == "hammer":
+ gp_note.effect.hammer = True
+ elif art == "slide_in":
+ # Grace note with transition=slide at the target pitch.
+ gp_note.effect.grace = guitarpro.GraceEffect(
+ duration=32,
+ fret=note.pitch % 12,
+ isDead=False,
+ isOnBeat=True,
+ transition=guitarpro.GraceEffectTransition.slide,
+ velocity=note.velocity,
+ )
+ elif art == "bend":
+ # Default to a 1-semitone bend. (Pitch wheel automation is not
+ # reconstructed from MIDI; this is a placeholder.)
+ gp_note.effect.bend = guitarpro.BendEffect(
+ type=guitarpro.BendType.bend,
+ value=0,
+ points=[
+ guitarpro.BendPoint(0, 0, False),
+ guitarpro.BendPoint(3, 4, False),
+ guitarpro.BendPoint(12, 4, False),
+ ],
+ )
+
+
+def _ticks_to_duration(ticks, ticks_per_beat, beats_to_grid):
+ """Convert ticks to a GP5 Duration object.
+
+ GP5 represents durations as discrete values (1=whole, 2=half, 4=quarter,
+ 8=eighth, 16=sixteenth, 32=thirty-second). We round to the nearest.
+ """
+ beats = ticks / ticks_per_beat
+ value = round(4 / beats) # 4/quarter = 4/1, 4/half = 2, etc.
+ value = max(1, min(64, value))
+ if value == 1:
+ return guitarpro.Duration(value=1, isDotted=False, tuplet=guitarpro.Tuplet(1, 1))
+ if value == 3:
+ return guitarpro.Duration(value=2, isDotted=True, tuplet=guitarpro.Tuplet(1, 1))
+ return guitarpro.Duration(value=value, isDotted=False, tuplet=guitarpro.Tuplet(1, 1))
+
+
+def _build_track(name, pitched, tuning, channel, ticks_per_beat):
+ """Build a GP5 Track from a list of PitchedNotes."""
+ song = guitarpro.Song()
+ track = guitarpro.Track(song=song)
+ track.name = name
+ track.channel = guitarpro.MidiChannel(channel=channel, instrument=25)
+ track.strings = [guitarpro.GuitarString(number=i + 1, value=t) for i, t in enumerate(tuning)]
+ track.isPercussionTrack = False
+ track.fretCount = 24
+
+ # Determine measure count from the latest note.
+ if not pitched:
+ return track
+ max_tick = max(n.start_tick + n.duration_ticks for n in pitched)
+ ticks_per_measure = ticks_per_beat * 4 # assume 4/4
+ num_measures = max(1, (max_tick + ticks_per_measure - 1) // ticks_per_measure)
+
+ # Build one Measure per row.
+ measures = []
+ for measure_idx in range(int(num_measures)):
+ measure_start = measure_idx * ticks_per_measure
+ measure_end = measure_start + ticks_per_measure
+ h = guitarpro.MeasureHeader(
+ timeSignature=guitarpro.TimeSignature(
+ numerator=4,
+ denominator=guitarpro.Duration(value=4, isDotted=False, tuplet=guitarpro.Tuplet(1, 1)),
+ ),
+ )
+ m = guitarpro.Measure(track=track, header=h)
+ m.voices[0].beats = []
+ # Group notes that start in this measure's beat slots.
+ # Create one beat per beat (quarter note). Multiple notes can
+ # share a beat (chord).
+ beats_in_measure = 4 # 4/4
+ beat_positions = []
+ for b in range(beats_in_measure):
+ beat_start = measure_start + b * ticks_per_beat
+ beat_end = beat_start + ticks_per_beat
+ # Find notes that start in this beat slot.
+ beat_notes = [n for n in pitched
+ if beat_start <= n.start_tick < beat_end]
+ beat_positions.append((beat_start, beat_notes))
+
+ for beat_start, beat_notes in beat_positions:
+ if not beat_notes:
+ # Empty beat: skip (no note, no beat).
+ continue
+ # Use the first note's duration as the beat duration.
+ first = beat_notes[0]
+ beat_dur = _ticks_to_duration(first.duration_ticks, ticks_per_beat, 4)
+ beat = guitarpro.Beat(
+ voice=m.voices[0],
+ notes=[],
+ duration=beat_dur,
+ start=beat_start,
+ )
+ for note in beat_notes:
+ string, fret = _pick_string_fret(note.pitch, tuning)
+ if string is None:
+ continue
+ gp_note = guitarpro.Note(
+ beat=beat,
+ string=string + 1,
+ value=fret,
+ velocity=note.velocity,
+ type=guitarpro.NoteType.normal,
+ )
+ _articulation_to_effect(note, gp_note)
+ beat.notes.append(gp_note)
+ if beat.notes:
+ m.voices[0].beats.append(beat)
+
+ measures.append(m)
+
+ track.measures = measures
+ return track
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+def main():
+ parser = argparse.ArgumentParser(
+ description="Convert a keyswitched MIDI back to a Guitar Pro 5/6 file.",
+ )
+ parser.add_argument("midi", help="Path to the input MIDI file.")
+ parser.add_argument("output", help="Path to the output .gp5/.gp file.")
+ parser.add_argument("--instrument", choices=["guitar", "bass", "drums"],
+ default="guitar")
+ args = parser.parse_args()
+
+ midi_path = os.path.abspath(args.midi)
+ output_path = os.path.abspath(args.output)
+
+ if not os.path.exists(midi_path):
+ print(f"ERROR: MIDI file not found: {midi_path}", file=sys.stderr)
+ return 1
+
+ print(f"Reading {midi_path}...")
+ name, conductor_track, pitched = _parse_midi(midi_path)
+ print(f" Track name: {name!r}")
+ print(f" Pitched notes: {len(pitched)}")
+ # Count by articulation.
+ from collections import Counter
+ arts = Counter(n.articulation for n in pitched)
+ for art, count in arts.most_common():
+ print(f" {art:12s} {count}")
+
+ if not pitched:
+ print("ERROR: no pitched notes found in MIDI", file=sys.stderr)
+ return 1
+
+ # Get ticks_per_beat from MIDI.
+ mid = mido.MidiFile(midi_path)
+ ticks_per_beat = mid.ticks_per_beat
+
+ # Choose tuning.
+ if args.instrument == "guitar":
+ tuning = DEFAULT_GUITAR_TUNING
+ elif args.instrument == "bass":
+ tuning = DEFAULT_BASS_TUNING
+ else:
+ tuning = DEFAULT_GUITAR_TUNING # ignored for drums
+
+ # Determine channel from the first note.
+ channel = pitched[0].channel
+
+ # Build the song.
+ song = guitarpro.Song()
+ song.tempo = 120 # overwritten below
+ # Extract tempo from conductor.
+ for msg in conductor_track:
+ if msg.type == "set_tempo":
+ song.tempo = int(60_000_000 / msg.tempo)
+ break
+
+ # Song starts with a default Track 1; replace it with our built track.
+ track = _build_track(
+ name=name, pitched=pitched, tuning=tuning,
+ channel=channel, ticks_per_beat=ticks_per_beat,
+ )
+ # The Track already has a MidiChannel from the build helper; ensure
+ # it points at the source file's channel.
+ track.channel = guitarpro.MidiChannel(channel=channel, instrument=25)
+ song.tracks[0] = track
+
+ print(f"Writing {output_path}...")
+ parent = os.path.dirname(output_path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ guitarpro.write(song, output_path)
+ print(f"OK: wrote {output_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/midjson_to_mid.py b/midjson_to_mid.py
new file mode 100644
index 0000000000000000000000000000000000000000..36e2d0f5bde0bc143b88496b6af4d27e00039402
--- /dev/null
+++ b/midjson_to_mid.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+midjson_to_mid.py
+=================
+Consume the JSON event stream emitted by gp_to_keyswitched_mid.js and write
+one Type-1 MIDI file per track into , named _keyswitched.mid.
+
+Usage:
+ node gp_to_keyswitched_mid.js input.gp out_dir > events.json
+ python midjson_to_mid.py events.json
+"""
+import json
+import os
+import re
+import sys
+
+import mido
+
+_FORBIDDEN = re.compile(r'[\\/:\*\?"<>\|]+')
+_WS = re.compile(r'\s+')
+
+
+def sanitize(name, default="output"):
+ cleaned = _FORBIDDEN.sub(' ', name or '')
+ cleaned = _WS.sub(' ', cleaned).strip().rstrip('. ')
+ return cleaned or default
+
+
+def write_track(mid, name, channel, events, ticks_per_beat=960):
+ track = mid.add_track(name[:127] if name else "Guitar")
+ last = 0
+ for e in events:
+ tick = max(0, int(e['tick']))
+ kind = e['kind']
+ note = int(e['note'])
+ vel = int(e.get('velocity', 0))
+ if kind == 'note_on' and vel > 0:
+ msg = mido.Message('note_on', channel=channel, note=note,
+ velocity=max(1, min(127, vel)), time=tick - last)
+ else:
+ msg = mido.Message('note_off', channel=channel, note=note,
+ velocity=0, time=tick - last)
+ track.append(msg)
+ last = tick
+ return track
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: python midjson_to_mid.py ",
+ file=sys.stderr)
+ return 2
+ events_path = sys.argv[1]
+ out_dir = sys.argv[2]
+ with open(events_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ os.makedirs(out_dir, exist_ok=True)
+ tempo = int(data.get('tempo', 120))
+ ts = data.get('timeSignature', {})
+ ts_num = int(ts.get('numerator', 4))
+ ts_den = int(ts.get('denominator', 4))
+
+ written = []
+ for tr in data.get('tracks', []):
+ events = tr['events']
+ if not events:
+ continue
+ mid = mido.MidiFile(type=1, ticks_per_beat=960)
+ conductor = mid.add_track("Conductor")
+ conductor.append(mido.MetaMessage(
+ 'set_tempo', tempo=mido.bpm2tempo(tempo), time=0))
+ conductor.append(mido.MetaMessage(
+ 'time_signature', numerator=ts_num, denominator=ts_den, time=0))
+ write_track(mid, tr['name'], int(tr['channel']), events)
+ fname = sanitize(tr['name']) + "_keyswitched.mid"
+ out_path = os.path.join(out_dir, fname)
+ mid.save(out_path)
+ # stats
+ ks_notes = sum(1 for e in events if e['kind'] == 'note_on' and e.get('ks'))
+ played = sum(1 for e in events if e['kind'] == 'note_on' and not e.get('ks'))
+ written.append((out_path, tr['name'], tr['channel'], len(events), ks_notes, played))
+
+ for out_path, name, ch, nev, ks, played in written:
+ size = os.path.getsize(out_path)
+ print(f" {os.path.basename(out_path)} ({size:,}B, ch={ch}) "
+ f"events={nev} keyswitch_on={ks} played_notes={played}")
+ print(f"\nWrote {len(written)} file(s) to {out_dir}")
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..b465e1b5fcd212be5c3dab9d754d19198e17c57e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "stable-jam",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Jam Buddy — an AI music companion that listens to what you play and joins in, at your tempo, in the instrument you pick. Built for the Stability AI Challenge at Music Hackspace Montreal.",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20",
+ "pnpm": ">=9"
+ },
+ "packageManager": "pnpm@9.15.9",
+ "workspaces": [
+ "apps/*",
+ "packages/*",
+ "services/*"
+ ],
+ "scripts": {
+ "dev": "pnpm --filter @patterntalk/web dev",
+ "build": "pnpm -r build",
+ "test": "pnpm -r test",
+ "test:web": "pnpm --filter @patterntalk/web test",
+ "lint": "pnpm -r lint",
+ "typecheck": "pnpm -r typecheck",
+ "clean": "pnpm -r exec rm -rf node_modules dist .next"
+ },
+ "devDependencies": {
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..de66904ca411791dc6df2eb05b47c3d39b800d80
--- /dev/null
+++ b/packages/shared-types/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@patterntalk/shared-types",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f8df0f0f1580fbebba2d560ac8995fc5ad08ddef
--- /dev/null
+++ b/packages/shared-types/src/index.ts
@@ -0,0 +1,292 @@
+/**
+ * @patterntalk/shared-types
+ *
+ * Domain types shared across web app, audio service, and pattern engine.
+ * These mirror the Pydantic models in services/audio/ (hand-maintained
+ * during the hackathon — codegen via JSON Schema is post-hackathon).
+ *
+ * Source of truth: docs/03-data-model.md
+ */
+
+// =============================================================================
+// LIMB
+// =============================================================================
+
+/**
+ * Drum limb / instrument. Maps to General MIDI drum map keys in
+ * apps/web/lib/midi/generator.ts (see limbToGeneralMidi there).
+ *
+ * Order matters for keyboard navigation in the visual grid — keep stable.
+ */
+export type Limb =
+ | "kick"
+ | "snare"
+ | "cross-stick"
+ | "hihat"
+ | "hihat-open"
+ | "ride"
+ | "ride-bell"
+ | "crash"
+ | "china"
+ | "splash"
+ | "tom-1"
+ | "tom-2"
+ | "tom-3";
+
+export const LIMBS: readonly Limb[] = [
+ "kick",
+ "snare",
+ "cross-stick",
+ "hihat",
+ "hihat-open",
+ "ride",
+ "ride-bell",
+ "crash",
+ "china",
+ "splash",
+ "tom-1",
+ "tom-2",
+ "tom-3",
+] as const;
+
+/**
+ * General MIDI drum map. Source of truth for MIDI byte assignment.
+ * Verified against the GM1 spec (kick=36, snare=38, etc.).
+ */
+export const LIMB_TO_GM: Readonly> = {
+ kick: 36,
+ snare: 38,
+ "cross-stick": 37,
+ hihat: 42,
+ "hihat-open": 46,
+ ride: 51,
+ "ride-bell": 53,
+ crash: 49,
+ china: 52,
+ splash: 55,
+ "tom-1": 50,
+ "tom-2": 47,
+ "tom-3": 45,
+};
+
+// =============================================================================
+// PATTERN TEMPLATE
+// =============================================================================
+
+/**
+ * A single drum hit at a fractional position within one bar.
+ * `position` is 0.0 (downbeat) to 1.0 (next downbeat), inclusive.
+ *
+ * The pattern engine expands `defaultBars` of these into the requested
+ * number of bars; see docs/03-data-model.md §Pattern engine.
+ */
+export interface Hit {
+ /** Fractional position within one bar, 0.0–1.0. */
+ position: number;
+ limb: Limb;
+ /** MIDI velocity, 0–127. */
+ velocity: number;
+ /** Visual + audio emphasis. Boosts velocity by +20 in the engine. */
+ accent?: boolean;
+}
+
+export type Genre =
+ | "metal"
+ | "rock"
+ | "punk"
+ | "pop"
+ | "jazz"
+ | "funk"
+ | "latin"
+ | "hiphop";
+
+/**
+ * Hand-authored drum pattern. The moat. Lives in apps/web/data/patterns/*.json.
+ *
+ * The `description` is the screenreader-friendly text — generated from the
+ * template at runtime, not a static caption. Format:
+ * {Name}. {Genre} at {tempo} BPM. {Limb-by-limb}. {Notable characteristics}.
+ * See docs/05-accessibility.md §Pattern description format.
+ */
+export interface PatternTemplate {
+ id: string;
+ name: string;
+ description: string;
+ tags: string[];
+ genre: Genre;
+ defaultTempo: number;
+ defaultBars: number;
+ /** 0.0 = straight, ~0.33 = shuffle. */
+ swingRatio?: number;
+ hits: Hit[];
+}
+
+// =============================================================================
+// PARSED REQUEST (parser output)
+// =============================================================================
+
+/**
+ * Cymbal/voice hint that overrides a pattern's default cymbal.
+ * e.g. "ride the crash" → cymbal: { type: "crash", pattern: "8ths" }
+ */
+export interface CymbalHint {
+ type: "crash" | "ride" | "china" | "hihat-open" | "hihat-closed" | "splash";
+ pattern?: "8ths" | "quarters" | "bell" | "wash" | "upstrokes";
+}
+
+export type TempoSource = "prompt" | "reaper" | "audio" | "default";
+export type Feel = "straight" | "swing" | "half-time" | "double-time";
+export type Intensity = "soft" | "medium" | "brutal" | "brutal-max";
+
+/**
+ * Structured output of the prompt parser. The parser is responsible for
+ * producing this from free-form voice/text input. See ParseResult below.
+ */
+export interface ParsedRequest {
+ /** Detected onomatopoeia, if any (e.g. "tupatupatupa"). */
+ onomatopoeia?: string;
+ /** Pattern template id (e.g. "d-beat", "skank"). */
+ patternId?: string;
+ /** User-friendly pattern name (e.g. "D-Beat"). */
+ patternName?: string;
+ bars: number;
+ beats?: number;
+ tempo?: number;
+ tempoSource: TempoSource;
+ tempoDefault: number;
+ timeSignature: {
+ numerator: number;
+ denominator: number;
+ };
+ cymbal?: CymbalHint;
+ /** Position descriptors, e.g. ["1", "and-of-2", "3"]. */
+ accents?: string[];
+ feel?: Feel;
+ intensity?: Intensity;
+}
+
+/**
+ * Parser return type. The union forces every call site to handle the
+ * low-confidence case explicitly. Without this, the parser would need
+ * to throw and the UI would need try/catch around every parse.
+ *
+ * - `ok: true` — confidence ≥ 0.7, request is ready to use.
+ * - `ok: false` — parser has a guess but wants confirmation.
+ */
+export type ParseResult =
+ | {
+ ok: true;
+ confidence: number;
+ request: ParsedRequest;
+ }
+ | {
+ ok: false;
+ confidence: number;
+ /** The parser's best guess and N alternates, sorted by confidence. */
+ candidates: Array<{
+ request: ParsedRequest;
+ confidence: number;
+ reason: string;
+ }>;
+ /** What the user actually said, normalized. */
+ heardAs: string;
+ };
+
+// =============================================================================
+// PATTERN ENGINE OUTPUT (MIDI events)
+// =============================================================================
+
+export interface MidiEvent {
+ /** Absolute tick position from start of bar 1. */
+ tick: number;
+ limb: Limb;
+ /** 0–127. */
+ velocity: number;
+ /** Ticks. Drums are short — typically 1–10. */
+ duration: number;
+}
+
+// =============================================================================
+// VOICE CONVERSATION FSM
+// =============================================================================
+
+/**
+ * Voice conversation states. Defined in docs/04-ux-voice-first.md with two
+ * additions for error handling and confirmation:
+ *
+ * - `awaiting-confirmation` — parser confidence < 0.7, user must pick
+ * - `error` — recoverable failure (mic denied, parse error, etc.)
+ *
+ * State machine lives in apps/web/lib/voice/conversation.ts (separate concern).
+ */
+export type ConversationState =
+ | "idle"
+ | "listening"
+ | "parsing"
+ | "generating"
+ | "ready"
+ | "playing"
+ | "awaiting-confirmation"
+ | "error";
+
+export interface ConversationContext {
+ /** Current parsed request (set after entering 'parsing'). */
+ parsed?: ParsedRequest;
+ /** Last generated MIDI events (set after entering 'ready'). */
+ midi?: MidiEvent[];
+ /** Audio sample URL from the audio service, if generated. */
+ sampleUrl?: string;
+ /** Generated variations, 0–4. */
+ variations?: MidiEvent[][];
+ /** Currently selected variation, 1-based. */
+ selectedVariation?: number;
+ /** Last error, if state is 'error'. */
+ error?: {
+ kind:
+ | "mic-denied"
+ | "no-speech"
+ | "parse-low-confidence"
+ | "pattern-not-found"
+ | "audio-timeout"
+ | "audio-error"
+ | "reaper-unavailable"
+ | "unknown";
+ message: string;
+ };
+}
+
+// =============================================================================
+// REAPER INTEGRATION
+// =============================================================================
+
+export interface ReaperState {
+ connected: boolean;
+ tempo: number | null;
+ timeSignature: [number, number] | null;
+ playState: number | null;
+}
+
+// =============================================================================
+// ONOMATOPOEIA MAPPING
+// =============================================================================
+
+/**
+ * Entry in apps/web/data/onomatopoeia.json. See docs/03-data-model.md
+ * §Onomatopoeia mapping.
+ */
+export interface OnomatopoeiaEntry {
+ id: string;
+ /** Phonetic variants. Normalized (lowercase, no punctuation) at match time. */
+ patterns: string[];
+ patternId: string;
+ defaultCymbal?: CymbalHint;
+ defaultIntensity?: Intensity;
+ /** 0.0–1.0. Below 0.7 the parser asks for confirmation. */
+ confidence: number;
+ notes?: string;
+}
+
+export interface OnomatopoeiaTable {
+ version: number;
+ entries: OnomatopoeiaEntry[];
+}
diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..5b66431e2cb450a62391526cadcbf727d6de1b1c
--- /dev/null
+++ b/packages/shared-types/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": false
+ },
+ "include": ["src/**/*"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f9b35d38f246a4d95c1203e1d71d8fde3d490b32
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,5014 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.3
+
+ apps/web:
+ dependencies:
+ '@patterntalk/shared-types':
+ specifier: workspace:*
+ version: link:../../packages/shared-types
+ '@tonejs/midi':
+ specifier: ^2.0.28
+ version: 2.0.28
+ next:
+ specifier: ^14.2.18
+ version: 14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ devDependencies:
+ '@types/node':
+ specifier: ^22.9.3
+ version: 22.20.1
+ '@types/react':
+ specifier: ^18.3.12
+ version: 18.3.31
+ '@types/react-dom':
+ specifier: ^18.3.1
+ version: 18.3.7(@types/react@18.3.31)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@5.4.21(@types/node@22.20.1))
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.5.4(postcss@8.5.26)
+ eslint:
+ specifier: ^8.57.1
+ version: 8.57.1
+ eslint-config-next:
+ specifier: ^14.2.18
+ version: 14.2.35(eslint@8.57.1)(typescript@5.9.3)
+ happy-dom:
+ specifier: ^15.11.7
+ version: 15.11.7
+ postcss:
+ specifier: ^8.4.49
+ version: 8.5.26
+ tailwindcss:
+ specifier: ^3.4.15
+ version: 3.4.19
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.3
+ vitest:
+ specifier: ^2.1.8
+ version: 2.1.9(@types/node@22.20.1)(happy-dom@15.11.7)
+
+ packages/shared-types:
+ devDependencies:
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.3
+
+packages:
+
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.8':
+ resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.8':
+ resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.8':
+ resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@esbuild/aix-ppc64@0.21.5':
+ resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.21.5':
+ resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.21.5':
+ resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.21.5':
+ resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.21.5':
+ resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.21.5':
+ resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.21.5':
+ resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.21.5':
+ resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.21.5':
+ resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.21.5':
+ resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.21.5':
+ resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.21.5':
+ resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.21.5':
+ resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.21.5':
+ resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.21.5':
+ resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.21.5':
+ resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-x64@0.21.5':
+ resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-x64@0.21.5':
+ resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.21.5':
+ resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.21.5':
+ resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.21.5':
+ resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.21.5':
+ resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.10.1':
+ resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/eslintrc@2.1.4':
+ resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ '@eslint/js@8.57.1':
+ resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ '@humanwhocodes/config-array@0.13.0':
+ resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
+ engines: {node: '>=10.10.0'}
+ deprecated: Use @eslint/config-array instead
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/object-schema@2.0.3':
+ resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
+ deprecated: Use @eslint/object-schema instead
+
+ '@isaacs/cliui@8.0.2':
+ resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+ engines: {node: '>=12'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+
+ '@napi-rs/wasm-runtime@1.2.3':
+ resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
+ '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
+
+ '@next/env@14.2.35':
+ resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
+
+ '@next/eslint-plugin-next@14.2.35':
+ resolution: {integrity: sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==}
+
+ '@next/swc-darwin-arm64@14.2.33':
+ resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@next/swc-darwin-x64@14.2.33':
+ resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@next/swc-linux-arm64-gnu@14.2.33':
+ resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@next/swc-linux-arm64-musl@14.2.33':
+ resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@next/swc-linux-x64-gnu@14.2.33':
+ resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@next/swc-linux-x64-musl@14.2.33':
+ resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@next/swc-win32-arm64-msvc@14.2.33':
+ resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@next/swc-win32-ia32-msvc@14.2.33':
+ resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==}
+ engines: {node: '>= 10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@next/swc-win32-x64-msvc@14.2.33':
+ resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@nolyfill/is-core-module@1.0.39':
+ resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
+ engines: {node: '>=12.4.0'}
+
+ '@pkgjs/parseargs@0.11.0':
+ resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
+ engines: {node: '>=14'}
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.62.4':
+ resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.62.4':
+ resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.62.4':
+ resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.62.4':
+ resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.62.4':
+ resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.62.4':
+ resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.4':
+ resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.4':
+ resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.4':
+ resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.62.4':
+ resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.4':
+ resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-musl@4.62.4':
+ resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.4':
+ resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.4':
+ resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.4':
+ resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.4':
+ resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.4':
+ resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.62.4':
+ resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.62.4':
+ resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openbsd-x64@4.62.4':
+ resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.62.4':
+ resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.4':
+ resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.4':
+ resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.62.4':
+ resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.62.4':
+ resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rtsao/scc@1.1.0':
+ resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+
+ '@rushstack/eslint-patch@1.16.1':
+ resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/helpers@0.5.5':
+ resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
+
+ '@tonejs/midi@2.0.28':
+ resolution: {integrity: sha512-RII6YpInPsOZ5t3Si/20QKpNqB1lZ2OCFJSOzJxz38YdY/3zqDr3uaml4JuCWkdixuPqP1/TBnXzhQ39csyoVg==}
+
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/json5@0.0.29':
+ resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+
+ '@types/node@22.20.1':
+ resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
+
+ '@types/prop-types@15.7.15':
+ resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+
+ '@types/react-dom@18.3.7':
+ resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
+ peerDependencies:
+ '@types/react': ^18.0.0
+
+ '@types/react@18.3.31':
+ resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
+
+ '@typescript-eslint/eslint-plugin@8.67.0':
+ resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.67.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.67.0':
+ resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/project-service@8.67.0':
+ resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.67.0':
+ resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.67.0':
+ resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.67.0':
+ resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/types@8.67.0':
+ resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.67.0':
+ resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.67.0':
+ resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.67.0':
+ resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@ungap/structured-clone@1.3.3':
+ resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
+
+ '@unrs/resolver-binding-android-arm-eabi@1.12.2':
+ resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
+ cpu: [arm]
+ os: [android]
+
+ '@unrs/resolver-binding-android-arm64@1.12.2':
+ resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@unrs/resolver-binding-darwin-arm64@1.12.2':
+ resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@unrs/resolver-binding-darwin-x64@1.12.2':
+ resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@unrs/resolver-binding-freebsd-x64@1.12.2':
+ resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
+ resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
+ resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
+ cpu: [arm]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
+ resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
+ resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
+ resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
+ resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
+ resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
+ resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
+ resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
+ resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
+ resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@unrs/resolver-binding-linux-x64-musl@1.12.2':
+ resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
+ cpu: [x64]
+ os: [linux]
+
+ '@unrs/resolver-binding-openharmony-arm64@1.12.2':
+ resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@unrs/resolver-binding-wasm32-wasi@1.12.2':
+ resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
+ resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
+ resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
+ resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ '@vitest/expect@2.1.9':
+ resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
+
+ '@vitest/mocker@2.1.9':
+ resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@2.1.9':
+ resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
+
+ '@vitest/runner@2.1.9':
+ resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
+
+ '@vitest/snapshot@2.1.9':
+ resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
+
+ '@vitest/spy@2.1.9':
+ resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
+
+ '@vitest/utils@2.1.9':
+ resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.18.0:
+ resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-regex@6.3.0:
+ resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
+ engines: {node: '>=12'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ array-buffer-byte-length@1.0.2:
+ resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
+ engines: {node: '>= 0.4'}
+
+ array-flatten@3.0.0:
+ resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==}
+
+ array-includes@3.1.9:
+ resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlast@1.2.5:
+ resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.findlastindex@1.2.6:
+ resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flat@1.3.3:
+ resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.flatmap@1.3.3:
+ resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
+ engines: {node: '>= 0.4'}
+
+ array.prototype.tosorted@1.1.4:
+ resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
+ engines: {node: '>= 0.4'}
+
+ arraybuffer.prototype.slice@1.0.4:
+ resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
+ engines: {node: '>= 0.4'}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ ast-types-flow@0.0.8:
+ resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+
+ async-function@1.0.0:
+ resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
+ engines: {node: '>= 0.4'}
+
+ autoprefixer@10.5.4:
+ resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
+ available-typed-arrays@1.0.7:
+ resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
+ engines: {node: '>= 0.4'}
+
+ axe-core@4.13.0:
+ resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
+ engines: {node: '>=4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ baseline-browser-mapping@2.11.14:
+ resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ binary-extensions@2.3.0:
+ resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
+ engines: {node: '>=8'}
+
+ brace-expansion@1.1.18:
+ resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
+
+ brace-expansion@2.1.4:
+ resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
+
+ brace-expansion@5.0.9:
+ resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
+ engines: {node: 20 || >=22}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.28.8:
+ resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ busboy@1.6.0:
+ resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
+ engines: {node: '>=10.16.0'}
+
+ cac@6.7.14:
+ resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+ engines: {node: '>=8'}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bind@1.0.9:
+ resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
+ caniuse-lite@1.0.30001809:
+ resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
+
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
+ chokidar@3.6.0:
+ resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
+ engines: {node: '>= 8.10.0'}
+
+ client-only@0.0.1:
+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
+ engines: {node: '>= 6'}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ damerau-levenshtein@1.0.8:
+ resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
+
+ data-view-buffer@1.0.2:
+ resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-length@1.0.2:
+ resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
+ engines: {node: '>= 0.4'}
+
+ data-view-byte-offset@1.0.1:
+ resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
+ engines: {node: '>= 0.4'}
+
+ debug@3.2.7:
+ resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+
+ dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+
+ doctrine@2.1.0:
+ resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
+ engines: {node: '>=0.10.0'}
+
+ doctrine@3.0.0:
+ resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
+ engines: {node: '>=6.0.0'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ eastasianwidth@0.2.0:
+ resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
+ electron-to-chromium@1.5.407:
+ resolution: {integrity: sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==}
+
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
+ emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ es-abstract-get@1.0.0:
+ resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
+ engines: {node: '>= 0.4'}
+
+ es-abstract@1.24.2:
+ resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-iterator-helpers@1.4.0:
+ resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
+ engines: {node: '>= 0.4'}
+
+ es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ es-shim-unscopables@1.1.0:
+ resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
+ engines: {node: '>= 0.4'}
+
+ es-to-primitive@1.3.4:
+ resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.21.5:
+ resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-config-next@14.2.35:
+ resolution: {integrity: sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==}
+ peerDependencies:
+ eslint: ^7.23.0 || ^8.0.0
+ typescript: '>=3.3.1'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ eslint-import-resolver-node@0.3.10:
+ resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
+
+ eslint-import-resolver-typescript@3.10.1:
+ resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ eslint: '*'
+ eslint-plugin-import: '*'
+ eslint-plugin-import-x: '*'
+ peerDependenciesMeta:
+ eslint-plugin-import:
+ optional: true
+ eslint-plugin-import-x:
+ optional: true
+
+ eslint-module-utils@2.14.0:
+ resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: '*'
+ eslint-import-resolver-node: '*'
+ eslint-import-resolver-typescript: '*'
+ eslint-import-resolver-webpack: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+ eslint:
+ optional: true
+ eslint-import-resolver-node:
+ optional: true
+ eslint-import-resolver-typescript:
+ optional: true
+ eslint-import-resolver-webpack:
+ optional: true
+
+ eslint-plugin-import@2.32.0:
+ resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+
+ eslint-plugin-jsx-a11y@6.10.2:
+ resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
+
+ eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705:
+ resolution: {integrity: sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
+
+ eslint-plugin-react@7.37.5:
+ resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
+
+ eslint-scope@7.2.2:
+ resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@8.57.1:
+ resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
+ hasBin: true
+
+ espree@9.6.1:
+ resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@6.0.1:
+ resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
+ engines: {node: ^10.12.0 || >=12.0.0}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@3.2.0:
+ resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
+ engines: {node: ^10.12.0 || >=12.0.0}
+
+ flatted@3.4.4:
+ resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
+
+ for-each@0.3.5:
+ resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
+ engines: {node: '>= 0.4'}
+
+ foreground-child@3.3.1:
+ resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
+ engines: {node: '>=14'}
+
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
+ fs.realpath@1.0.0:
+ resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ function.prototype.name@1.2.0:
+ resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
+ engines: {node: '>= 0.4'}
+
+ functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+
+ generator-function@2.0.1:
+ resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
+ engines: {node: '>= 0.4'}
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ get-symbol-description@1.1.0:
+ resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
+ engines: {node: '>= 0.4'}
+
+ get-tsconfig@4.14.2:
+ resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==}
+
+ glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ glob@10.3.10:
+ resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ hasBin: true
+
+ glob@7.2.3:
+ resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+
+ globals@13.24.0:
+ resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
+ engines: {node: '>=8'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ graphemer@1.4.0:
+ resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+
+ happy-dom@15.11.7:
+ resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==}
+ engines: {node: '>=18.0.0'}
+
+ has-bigints@1.1.0:
+ resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-proto@1.2.0:
+ resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.6:
+ resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ inflight@1.0.6:
+ resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+ 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.
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ internal-slot@1.1.0:
+ resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
+ engines: {node: '>= 0.4'}
+
+ is-array-buffer@3.0.5:
+ resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
+ engines: {node: '>= 0.4'}
+
+ is-async-function@2.1.1:
+ resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
+ engines: {node: '>= 0.4'}
+
+ is-bigint@1.1.0:
+ resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
+ engines: {node: '>= 0.4'}
+
+ is-binary-path@2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+
+ is-boolean-object@1.2.2:
+ resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
+ engines: {node: '>= 0.4'}
+
+ is-bun-module@2.0.0:
+ resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
+
+ is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ is-data-view@1.0.2:
+ resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
+ engines: {node: '>= 0.4'}
+
+ is-date-object@1.1.0:
+ resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
+ engines: {node: '>= 0.4'}
+
+ is-document.all@1.0.0:
+ resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
+ engines: {node: '>= 0.4'}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-finalizationregistry@1.1.1:
+ resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
+ engines: {node: '>= 0.4'}
+
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
+ is-generator-function@1.1.2:
+ resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
+ engines: {node: '>= 0.4'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-map@2.0.3:
+ resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
+ engines: {node: '>= 0.4'}
+
+ is-negative-zero@2.0.3:
+ resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
+ engines: {node: '>= 0.4'}
+
+ is-number-object@1.1.1:
+ resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
+ engines: {node: '>= 0.4'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-path-inside@3.0.3:
+ resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
+ engines: {node: '>=8'}
+
+ is-regex@1.2.1:
+ resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
+ engines: {node: '>= 0.4'}
+
+ is-set@2.0.3:
+ resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
+ engines: {node: '>= 0.4'}
+
+ is-shared-array-buffer@1.0.4:
+ resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
+ engines: {node: '>= 0.4'}
+
+ is-string@1.1.1:
+ resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
+ engines: {node: '>= 0.4'}
+
+ is-symbol@1.1.1:
+ resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
+ engines: {node: '>= 0.4'}
+
+ is-typed-array@1.1.15:
+ resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
+ engines: {node: '>= 0.4'}
+
+ is-weakmap@2.0.2:
+ resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
+ engines: {node: '>= 0.4'}
+
+ is-weakref@1.1.1:
+ resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
+ engines: {node: '>= 0.4'}
+
+ is-weakset@2.0.4:
+ resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
+ engines: {node: '>= 0.4'}
+
+ isarray@2.0.5:
+ resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ iterator.prototype@1.1.5:
+ resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
+ engines: {node: '>= 0.4'}
+
+ jackspeak@2.3.6:
+ resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==}
+ engines: {node: '>=14'}
+
+ jiti@1.21.7:
+ resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.3.1:
+ resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
+ hasBin: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@1.0.2:
+ resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
+ engines: {node: '>=4.0'}
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ language-subtag-registry@0.3.23:
+ resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
+
+ language-tags@1.0.9:
+ resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
+ engines: {node: '>=0.10'}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lilconfig@3.1.3:
+ resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
+ engines: {node: '>=14'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
+ lru-cache@10.4.3:
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ midi-file@1.2.4:
+ resolution: {integrity: sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==}
+
+ minimatch@10.2.6:
+ resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimatch@3.1.5:
+ resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ minipass@7.1.3:
+ resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
+ nanoid@3.3.18:
+ resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ napi-postinstall@0.3.4:
+ resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
+ engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ hasBin: true
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ next@14.2.35:
+ resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==}
+ engines: {node: '>=18.17.0'}
+ hasBin: true
+ peerDependencies:
+ '@opentelemetry/api': ^1.1.0
+ '@playwright/test': ^1.41.2
+ react: ^18.2.0
+ react-dom: ^18.2.0
+ sass: ^1.3.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@playwright/test':
+ optional: true
+ sass:
+ optional: true
+
+ node-exports-info@1.6.2:
+ resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
+ engines: {node: '>= 0.4'}
+
+ node-releases@2.0.53:
+ resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
+ engines: {node: '>=18'}
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ object.assign@4.1.7:
+ resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
+ engines: {node: '>= 0.4'}
+
+ object.entries@1.1.9:
+ resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
+ engines: {node: '>= 0.4'}
+
+ object.fromentries@2.0.8:
+ resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
+ engines: {node: '>= 0.4'}
+
+ object.groupby@1.0.3:
+ resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
+ engines: {node: '>= 0.4'}
+
+ object.values@1.2.1:
+ resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
+ engines: {node: '>= 0.4'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ own-keys@1.0.2:
+ resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
+ engines: {node: '>= 0.4'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-is-absolute@1.0.1:
+ resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+ engines: {node: '>=0.10.0'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ path-scurry@1.11.1:
+ resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
+ engines: {node: '>=16 || 14 >=14.18'}
+
+ pathe@1.1.2:
+ resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
+ engines: {node: '>=12'}
+
+ pify@2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+ engines: {node: '>=0.10.0'}
+
+ pirates@4.0.7:
+ resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
+ engines: {node: '>= 6'}
+
+ possible-typed-array-names@1.1.0:
+ resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
+ engines: {node: '>= 0.4'}
+
+ postcss-import@15.1.0:
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-load-config@6.0.1:
+ resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ jiti: '>=1.21.0'
+ postcss: '>=8.0.9'
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+ postcss:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.4:
+ resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
+ engines: {node: '>=4'}
+
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
+ postcss@8.4.31:
+ resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postcss@8.5.26:
+ resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ react-dom@18.3.1:
+ resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ peerDependencies:
+ react: ^18.3.1
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react@18.3.1:
+ resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+ engines: {node: '>=0.10.0'}
+
+ read-cache@1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+
+ readdirp@3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+
+ reflect.getprototypeof@1.0.10:
+ resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
+ engines: {node: '>= 0.4'}
+
+ regexp.prototype.flags@1.5.4:
+ resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
+ engines: {node: '>= 0.4'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ resolve@2.0.0-next.7:
+ resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ rimraf@3.0.2:
+ resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
+ deprecated: Rimraf versions prior to v4 are no longer supported
+ hasBin: true
+
+ rollup@4.62.4:
+ resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ safe-array-concat@1.1.4:
+ resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
+ engines: {node: '>=0.4'}
+
+ safe-push-apply@1.0.0:
+ resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
+ engines: {node: '>= 0.4'}
+
+ safe-regex-test@1.1.0:
+ resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
+ engines: {node: '>= 0.4'}
+
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-function-length@1.2.2:
+ resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
+ engines: {node: '>= 0.4'}
+
+ set-function-name@2.0.2:
+ resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
+ engines: {node: '>= 0.4'}
+
+ set-proto@1.0.0:
+ resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
+ engines: {node: '>= 0.4'}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ stable-hash@0.0.5:
+ resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
+ stop-iteration-iterator@1.1.0:
+ resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
+ engines: {node: '>= 0.4'}
+
+ streamsearch@1.1.0:
+ resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
+ engines: {node: '>=10.0.0'}
+
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ string-width@5.1.2:
+ resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+ engines: {node: '>=12'}
+
+ string.prototype.includes@2.0.1:
+ resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.matchall@4.0.12:
+ resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.repeat@1.0.0:
+ resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
+
+ string.prototype.trim@1.2.11:
+ resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimend@1.0.10:
+ resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
+ engines: {node: '>= 0.4'}
+
+ string.prototype.trimstart@1.0.8:
+ resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
+ engines: {node: '>= 0.4'}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
+ strip-ansi@7.2.0:
+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+ engines: {node: '>=12'}
+
+ strip-bom@3.0.0:
+ resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
+ engines: {node: '>=4'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ styled-jsx@5.1.1:
+ resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
+ engines: {node: '>= 12.0.0'}
+ peerDependencies:
+ '@babel/core': '*'
+ babel-plugin-macros: '*'
+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0'
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ babel-plugin-macros:
+ optional: true
+
+ sucrase@3.35.1:
+ resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tailwindcss@3.4.19:
+ resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ text-table@0.2.0:
+ resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
+
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tinypool@1.1.1:
+ resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
+ tinyrainbow@1.2.0:
+ resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
+ engines: {node: '>=14.0.0'}
+
+ tinyspy@3.0.2:
+ resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
+ engines: {node: '>=14.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+
+ tsconfig-paths@3.15.0:
+ resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ type-fest@0.20.2:
+ resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
+ engines: {node: '>=10'}
+
+ typed-array-buffer@1.0.3:
+ resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-length@1.0.3:
+ resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-byte-offset@1.0.4:
+ resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
+ engines: {node: '>= 0.4'}
+
+ typed-array-length@1.0.8:
+ resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
+ engines: {node: '>= 0.4'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ unbox-primitive@1.1.0:
+ resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
+ engines: {node: '>= 0.4'}
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ unrs-resolver@1.12.2:
+ resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
+
+ update-browserslist-db@1.3.1:
+ resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vite-node@2.1.9:
+ resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || >=20.0.0
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+
+ vitest@2.1.9:
+ resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/node': ^18.0.0 || >=20.0.0
+ '@vitest/browser': 2.1.9
+ '@vitest/ui': 2.1.9
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
+ which-boxed-primitive@1.1.1:
+ resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
+ engines: {node: '>= 0.4'}
+
+ which-builtin-type@1.2.1:
+ resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
+ engines: {node: '>= 0.4'}
+
+ which-collection@1.0.2:
+ resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
+ engines: {node: '>= 0.4'}
+
+ which-typed-array@1.1.22:
+ resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
+ engines: {node: '>= 0.4'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
+ wrap-ansi@8.1.0:
+ resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+ engines: {node: '>=12'}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+snapshots:
+
+ '@alloc/quick-lru@5.2.0': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.8':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.8
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.8
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+
+ '@babel/parser@7.29.8':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+
+ '@babel/traverse@7.29.8':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.8
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@esbuild/aix-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm@0.21.5':
+ optional: true
+
+ '@esbuild/android-x64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-x64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/linux-loong64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-s390x@0.21.5':
+ optional: true
+
+ '@esbuild/linux-x64@0.21.5':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/sunos-x64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/win32-x64@0.21.5':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)':
+ dependencies:
+ eslint: 8.57.1
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/eslintrc@2.1.4':
+ dependencies:
+ ajv: 6.15.0
+ debug: 4.4.3
+ espree: 9.6.1
+ globals: 13.24.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.3.1
+ minimatch: 3.1.5
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@8.57.1': {}
+
+ '@humanwhocodes/config-array@0.13.0':
+ dependencies:
+ '@humanwhocodes/object-schema': 2.0.3
+ debug: 4.4.3
+ minimatch: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/object-schema@2.0.3': {}
+
+ '@isaacs/cliui@8.0.2':
+ dependencies:
+ string-width: 5.1.2
+ string-width-cjs: string-width@4.2.3
+ strip-ansi: 7.2.0
+ strip-ansi-cjs: strip-ansi@6.0.1
+ wrap-ansi: 8.1.0
+ wrap-ansi-cjs: wrap-ansi@7.0.0
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@next/env@14.2.35': {}
+
+ '@next/eslint-plugin-next@14.2.35':
+ dependencies:
+ glob: 10.3.10
+
+ '@next/swc-darwin-arm64@14.2.33':
+ optional: true
+
+ '@next/swc-darwin-x64@14.2.33':
+ optional: true
+
+ '@next/swc-linux-arm64-gnu@14.2.33':
+ optional: true
+
+ '@next/swc-linux-arm64-musl@14.2.33':
+ optional: true
+
+ '@next/swc-linux-x64-gnu@14.2.33':
+ optional: true
+
+ '@next/swc-linux-x64-musl@14.2.33':
+ optional: true
+
+ '@next/swc-win32-arm64-msvc@14.2.33':
+ optional: true
+
+ '@next/swc-win32-ia32-msvc@14.2.33':
+ optional: true
+
+ '@next/swc-win32-x64-msvc@14.2.33':
+ optional: true
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.20.1
+
+ '@nolyfill/is-core-module@1.0.39': {}
+
+ '@pkgjs/parseargs@0.11.0':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.62.4':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.62.4':
+ optional: true
+
+ '@rtsao/scc@1.1.0': {}
+
+ '@rushstack/eslint-patch@1.16.1': {}
+
+ '@swc/counter@0.1.3': {}
+
+ '@swc/helpers@0.5.5':
+ dependencies:
+ '@swc/counter': 0.1.3
+ tslib: 2.8.1
+
+ '@tonejs/midi@2.0.28':
+ dependencies:
+ array-flatten: 3.0.0
+ midi-file: 1.2.4
+
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@babel/types': 7.29.8
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@types/estree@1.0.9': {}
+
+ '@types/json5@0.0.29': {}
+
+ '@types/node@22.20.1':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/prop-types@15.7.15': {}
+
+ '@types/react-dom@18.3.7(@types/react@18.3.31)':
+ dependencies:
+ '@types/react': 18.3.31
+
+ '@types/react@18.3.31':
+ dependencies:
+ '@types/prop-types': 15.7.15
+ csstype: 3.2.3
+
+ '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.67.0
+ '@typescript-eslint/type-utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.67.0
+ eslint: 8.57.1
+ ignore: 7.0.6
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.67.0
+ '@typescript-eslint/types': 8.67.0
+ '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.67.0
+ debug: 4.4.3
+ eslint: 8.57.1
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.67.0
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.67.0':
+ dependencies:
+ '@typescript-eslint/types': 8.67.0
+ '@typescript-eslint/visitor-keys': 8.67.0
+
+ '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.67.0
+ '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 8.57.1
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.67.0': {}
+
+ '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.67.0
+ '@typescript-eslint/visitor-keys': 8.67.0
+ debug: 4.4.3
+ minimatch: 10.2.6
+ semver: 7.8.5
+ tinyglobby: 0.2.17
+ ts-api-utils: 2.5.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.67.0(eslint@8.57.1)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1)
+ '@typescript-eslint/scope-manager': 8.67.0
+ '@typescript-eslint/types': 8.67.0
+ '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
+ eslint: 8.57.1
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.67.0':
+ dependencies:
+ '@typescript-eslint/types': 8.67.0
+ eslint-visitor-keys: 5.0.1
+
+ '@ungap/structured-clone@1.3.3': {}
+
+ '@unrs/resolver-binding-android-arm-eabi@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-android-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-darwin-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-darwin-x64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-freebsd-x64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-linux-x64-musl@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-openharmony-arm64@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-wasm32-wasi@1.12.2':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
+ optional: true
+
+ '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
+ optional: true
+
+ '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 5.4.21(@types/node@22.20.1)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@vitest/expect@2.1.9':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ tinyrainbow: 1.2.0
+
+ '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.1))':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 5.4.21(@types/node@22.20.1)
+
+ '@vitest/pretty-format@2.1.9':
+ dependencies:
+ tinyrainbow: 1.2.0
+
+ '@vitest/runner@2.1.9':
+ dependencies:
+ '@vitest/utils': 2.1.9
+ pathe: 1.1.2
+
+ '@vitest/snapshot@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ magic-string: 0.30.21
+ pathe: 1.1.2
+
+ '@vitest/spy@2.1.9':
+ dependencies:
+ tinyspy: 3.0.2
+
+ '@vitest/utils@2.1.9':
+ dependencies:
+ '@vitest/pretty-format': 2.1.9
+ loupe: 3.2.1
+ tinyrainbow: 1.2.0
+
+ acorn-jsx@5.3.2(acorn@8.18.0):
+ dependencies:
+ acorn: 8.18.0
+
+ acorn@8.18.0: {}
+
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ansi-regex@5.0.1: {}
+
+ ansi-regex@6.3.0: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ ansi-styles@6.2.3: {}
+
+ any-promise@1.3.0: {}
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ arg@5.0.2: {}
+
+ argparse@2.0.1: {}
+
+ aria-query@5.3.2: {}
+
+ array-buffer-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ is-array-buffer: 3.0.5
+
+ array-flatten@3.0.0: {}
+
+ array-includes@3.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ is-string: 1.1.1
+ math-intrinsics: 1.1.0
+
+ array.prototype.findlast@1.2.5:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.findlastindex@1.2.6:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flat@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.flatmap@1.3.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-shim-unscopables: 1.1.0
+
+ array.prototype.tosorted@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-shim-unscopables: 1.1.0
+
+ arraybuffer.prototype.slice@1.0.4:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ is-array-buffer: 3.0.5
+
+ assertion-error@2.0.1: {}
+
+ ast-types-flow@0.0.8: {}
+
+ async-function@1.0.0: {}
+
+ autoprefixer@10.5.4(postcss@8.5.26):
+ dependencies:
+ browserslist: 4.28.8
+ caniuse-lite: 1.0.30001809
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.26
+ postcss-value-parser: 4.2.0
+
+ available-typed-arrays@1.0.7:
+ dependencies:
+ possible-typed-array-names: 1.1.0
+
+ axe-core@4.13.0: {}
+
+ axobject-query@4.1.0: {}
+
+ balanced-match@1.0.2: {}
+
+ balanced-match@4.0.4: {}
+
+ baseline-browser-mapping@2.11.14: {}
+
+ binary-extensions@2.3.0: {}
+
+ brace-expansion@1.1.18:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.1.4:
+ dependencies:
+ balanced-match: 1.0.2
+
+ brace-expansion@5.0.9:
+ dependencies:
+ balanced-match: 4.0.4
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.28.8:
+ dependencies:
+ baseline-browser-mapping: 2.11.14
+ caniuse-lite: 1.0.30001809
+ electron-to-chromium: 1.5.407
+ node-releases: 2.0.53
+ update-browserslist-db: 1.3.1(browserslist@4.28.8)
+
+ busboy@1.6.0:
+ dependencies:
+ streamsearch: 1.1.0
+
+ cac@6.7.14: {}
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bind@1.0.9:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ get-intrinsic: 1.3.0
+ set-function-length: 1.2.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ callsites@3.1.0: {}
+
+ camelcase-css@2.0.1: {}
+
+ caniuse-lite@1.0.30001809: {}
+
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ check-error@2.1.3: {}
+
+ chokidar@3.6.0:
+ dependencies:
+ anymatch: 3.1.3
+ braces: 3.0.3
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ client-only@0.0.1: {}
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ commander@4.1.1: {}
+
+ concat-map@0.0.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ cssesc@3.0.0: {}
+
+ csstype@3.2.3: {}
+
+ damerau-levenshtein@1.0.8: {}
+
+ data-view-buffer@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-length@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ data-view-byte-offset@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-data-view: 1.0.2
+
+ debug@3.2.7:
+ dependencies:
+ ms: 2.1.3
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-eql@5.0.2: {}
+
+ deep-is@0.1.4: {}
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ didyoumean@1.2.2: {}
+
+ dlv@1.1.3: {}
+
+ doctrine@2.1.0:
+ dependencies:
+ esutils: 2.0.3
+
+ doctrine@3.0.0:
+ dependencies:
+ esutils: 2.0.3
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ eastasianwidth@0.2.0: {}
+
+ electron-to-chromium@1.5.407: {}
+
+ emoji-regex@8.0.0: {}
+
+ emoji-regex@9.2.2: {}
+
+ entities@4.5.0: {}
+
+ es-abstract-get@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ is-callable: 1.2.7
+ object-inspect: 1.13.4
+
+ es-abstract@1.24.2:
+ dependencies:
+ array-buffer-byte-length: 1.0.2
+ arraybuffer.prototype.slice: 1.0.4
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ data-view-buffer: 1.0.2
+ data-view-byte-length: 1.0.2
+ data-view-byte-offset: 1.0.1
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ es-set-tostringtag: 2.1.0
+ es-to-primitive: 1.3.4
+ function.prototype.name: 1.2.0
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ get-symbol-description: 1.1.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ internal-slot: 1.1.0
+ is-array-buffer: 3.0.5
+ is-callable: 1.2.7
+ is-data-view: 1.0.2
+ is-negative-zero: 2.0.3
+ is-regex: 1.2.1
+ is-set: 2.0.3
+ is-shared-array-buffer: 1.0.4
+ is-string: 1.1.1
+ is-typed-array: 1.1.15
+ is-weakref: 1.1.1
+ math-intrinsics: 1.1.0
+ object-inspect: 1.13.4
+ object-keys: 1.1.1
+ object.assign: 4.1.7
+ own-keys: 1.0.2
+ regexp.prototype.flags: 1.5.4
+ safe-array-concat: 1.1.4
+ safe-push-apply: 1.0.0
+ safe-regex-test: 1.1.0
+ set-proto: 1.0.0
+ stop-iteration-iterator: 1.1.0
+ string.prototype.trim: 1.2.11
+ string.prototype.trimend: 1.0.10
+ string.prototype.trimstart: 1.0.8
+ typed-array-buffer: 1.0.3
+ typed-array-byte-length: 1.0.3
+ typed-array-byte-offset: 1.0.4
+ typed-array-length: 1.0.8
+ unbox-primitive: 1.1.0
+ which-typed-array: 1.1.22
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-iterator-helpers@1.4.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-set-tostringtag: 2.1.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ globalthis: 1.0.4
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+ has-proto: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ iterator.prototype: 1.1.5
+ math-intrinsics: 1.1.0
+
+ es-module-lexer@1.7.0: {}
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ es-shim-unscopables@1.1.0:
+ dependencies:
+ hasown: 2.0.4
+
+ es-to-primitive@1.3.4:
+ dependencies:
+ es-abstract-get: 1.0.0
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ is-callable: 1.2.7
+ is-date-object: 1.1.0
+ is-symbol: 1.1.1
+
+ esbuild@0.21.5:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.21.5
+ '@esbuild/android-arm': 0.21.5
+ '@esbuild/android-arm64': 0.21.5
+ '@esbuild/android-x64': 0.21.5
+ '@esbuild/darwin-arm64': 0.21.5
+ '@esbuild/darwin-x64': 0.21.5
+ '@esbuild/freebsd-arm64': 0.21.5
+ '@esbuild/freebsd-x64': 0.21.5
+ '@esbuild/linux-arm': 0.21.5
+ '@esbuild/linux-arm64': 0.21.5
+ '@esbuild/linux-ia32': 0.21.5
+ '@esbuild/linux-loong64': 0.21.5
+ '@esbuild/linux-mips64el': 0.21.5
+ '@esbuild/linux-ppc64': 0.21.5
+ '@esbuild/linux-riscv64': 0.21.5
+ '@esbuild/linux-s390x': 0.21.5
+ '@esbuild/linux-x64': 0.21.5
+ '@esbuild/netbsd-x64': 0.21.5
+ '@esbuild/openbsd-x64': 0.21.5
+ '@esbuild/sunos-x64': 0.21.5
+ '@esbuild/win32-arm64': 0.21.5
+ '@esbuild/win32-ia32': 0.21.5
+ '@esbuild/win32-x64': 0.21.5
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-config-next@14.2.35(eslint@8.57.1)(typescript@5.9.3):
+ dependencies:
+ '@next/eslint-plugin-next': 14.2.35
+ '@rushstack/eslint-patch': 1.16.1
+ '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
+ eslint: 8.57.1
+ eslint-import-resolver-node: 0.3.10
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
+ eslint-plugin-react: 7.37.5(eslint@8.57.1)
+ eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - eslint-import-resolver-webpack
+ - eslint-plugin-import-x
+ - supports-color
+
+ eslint-import-resolver-node@0.3.10:
+ dependencies:
+ debug: 3.2.7
+ is-core-module: 2.16.2
+ resolve: 2.0.0-next.7
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
+ dependencies:
+ '@nolyfill/is-core-module': 1.0.39
+ debug: 4.4.3
+ eslint: 8.57.1
+ get-tsconfig: 4.14.2
+ is-bun-module: 2.0.0
+ stable-hash: 0.0.5
+ tinyglobby: 0.2.17
+ unrs-resolver: 1.12.2
+ optionalDependencies:
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ dependencies:
+ debug: 3.2.7
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
+ eslint: 8.57.1
+ eslint-import-resolver-node: 0.3.10
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ dependencies:
+ '@rtsao/scc': 1.1.0
+ array-includes: 3.1.9
+ array.prototype.findlastindex: 1.2.6
+ array.prototype.flat: 1.3.3
+ array.prototype.flatmap: 1.3.3
+ debug: 3.2.7
+ doctrine: 2.1.0
+ eslint: 8.57.1
+ eslint-import-resolver-node: 0.3.10
+ eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ hasown: 2.0.4
+ is-core-module: 2.16.2
+ is-glob: 4.0.3
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ object.groupby: 1.0.3
+ object.values: 1.2.1
+ semver: 6.3.1
+ string.prototype.trimend: 1.0.10
+ tsconfig-paths: 3.15.0
+ optionalDependencies:
+ '@typescript-eslint/parser': 8.67.0(eslint@8.57.1)(typescript@5.9.3)
+ transitivePeerDependencies:
+ - eslint-import-resolver-typescript
+ - eslint-import-resolver-webpack
+ - supports-color
+
+ eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1):
+ dependencies:
+ aria-query: 5.3.2
+ array-includes: 3.1.9
+ array.prototype.flatmap: 1.3.3
+ ast-types-flow: 0.0.8
+ axe-core: 4.13.0
+ axobject-query: 4.1.0
+ damerau-levenshtein: 1.0.8
+ emoji-regex: 9.2.2
+ eslint: 8.57.1
+ hasown: 2.0.4
+ jsx-ast-utils: 3.3.5
+ language-tags: 1.0.9
+ minimatch: 3.1.5
+ object.fromentries: 2.0.8
+ safe-regex-test: 1.1.0
+ string.prototype.includes: 2.0.1
+
+ eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1):
+ dependencies:
+ eslint: 8.57.1
+
+ eslint-plugin-react@7.37.5(eslint@8.57.1):
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.findlast: 1.2.5
+ array.prototype.flatmap: 1.3.3
+ array.prototype.tosorted: 1.1.4
+ doctrine: 2.1.0
+ es-iterator-helpers: 1.4.0
+ eslint: 8.57.1
+ estraverse: 5.3.0
+ hasown: 2.0.4
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.5
+ object.entries: 1.1.9
+ object.fromentries: 2.0.8
+ object.values: 1.2.1
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.7
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.12
+ string.prototype.repeat: 1.0.0
+
+ eslint-scope@7.2.2:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@8.57.1:
+ dependencies:
+ '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1)
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/eslintrc': 2.1.4
+ '@eslint/js': 8.57.1
+ '@humanwhocodes/config-array': 0.13.0
+ '@humanwhocodes/module-importer': 1.0.1
+ '@nodelib/fs.walk': 1.2.8
+ '@ungap/structured-clone': 1.3.3
+ ajv: 6.15.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ doctrine: 3.0.0
+ escape-string-regexp: 4.0.0
+ eslint-scope: 7.2.2
+ eslint-visitor-keys: 3.4.3
+ espree: 9.6.1
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 6.0.1
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ globals: 13.24.0
+ graphemer: 1.4.0
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ is-path-inside: 3.0.3
+ js-yaml: 4.3.1
+ json-stable-stringify-without-jsonify: 1.0.1
+ levn: 0.4.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ strip-ansi: 6.0.1
+ text-table: 0.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@9.6.1:
+ dependencies:
+ acorn: 8.18.0
+ acorn-jsx: 5.3.2(acorn@8.18.0)
+ eslint-visitor-keys: 3.4.3
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
+ esutils@2.0.3: {}
+
+ expect-type@1.4.0: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
+ file-entry-cache@6.0.1:
+ dependencies:
+ flat-cache: 3.2.0
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@3.2.0:
+ dependencies:
+ flatted: 3.4.4
+ keyv: 4.5.4
+ rimraf: 3.0.2
+
+ flatted@3.4.4: {}
+
+ for-each@0.3.5:
+ dependencies:
+ is-callable: 1.2.7
+
+ foreground-child@3.3.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ signal-exit: 4.1.0
+
+ fraction.js@5.3.4: {}
+
+ fs.realpath@1.0.0: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ function.prototype.name@1.2.0:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+ hasown: 2.0.4
+ is-callable: 1.2.7
+ is-document.all: 1.0.0
+
+ functions-have-names@1.2.3: {}
+
+ generator-function@2.0.1: {}
+
+ gensync@1.0.0-beta.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ get-symbol-description@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+
+ get-tsconfig@4.14.2:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob@10.3.10:
+ dependencies:
+ foreground-child: 3.3.1
+ jackspeak: 2.3.6
+ minimatch: 9.0.9
+ minipass: 7.1.3
+ path-scurry: 1.11.1
+
+ glob@7.2.3:
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.5
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+
+ globals@13.24.0:
+ dependencies:
+ type-fest: 0.20.2
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ graphemer@1.4.0: {}
+
+ happy-dom@15.11.7:
+ dependencies:
+ entities: 4.5.0
+ webidl-conversions: 7.0.0
+ whatwg-mimetype: 3.0.0
+
+ has-bigints@1.1.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-proto@1.2.0:
+ dependencies:
+ dunder-proto: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.6: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ inflight@1.0.6:
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+
+ inherits@2.0.4: {}
+
+ internal-slot@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ hasown: 2.0.4
+ side-channel: 1.1.1
+
+ is-array-buffer@3.0.5:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ is-async-function@2.1.1:
+ dependencies:
+ async-function: 1.0.0
+ call-bound: 1.0.4
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-bigint@1.1.0:
+ dependencies:
+ has-bigints: 1.1.0
+
+ is-binary-path@2.1.0:
+ dependencies:
+ binary-extensions: 2.3.0
+
+ is-boolean-object@1.2.2:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-bun-module@2.0.0:
+ dependencies:
+ semver: 7.8.5
+
+ is-callable@1.2.7: {}
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.4
+
+ is-data-view@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ is-typed-array: 1.1.15
+
+ is-date-object@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-document.all@1.0.0:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-extglob@2.1.1: {}
+
+ is-finalizationregistry@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-fullwidth-code-point@3.0.0: {}
+
+ is-generator-function@1.1.2:
+ dependencies:
+ call-bound: 1.0.4
+ generator-function: 2.0.1
+ get-proto: 1.0.1
+ has-tostringtag: 1.0.2
+ safe-regex-test: 1.1.0
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-map@2.0.3: {}
+
+ is-negative-zero@2.0.3: {}
+
+ is-number-object@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-number@7.0.0: {}
+
+ is-path-inside@3.0.3: {}
+
+ is-regex@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ is-set@2.0.3: {}
+
+ is-shared-array-buffer@1.0.4:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-string@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-tostringtag: 1.0.2
+
+ is-symbol@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+ has-symbols: 1.1.0
+ safe-regex-test: 1.1.0
+
+ is-typed-array@1.1.15:
+ dependencies:
+ which-typed-array: 1.1.22
+
+ is-weakmap@2.0.2: {}
+
+ is-weakref@1.1.1:
+ dependencies:
+ call-bound: 1.0.4
+
+ is-weakset@2.0.4:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+
+ isarray@2.0.5: {}
+
+ isexe@2.0.0: {}
+
+ iterator.prototype@1.1.5:
+ dependencies:
+ define-data-property: 1.1.4
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ has-symbols: 1.1.0
+ set-function-name: 2.0.2
+
+ jackspeak@2.3.6:
+ dependencies:
+ '@isaacs/cliui': 8.0.2
+ optionalDependencies:
+ '@pkgjs/parseargs': 0.11.0
+
+ jiti@1.21.7: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.3.1:
+ dependencies:
+ argparse: 2.0.1
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@1.0.2:
+ dependencies:
+ minimist: 1.2.8
+
+ json5@2.2.3: {}
+
+ jsx-ast-utils@3.3.5:
+ dependencies:
+ array-includes: 3.1.9
+ array.prototype.flat: 1.3.3
+ object.assign: 4.1.7
+ object.values: 1.2.1
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ language-subtag-registry@0.3.23: {}
+
+ language-tags@1.0.9:
+ dependencies:
+ language-subtag-registry: 0.3.23
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lilconfig@3.1.3: {}
+
+ lines-and-columns@1.2.4: {}
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ loupe@3.2.1: {}
+
+ lru-cache@10.4.3: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ math-intrinsics@1.1.0: {}
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
+ midi-file@1.2.4: {}
+
+ minimatch@10.2.6:
+ dependencies:
+ brace-expansion: 5.0.9
+
+ minimatch@3.1.5:
+ dependencies:
+ brace-expansion: 1.1.18
+
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 2.1.4
+
+ minimist@1.2.8: {}
+
+ minipass@7.1.3: {}
+
+ ms@2.1.3: {}
+
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
+ nanoid@3.3.18: {}
+
+ napi-postinstall@0.3.4: {}
+
+ natural-compare@1.4.0: {}
+
+ next@14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@next/env': 14.2.35
+ '@swc/helpers': 0.5.5
+ busboy: 1.6.0
+ caniuse-lite: 1.0.30001809
+ graceful-fs: 4.2.11
+ postcss: 8.4.31
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ styled-jsx: 5.1.1(@babel/core@7.29.7)(react@18.3.1)
+ optionalDependencies:
+ '@next/swc-darwin-arm64': 14.2.33
+ '@next/swc-darwin-x64': 14.2.33
+ '@next/swc-linux-arm64-gnu': 14.2.33
+ '@next/swc-linux-arm64-musl': 14.2.33
+ '@next/swc-linux-x64-gnu': 14.2.33
+ '@next/swc-linux-x64-musl': 14.2.33
+ '@next/swc-win32-arm64-msvc': 14.2.33
+ '@next/swc-win32-ia32-msvc': 14.2.33
+ '@next/swc-win32-x64-msvc': 14.2.33
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
+
+ node-exports-info@1.6.2:
+ dependencies:
+ array.prototype.flatmap: 1.3.3
+ es-errors: 1.3.0
+ object.entries: 1.1.9
+ semver: 6.3.1
+
+ node-releases@2.0.53: {}
+
+ normalize-path@3.0.0: {}
+
+ object-assign@4.1.1: {}
+
+ object-hash@3.0.0: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ object.assign@4.1.7:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+ has-symbols: 1.1.0
+ object-keys: 1.1.1
+
+ object.entries@1.1.9:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ object.fromentries@2.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+
+ object.groupby@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ object.values@1.2.1:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ own-keys@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ object-keys: 1.1.1
+ safe-push-apply: 1.0.0
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-is-absolute@1.0.1: {}
+
+ path-key@3.1.1: {}
+
+ path-parse@1.0.7: {}
+
+ path-scurry@1.11.1:
+ dependencies:
+ lru-cache: 10.4.3
+ minipass: 7.1.3
+
+ pathe@1.1.2: {}
+
+ pathval@2.0.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.2: {}
+
+ picomatch@4.0.5: {}
+
+ pify@2.3.0: {}
+
+ pirates@4.0.7: {}
+
+ possible-typed-array-names@1.1.0: {}
+
+ postcss-import@15.1.0(postcss@8.5.26):
+ dependencies:
+ postcss: 8.5.26
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.12
+
+ postcss-js@4.1.0(postcss@8.5.26):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.26
+
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26):
+ dependencies:
+ lilconfig: 3.1.3
+ optionalDependencies:
+ jiti: 1.21.7
+ postcss: 8.5.26
+
+ postcss-nested@6.2.0(postcss@8.5.26):
+ dependencies:
+ postcss: 8.5.26
+ postcss-selector-parser: 6.1.4
+
+ postcss-selector-parser@6.1.4:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-value-parser@4.2.0: {}
+
+ postcss@8.4.31:
+ dependencies:
+ nanoid: 3.3.18
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postcss@8.5.26:
+ dependencies:
+ nanoid: 3.3.18
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prelude-ls@1.2.1: {}
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ punycode@2.3.1: {}
+
+ queue-microtask@1.2.3: {}
+
+ react-dom@18.3.1(react@18.3.1):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.3.1
+ scheduler: 0.23.2
+
+ react-is@16.13.1: {}
+
+ react-refresh@0.17.0: {}
+
+ react@18.3.1:
+ dependencies:
+ loose-envify: 1.4.0
+
+ read-cache@1.0.0:
+ dependencies:
+ pify: 2.3.0
+
+ readdirp@3.6.0:
+ dependencies:
+ picomatch: 2.3.2
+
+ reflect.getprototypeof@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ get-proto: 1.0.1
+ which-builtin-type: 1.2.1
+
+ regexp.prototype.flags@1.5.4:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-errors: 1.3.0
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ set-function-name: 2.0.2
+
+ resolve-from@4.0.0: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ resolve@2.0.0-next.7:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ node-exports-info: 1.6.2
+ object-keys: 1.1.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ reusify@1.1.0: {}
+
+ rimraf@3.0.2:
+ dependencies:
+ glob: 7.2.3
+
+ rollup@4.62.4:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.62.4
+ '@rollup/rollup-android-arm64': 4.62.4
+ '@rollup/rollup-darwin-arm64': 4.62.4
+ '@rollup/rollup-darwin-x64': 4.62.4
+ '@rollup/rollup-freebsd-arm64': 4.62.4
+ '@rollup/rollup-freebsd-x64': 4.62.4
+ '@rollup/rollup-linux-arm-gnueabihf': 4.62.4
+ '@rollup/rollup-linux-arm-musleabihf': 4.62.4
+ '@rollup/rollup-linux-arm64-gnu': 4.62.4
+ '@rollup/rollup-linux-arm64-musl': 4.62.4
+ '@rollup/rollup-linux-loong64-gnu': 4.62.4
+ '@rollup/rollup-linux-loong64-musl': 4.62.4
+ '@rollup/rollup-linux-ppc64-gnu': 4.62.4
+ '@rollup/rollup-linux-ppc64-musl': 4.62.4
+ '@rollup/rollup-linux-riscv64-gnu': 4.62.4
+ '@rollup/rollup-linux-riscv64-musl': 4.62.4
+ '@rollup/rollup-linux-s390x-gnu': 4.62.4
+ '@rollup/rollup-linux-x64-gnu': 4.62.4
+ '@rollup/rollup-linux-x64-musl': 4.62.4
+ '@rollup/rollup-openbsd-x64': 4.62.4
+ '@rollup/rollup-openharmony-arm64': 4.62.4
+ '@rollup/rollup-win32-arm64-msvc': 4.62.4
+ '@rollup/rollup-win32-ia32-msvc': 4.62.4
+ '@rollup/rollup-win32-x64-gnu': 4.62.4
+ '@rollup/rollup-win32-x64-msvc': 4.62.4
+ fsevents: 2.3.3
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ safe-array-concat@1.1.4:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ get-intrinsic: 1.3.0
+ has-symbols: 1.1.0
+ isarray: 2.0.5
+
+ safe-push-apply@1.0.0:
+ dependencies:
+ es-errors: 1.3.0
+ isarray: 2.0.5
+
+ safe-regex-test@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-regex: 1.2.1
+
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
+ semver@6.3.1: {}
+
+ semver@7.8.5: {}
+
+ set-function-length@1.2.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-property-descriptors: 1.0.2
+
+ set-function-name@2.0.2:
+ dependencies:
+ define-data-property: 1.1.4
+ es-errors: 1.3.0
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.2
+
+ set-proto@1.0.0:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ siginfo@2.0.0: {}
+
+ signal-exit@4.1.0: {}
+
+ source-map-js@1.2.1: {}
+
+ stable-hash@0.0.5: {}
+
+ stackback@0.0.2: {}
+
+ std-env@3.10.0: {}
+
+ stop-iteration-iterator@1.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ internal-slot: 1.1.0
+
+ streamsearch@1.1.0: {}
+
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ string-width@5.1.2:
+ dependencies:
+ eastasianwidth: 0.2.0
+ emoji-regex: 9.2.2
+ strip-ansi: 7.2.0
+
+ string.prototype.includes@2.0.1:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.matchall@4.0.12:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ get-intrinsic: 1.3.0
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ internal-slot: 1.1.0
+ regexp.prototype.flags: 1.5.4
+ set-function-name: 2.0.2
+ side-channel: 1.1.1
+
+ string.prototype.repeat@1.0.0:
+ dependencies:
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+
+ string.prototype.trim@1.2.11:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-data-property: 1.1.4
+ define-properties: 1.2.1
+ es-abstract: 1.24.2
+ es-object-atoms: 1.1.2
+ has-property-descriptors: 1.0.2
+ safe-regex-test: 1.1.0
+
+ string.prototype.trimend@1.0.10:
+ dependencies:
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ string.prototype.trimstart@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ define-properties: 1.2.1
+ es-object-atoms: 1.1.2
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
+ strip-ansi@7.2.0:
+ dependencies:
+ ansi-regex: 6.3.0
+
+ strip-bom@3.0.0: {}
+
+ strip-json-comments@3.1.1: {}
+
+ styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1):
+ dependencies:
+ client-only: 0.0.1
+ react: 18.3.1
+ optionalDependencies:
+ '@babel/core': 7.29.7
+
+ sucrase@3.35.1:
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ commander: 4.1.1
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.7
+ tinyglobby: 0.2.17
+ ts-interface-checker: 0.1.13
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tailwindcss@3.4.19:
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ arg: 5.0.2
+ chokidar: 3.6.0
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.3
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.21.7
+ lilconfig: 3.1.3
+ micromatch: 4.0.8
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.1.1
+ postcss: 8.5.26
+ postcss-import: 15.1.0(postcss@8.5.26)
+ postcss-js: 4.1.0(postcss@8.5.26)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)
+ postcss-nested: 6.2.0(postcss@8.5.26)
+ postcss-selector-parser: 6.1.4
+ resolve: 1.22.12
+ sucrase: 3.35.1
+ transitivePeerDependencies:
+ - tsx
+ - yaml
+
+ text-table@0.2.0: {}
+
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
+ tinybench@2.9.0: {}
+
+ tinyexec@0.3.2: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+
+ tinypool@1.1.1: {}
+
+ tinyrainbow@1.2.0: {}
+
+ tinyspy@3.0.2: {}
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ ts-api-utils@2.5.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ ts-interface-checker@0.1.13: {}
+
+ tsconfig-paths@3.15.0:
+ dependencies:
+ '@types/json5': 0.0.29
+ json5: 1.0.2
+ minimist: 1.2.8
+ strip-bom: 3.0.0
+
+ tslib@2.8.1: {}
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ type-fest@0.20.2: {}
+
+ typed-array-buffer@1.0.3:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-length@1.0.3:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+
+ typed-array-byte-offset@1.0.4:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ has-proto: 1.2.0
+ is-typed-array: 1.1.15
+ reflect.getprototypeof: 1.0.10
+
+ typed-array-length@1.0.8:
+ dependencies:
+ call-bind: 1.0.9
+ for-each: 0.3.5
+ gopd: 1.2.0
+ is-typed-array: 1.1.15
+ possible-typed-array-names: 1.1.0
+ reflect.getprototypeof: 1.0.10
+
+ typescript@5.9.3: {}
+
+ unbox-primitive@1.1.0:
+ dependencies:
+ call-bound: 1.0.4
+ has-bigints: 1.1.0
+ has-symbols: 1.1.0
+ which-boxed-primitive: 1.1.1
+
+ undici-types@6.21.0: {}
+
+ unrs-resolver@1.12.2:
+ dependencies:
+ napi-postinstall: 0.3.4
+ optionalDependencies:
+ '@unrs/resolver-binding-android-arm-eabi': 1.12.2
+ '@unrs/resolver-binding-android-arm64': 1.12.2
+ '@unrs/resolver-binding-darwin-arm64': 1.12.2
+ '@unrs/resolver-binding-darwin-x64': 1.12.2
+ '@unrs/resolver-binding-freebsd-x64': 1.12.2
+ '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
+ '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
+ '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
+ '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
+ '@unrs/resolver-binding-linux-x64-musl': 1.12.2
+ '@unrs/resolver-binding-openharmony-arm64': 1.12.2
+ '@unrs/resolver-binding-wasm32-wasi': 1.12.2
+ '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
+ '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
+ '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
+
+ update-browserslist-db@1.3.1(browserslist@4.28.8):
+ dependencies:
+ browserslist: 4.28.8
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ util-deprecate@1.0.2: {}
+
+ vite-node@2.1.9(@types/node@22.20.1):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3
+ es-module-lexer: 1.7.0
+ pathe: 1.1.2
+ vite: 5.4.21(@types/node@22.20.1)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
+ vite@5.4.21(@types/node@22.20.1):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.26
+ rollup: 4.62.4
+ optionalDependencies:
+ '@types/node': 22.20.1
+ fsevents: 2.3.3
+
+ vitest@2.1.9(@types/node@22.20.1)(happy-dom@15.11.7):
+ dependencies:
+ '@vitest/expect': 2.1.9
+ '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.1))
+ '@vitest/pretty-format': 2.1.9
+ '@vitest/runner': 2.1.9
+ '@vitest/snapshot': 2.1.9
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ debug: 4.4.3
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ pathe: 1.1.2
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinypool: 1.1.1
+ tinyrainbow: 1.2.0
+ vite: 5.4.21(@types/node@22.20.1)
+ vite-node: 2.1.9(@types/node@22.20.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.20.1
+ happy-dom: 15.11.7
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
+ webidl-conversions@7.0.0: {}
+
+ whatwg-mimetype@3.0.0: {}
+
+ which-boxed-primitive@1.1.1:
+ dependencies:
+ is-bigint: 1.1.0
+ is-boolean-object: 1.2.2
+ is-number-object: 1.1.1
+ is-string: 1.1.1
+ is-symbol: 1.1.1
+
+ which-builtin-type@1.2.1:
+ dependencies:
+ call-bound: 1.0.4
+ function.prototype.name: 1.2.0
+ has-tostringtag: 1.0.2
+ is-async-function: 2.1.1
+ is-date-object: 1.1.0
+ is-finalizationregistry: 1.1.1
+ is-generator-function: 1.1.2
+ is-regex: 1.2.1
+ is-weakref: 1.1.1
+ isarray: 2.0.5
+ which-boxed-primitive: 1.1.1
+ which-collection: 1.0.2
+ which-typed-array: 1.1.22
+
+ which-collection@1.0.2:
+ dependencies:
+ is-map: 2.0.3
+ is-set: 2.0.3
+ is-weakmap: 2.0.2
+ is-weakset: 2.0.4
+
+ which-typed-array@1.1.22:
+ dependencies:
+ available-typed-arrays: 1.0.7
+ call-bind: 1.0.9
+ call-bound: 1.0.4
+ for-each: 0.3.5
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-tostringtag: 1.0.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
+ word-wrap@1.2.5: {}
+
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
+ wrap-ansi@8.1.0:
+ dependencies:
+ ansi-styles: 6.2.3
+ string-width: 5.1.2
+ strip-ansi: 7.2.0
+
+ wrappy@1.0.2: {}
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..1d8028e27a284a2ae5e89f167f16f8996ce1ff0e
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+packages:
+ - "apps/*"
+ - "packages/*"
+ - "services/*"
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000000000000000000000000000000000000..cabd0131303c3b1dd11c4a48cf56d78953aadfaf
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,8 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+filterwarnings =
+ ignore::DeprecationWarning
+addopts = -v --tb=short
diff --git a/scripts/sync_to_hf_space.py b/scripts/sync_to_hf_space.py
new file mode 100644
index 0000000000000000000000000000000000000000..df2bacf582c9da8a08fd0f84a348f6151c33623e
--- /dev/null
+++ b/scripts/sync_to_hf_space.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""Sync local repository to HuggingFace Space (mirrors color-ux-access)."""
+
+import os
+import sys
+
+try:
+ from huggingface_hub import HfApi
+except ImportError:
+ print("Error: huggingface_hub not installed. Run: pip install huggingface_hub")
+ sys.exit(1)
+
+REPO_ID = "salgadev/jam-buddy"
+REPO_TYPE = "space"
+
+
+def main() -> None:
+ token = os.environ.get("HF_SPACES_TOKEN") or os.environ.get("HF_TOKEN")
+ if not token:
+ print("Error: HF_SPACES_TOKEN or HF_TOKEN environment variable not set")
+ sys.exit(1)
+
+ api = HfApi(token=token)
+
+ ignore_patterns = [
+ ".git/**",
+ ".github/**",
+ ".venv/**",
+ "__pycache__/**",
+ "*.pyc",
+ ".DS_Store",
+ ".qodo/**",
+ ".idea/**",
+ ".pytest_cache/**",
+ "uv.lock",
+ "node_modules/**",
+ "stable-audio-3/**",
+ "generations/**",
+ ".env",
+ "*.webp",
+ "*.mid",
+ "*.wav",
+ "*.mp3",
+ ]
+
+ sha = os.environ.get("GITHUB_SHA", "manual")[:8]
+ commit_message = f"Sync from GitHub {sha}"
+
+ api.upload_folder(
+ folder_path=".",
+ repo_id=REPO_ID,
+ repo_type=REPO_TYPE,
+ ignore_patterns=ignore_patterns,
+ commit_message=commit_message,
+ )
+ print(f"✓ Synced {REPO_ID} to Space")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a0e5aaf8e5a76b4b59333763c72cb5ba20be7c90
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,88 @@
+# Test harness for gp5_to_keyswitched_mid.py
+
+This directory holds the test suite that validates the Guitar Pro 5 → keyswitched MIDI conversion script. The goal is to catch regressions before they break a recording session.
+
+## What it tests
+
+For each track in each song, the suite verifies:
+
+1. **Conductor tempo** — must match the source GP file's BPM (was off by 60,000,000÷x in earlier versions).
+2. **Conductor time signature** — must be 4/4 for the test corpus (was 4/16 when we read the wrong field).
+3. **First event at tick 0** — no empty intro bars (the 35-bar silent intro was a real bug).
+4. **Channel preserved** — output channel matches the GP file's channel assignment.
+5. **Keyswitch counts exact** — every PalmDown, SlideIn, SlideUp, SlideDown, Hammer, Bend, Harm, Sustain key counted and matched against the baseline.
+6. **Total keyswitchs** — sanity check on the count.
+7. **Not empty** — placeholder MIDIs (54 bytes) are rejected.
+8. **Track count** — exactly 2 tracks (conductor + audio).
+9. **MIDI Type 1** — multi-track format.
+10. **960 ticks per beat** — matches PyGuitarPro's resolution.
+
+## How to run
+
+```bash
+cd D:/CODE/unstable-drums
+python -m pytest tests/
+```
+
+A clean run takes ~2 minutes (the script regenerates every track). A focused run with `-k`:
+
+```bash
+python -m pytest tests/ -k "Dany_Rhythm_Guitar"
+```
+
+## How to add a new song
+
+1. Copy the GP5 file into `D:/CODE/unstable-drums/gp5_songs/`.
+2. Re-run the baseline recorder:
+ ```bash
+ python tests/record_expected.py
+ ```
+ This regenerates `tests/expected/baseline.yaml` with the current script's output.
+3. Inspect the YAML to confirm the keyswitch counts and structural facts look right.
+4. Commit the updated baseline.yaml.
+
+## How to add a new test scenario
+
+If you find a new edge case (e.g., a new articulation the script doesn't handle yet), add a test function to `tests/test_gp5_conversion.py` and re-run the suite. The test should run against every track in the baseline. Example:
+
+```python
+def test_my_new_scenario(track_setup):
+ """Every track should have ... whatever."""
+ actual = ...
+ expected = ...
+ assert actual == expected, f"{track_setup['song_name']}/{...}"
+```
+
+The new test runs against every (song, track) pair automatically — no need to parametrize manually.
+
+## How to update the baseline
+
+The baseline is the *contract*. When you fix the GP file (e.g., add 5 more grace notes), the baseline's keyswitch counts become stale. Re-run `record_expected.py` to update them, then commit the new YAML.
+
+The expected YAML is **not** a measurement of "what the script does" — it's a measurement of "what the script should do, given the user's intent for the GP file." If you change the GP file on purpose, you update the baseline on purpose.
+
+## How the script handles things
+
+The script writes outputs to subfolders named after the song:
+- `D:/CODE/unstable-drums/The Summoning Aug 2026/*.mid`
+- `D:/CODE/unstable-drums/Sacrifice ReDrum Full v2 gracenotes/*.mid`
+
+These directories are gitignored. The test suite regenerates them on every run.
+
+## How regressions are caught
+
+The tests re-run the script for every track before testing. If a code change breaks the keyswitch counts, the test fails with a clear message like:
+
+```
+Sacrifice ReDrum Full v2 gracenotes/Dany Rhythm Guitar: keyswitch counts mismatch
+ Missing (expected but not emitted): {'PalmDown': 2}
+ Extra (emitted but not expected): {'C1(SlideUp)': 2}
+```
+
+That tells you exactly which song, which track, and which keyswitch is wrong.
+
+## When the test fails for a "good" reason
+
+If the user changes the GP file deliberately, the baseline is stale. Run `python tests/record_expected.py` to refresh the baseline, review the diff, and commit.
+
+If the test fails because the script has a bug, fix the script and re-run the tests. The baseline shouldn't change for a bug fix.
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..10814dde198a33ea0a78a9a0e73daeb6ef75b6c2
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,76 @@
+"""
+pytest fixtures for the gp5_to_keyswitched_mid test suite.
+
+The test corpus is two Guitar Pro 5 files in D:/CODE/unstable-drums/gp5_songs/. The expected
+output (keyswitch counts, channel assignments, structural facts) is
+encoded in tests/expected/baseline.yaml. The GP files themselves are NOT
+in git (user's preference); the baseline is committed so the script can
+be re-run and validated against a fixed contract.
+"""
+import os
+from pathlib import Path
+
+import mido
+import pytest
+import yaml
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+GP5_DIR = REPO_ROOT / "gp5_songs"
+EXPECTED_PATH = Path(__file__).resolve().parent / "expected" / "baseline.yaml"
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+@pytest.fixture(scope="session")
+def baseline():
+ """Load the expected baseline YAML once per test session."""
+ if not EXPECTED_PATH.exists():
+ pytest.skip(
+ f"baseline.yaml not found at {EXPECTED_PATH}. "
+ "Run tests/record_expected.py to generate it."
+ )
+ with open(EXPECTED_PATH) as f:
+ return yaml.safe_load(f)
+
+
+# ---------------------------------------------------------------------------
+# Parametrize IDs (positive and negative lookups)
+# ---------------------------------------------------------------------------
+SCRIPT_PATH = REPO_ROOT / "gp5_to_keyswitched_mid.py"
+
+
+def _script_path():
+ """Return the path to the script under test."""
+ return str(SCRIPT_PATH)
+
+
+def _gp5_path_for_song(song_name):
+ """Return the absolute path to the GP5 file for a given song."""
+ # Match the source_gp5 filename in the baseline.
+ for gp5 in GP5_DIR.glob("*.gp5"):
+ if gp5.stem == song_name:
+ return str(gp5)
+ raise FileNotFoundError(f"No GP5 file for song '{song_name}'")
+
+
+def _exported_midi_path(song_name, track_name):
+ """Return the absolute path to the MIDI produced by the script."""
+ return str(REPO_ROOT / song_name / f"{track_name}_keyswitched.mid")
+
+
+# ---------------------------------------------------------------------------
+# Parametrize over every (song, track) pair in the baseline
+# ---------------------------------------------------------------------------
+def _all_track_pairs(baseline):
+ pairs = []
+ for song in baseline["songs"]:
+ for track in song["tracks"]:
+ pairs.append((song["source_gp5"], track["name"], track))
+ return pairs
+
+
+def _track_param(baseline):
+ """Yields (gp5, track_name, expected_track_dict) for every track."""
+ return _all_track_pairs(baseline)
diff --git a/tests/expected/baseline.yaml b/tests/expected/baseline.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..915857ca67459348e0c4df7501ba5b35bce552b1
--- /dev/null
+++ b/tests/expected/baseline.yaml
@@ -0,0 +1,511 @@
+songs:
+- source_gp5: Altars rev Yes Breakdown.gp5
+ songs_subdir: Altars rev Yes Breakdown
+ tracks:
+ - name: Distortion Guitar
+ channel: 0
+ include_bass: false
+ midi_path: Altars rev Yes Breakdown/Distortion Guitar (1)_keyswitched.mid
+ conductor:
+ tempo_bpm: 190
+ numerator: 3
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 558720
+ keyswitch_counts:
+ SusUp: 595
+ PalmDown: 964
+ D1(Hammer): 50
+ '38': 168
+ '40': 19
+ '41': 19
+ '43': 7
+ '45': 100
+ '46': 233
+ '48': 173
+ '49': 20
+ '50': 258
+ '51': 2
+ '52': 176
+ '53': 237
+ '55': 196
+ '57': 216
+ '58': 128
+ '60': 56
+ '61': 2
+ '62': 13
+ '64': 59
+ '65': 42
+ '67': 30
+ '69': 30
+ - name: Distortion Guitar
+ channel: 2
+ include_bass: false
+ midi_path: Altars rev Yes Breakdown/Distortion Guitar (2)_keyswitched.mid
+ conductor:
+ tempo_bpm: 190
+ numerator: 3
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 558720
+ keyswitch_counts:
+ SusUp: 366
+ PalmDown: 651
+ D1(Hammer): 16
+ '38': 96
+ '40': 16
+ '41': 16
+ '43': 4
+ '45': 48
+ '46': 157
+ '48': 132
+ '49': 20
+ '50': 187
+ '51': 2
+ '52': 136
+ '53': 165
+ '55': 143
+ '57': 138
+ '58': 70
+ '60': 30
+ '62': 7
+ '64': 18
+ '65': 9
+ '67': 8
+ '69': 8
+ - name: Clean Guitar
+ channel: 4
+ include_bass: false
+ midi_path: Altars rev Yes Breakdown/Clean Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 190
+ numerator: 3
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 63360
+ keyswitch_counts:
+ SusUp: 35
+ '38': 4
+ '40': 2
+ '41': 2
+ '43': 2
+ '45': 12
+ '46': 4
+ '48': 2
+ '50': 14
+ '52': 4
+ '53': 6
+ '57': 16
+ '58': 8
+ '60': 8
+ '62': 4
+ '64': 16
+ '65': 8
+ '67': 4
+ '69': 4
+- source_gp5: Sacrifice ReDrum Full v2 gracenotes.gp5
+ songs_subdir: Sacrifice ReDrum Full v2 gracenotes
+ tracks:
+ - name: Clean Guitar
+ channel: 0
+ include_bass: false
+ midi_path: Sacrifice ReDrum Full v2 gracenotes/Clean Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 140
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 123840
+ keyswitch_counts:
+ SusUp: 225
+ '38': 8
+ '45': 6
+ '46': 10
+ '48': 4
+ '49': 2
+ '50': 22
+ '52': 16
+ '53': 18
+ '55': 8
+ '57': 42
+ '58': 24
+ '60': 8
+ '61': 6
+ '62': 52
+ '64': 8
+ '65': 8
+ - name: Dany Rhythm Guitar
+ channel: 2
+ include_bass: false
+ midi_path: Sacrifice ReDrum Full v2 gracenotes/Dany Rhythm Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 140
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 738240
+ keyswitch_counts:
+ Harm: 2
+ SusUp: 444
+ PalmDown: 1094
+ B0(SlideDown): 30
+ C1(SlideUp): 16
+ D1(Hammer): 150
+ D#1(SlideIn): 49
+ '38': 675
+ '39': 3
+ '40': 166
+ '41': 152
+ '43': 46
+ '44': 17
+ '45': 349
+ '46': 40
+ '47': 27
+ '48': 131
+ '49': 2
+ '50': 120
+ '51': 29
+ '52': 84
+ '53': 120
+ '54': 8
+ '55': 113
+ '56': 19
+ '57': 62
+ '58': 19
+ '59': 2
+ '60': 6
+ '62': 18
+ '63': 2
+ '64': 2
+ '65': 4
+ '66': 1
+ '67': 1
+ '69': 1
+ '70': 1
+ '72': 1
+ '74': 1
+ - name: Roberto Rhythm Guitar
+ channel: 4
+ include_bass: false
+ midi_path: Sacrifice ReDrum Full v2 gracenotes/Roberto Rhythm Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 140
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 738240
+ keyswitch_counts:
+ Harm: 2
+ SusUp: 444
+ PalmDown: 1094
+ B0(SlideDown): 30
+ C1(SlideUp): 16
+ D1(Hammer): 150
+ D#1(SlideIn): 49
+ '38': 675
+ '39': 4
+ '40': 184
+ '41': 171
+ '43': 38
+ '44': 17
+ '45': 341
+ '46': 49
+ '47': 28
+ '48': 149
+ '50': 133
+ '51': 30
+ '52': 109
+ '53': 128
+ '54': 8
+ '55': 71
+ '56': 21
+ '57': 25
+ '58': 11
+ '60': 4
+ '62': 19
+ '63': 3
+ '65': 1
+ '67': 1
+ '69': 1
+ '70': 1
+ - name: Dany Lead Guitar
+ channel: 6
+ include_bass: false
+ midi_path: Sacrifice ReDrum Full v2 gracenotes/Dany Lead Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 140
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 62400
+ keyswitch_counts:
+ SusUp: 73
+ B0(SlideDown): 6
+ C1(SlideUp): 3
+ D1(Hammer): 19
+ D#1(SlideIn): 1
+ '55': 4
+ '57': 4
+ '58': 5
+ '60': 4
+ '62': 8
+ '64': 5
+ '65': 12
+ '67': 9
+ '69': 6
+ '70': 4
+ '72': 5
+ '73': 2
+ '74': 11
+ '76': 9
+ '77': 8
+ '79': 5
+ '81': 1
+ '82': 3
+ '84': 1
+ Bend: 8
+- source_gp5: The Summoning Aug 2026.gp5
+ songs_subdir: The Summoning Aug 2026
+ tracks:
+ - name: Roberto Rhythm Guitar
+ channel: 0
+ include_bass: false
+ midi_path: The Summoning Aug 2026/Roberto Rhythm Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 130
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 496320
+ keyswitch_counts:
+ Harm: 2
+ SusUp: 614
+ PalmDown: 365
+ C1(SlideUp): 3
+ D1(Hammer): 54
+ D#1(SlideIn): 16
+ '38': 300
+ '40': 62
+ '41': 61
+ '43': 16
+ '44': 8
+ '45': 133
+ '46': 131
+ '48': 48
+ '49': 9
+ '50': 160
+ '51': 1
+ '52': 115
+ '53': 99
+ '55': 24
+ '56': 1
+ '57': 88
+ '58': 28
+ '61': 4
+ '62': 37
+ '64': 21
+ '65': 5
+ '69': 3
+ '71': 1
+ '73': 2
+ '74': 1
+ - name: DANY Rhythm Guitar
+ channel: 2
+ include_bass: false
+ midi_path: The Summoning Aug 2026/DANY Rhythm Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 130
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 496320
+ keyswitch_counts:
+ Harm: 2
+ SusUp: 560
+ PalmDown: 368
+ C1(SlideUp): 2
+ D1(Hammer): 39
+ D#1(SlideIn): 16
+ '38': 284
+ '40': 48
+ '41': 54
+ '43': 28
+ '44': 8
+ '45': 135
+ '46': 121
+ '48': 51
+ '49': 8
+ '50': 141
+ '51': 1
+ '52': 72
+ '53': 80
+ '55': 61
+ '56': 1
+ '57': 101
+ '58': 18
+ '60': 9
+ '61': 5
+ '62': 35
+ '64': 15
+ '65': 9
+ '67': 3
+ '69': 7
+ '70': 2
+ '73': 3
+ '74': 1
+ '76': 3
+ Bend: 1
+ - name: DANY Lead Guitar
+ channel: 8
+ include_bass: false
+ midi_path: The Summoning Aug 2026/DANY Lead Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 130
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 335040
+ keyswitch_counts:
+ Harm: 3
+ SusUp: 153
+ PalmDown: 7
+ B0(SlideDown): 3
+ C1(SlideUp): 6
+ D1(Hammer): 110
+ D#1(SlideIn): 3
+ '38': 1
+ '50': 3
+ '53': 8
+ '55': 9
+ '56': 1
+ '57': 14
+ '58': 4
+ '59': 1
+ '60': 10
+ '62': 19
+ '64': 11
+ '65': 20
+ '67': 11
+ '68': 5
+ '69': 23
+ '70': 12
+ '71': 5
+ '72': 21
+ '74': 29
+ '75': 1
+ '76': 18
+ '77': 25
+ '79': 10
+ '80': 5
+ '81': 13
+ '82': 7
+ '83': 2
+ '84': 2
+ '86': 4
+ Bend: 12
+ - name: Roberto Lead Guitar
+ channel: 15
+ include_bass: false
+ midi_path: The Summoning Aug 2026/Roberto Lead Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 130
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 139200
+ keyswitch_counts:
+ SusUp: 77
+ PalmDown: 6
+ B0(SlideDown): 1
+ C1(SlideUp): 2
+ D1(Hammer): 42
+ '38': 16
+ '40': 6
+ '41': 14
+ '45': 10
+ '46': 6
+ '50': 14
+ '52': 14
+ '53': 22
+ '55': 2
+ '57': 12
+ '62': 6
+ '64': 2
+ '65': 1
+ '67': 2
+ '69': 4
+ '70': 3
+ '72': 3
+ '74': 4
+ '76': 4
+ Bend: 4
+ - name: William Harmony
+ channel: 11
+ include_bass: false
+ midi_path: The Summoning Aug 2026/William Harmony_keyswitched.mid
+ conductor:
+ tempo_bpm: 130
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 228480
+ keyswitch_counts:
+ SusUp: 14
+ B0(SlideDown): 1
+ C1(SlideUp): 2
+ D1(Hammer): 3
+ '65': 1
+ '67': 2
+ '69': 4
+ '70': 3
+ '72': 3
+ '74': 4
+ '76': 4
+ '77': 1
+ '79': 2
+ '81': 1
+ Bend: 4
+ - name: Roberto Clean Guitar
+ channel: 4
+ include_bass: false
+ midi_path: The Summoning Aug 2026/Roberto Clean Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 130
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 31520
+ keyswitch_counts:
+ SusUp: 65
+ PalmDown: 6
+ D1(Hammer): 40
+ '38': 16
+ '40': 6
+ '41': 14
+ '45': 10
+ '46': 6
+ '50': 14
+ '52': 14
+ '53': 22
+ '55': 2
+ '57': 12
+ '62': 6
+ '64': 2
+ - name: William Clean Guitar
+ channel: 6
+ include_bass: false
+ midi_path: The Summoning Aug 2026/William Clean Guitar_keyswitched.mid
+ conductor:
+ tempo_bpm: 130
+ numerator: 4
+ denominator: 4
+ first_event_tick: 0
+ last_event_tick: 4640
+ keyswitch_counts:
+ SusUp: 5
+ PalmDown: 2
+ D1(Hammer): 10
+ '53': 4
+ '55': 5
+ '57': 8
+ '58': 1
diff --git a/tests/record_expected.py b/tests/record_expected.py
new file mode 100644
index 0000000000000000000000000000000000000000..8978da1bbf4ec5f780b383281a6e3c9656ea667e
--- /dev/null
+++ b/tests/record_expected.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""
+Record the current script's output as a baseline YAML.
+
+Run this once to create the expected/ files. Re-run only when the user's
+contract changes (i.e., when they edit the .gp5 files and want the new
+expected counts). NOT part of the test suite itself.
+"""
+import os
+import sys
+import collections
+import subprocess
+
+import mido
+import yaml
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+GP5_DIR = os.path.join(REPO_ROOT, "gp5_songs")
+EXPECTED_DIR = os.path.join(os.path.dirname(__file__), "expected")
+SCRIPT = os.path.join(REPO_ROOT, "gp5_to_keyswitched_mid.py")
+
+NOTE_NAMES = {
+ 17: "SusDown", 18: "SusUp", 20: "PalmDown", 23: "B0(SlideDown)",
+ 24: "C1(SlideUp)", 26: "D1(Hammer)", 27: "D#1(SlideIn)", 91: "Bend",
+ 9: "Harm",
+}
+
+
+def keyswitch_counts(midi_path):
+ counts = collections.Counter()
+ mids = mido.MidiFile(midi_path)
+ if len(mids.tracks) < 2:
+ return {}
+ for msg in mids.tracks[1]:
+ if hasattr(msg, "channel") and msg.type == "note_on" and msg.velocity > 0:
+ counts[msg.note] += 1
+ return {NOTE_NAMES.get(n, str(n)): c for n, c in sorted(counts.items())}
+
+
+def conductor_facts(midi_path):
+ mids = mido.MidiFile(midi_path)
+ if not mids.tracks:
+ return {}
+ facts = {}
+ for msg in mids.tracks[0]:
+ if msg.type == "set_tempo":
+ facts["tempo_bpm"] = round(60_000_000 / msg.tempo)
+ elif msg.type == "time_signature":
+ facts["numerator"] = msg.numerator
+ facts["denominator"] = msg.denominator
+ return facts
+
+
+def first_event_tick(midi_path):
+ mid = mido.MidiFile(midi_path)
+ if len(mid.tracks) < 2:
+ return None
+ abs_t = 0
+ for msg in mid.tracks[1]:
+ abs_t += msg.time
+ if hasattr(msg, "channel") and msg.type == "note_on" and msg.velocity > 0:
+ return abs_t
+ return None
+
+
+def last_event_tick(midi_path):
+ mid = mido.MidiFile(midi_path)
+ if len(mid.tracks) < 2:
+ return None
+ abs_t = 0
+ last = 0
+ for msg in mid.tracks[1]:
+ abs_t += msg.time
+ if hasattr(msg, "channel"):
+ last = abs_t
+ return last
+
+
+def track_is_bass(track_name):
+ return "bass" in track_name.lower()
+
+
+def song_block(gp5_filename):
+ """Run the script for each track in the song and return the expected data."""
+ gp5_path = os.path.join(GP5_DIR, gp5_filename)
+ song_name = os.path.splitext(gp5_filename)[0]
+ songs_subdir = os.path.join(REPO_ROOT, song_name)
+
+ # First pass: run the script for each track to populate the output dir.
+ # We always pass --only "