|
| 1 | +const CHORD_PATTERN = /^[A-G](?:#|b)?(?:m|min|maj|dim|aug|sus2|sus4|7|maj7|m7|min7|dim7|add9)?$/; |
| 2 | +const DRUM_TOKENS = new Set(["kick", "snare", "hat", "ride", "clap", "tom", "crash", "perc"]); |
| 3 | +const LANE_NAMES = ["chords", "bass", "pad", "lead", "drums"]; |
| 4 | +const NOTE_PATTERN = /^[A-G](?:#|b)?[0-8]$/; |
| 5 | +const REST_TOKENS = new Set(["", "-", ".", "rest"]); |
| 6 | +const SUPPORTED_SUBDIVISIONS = new Set([1, 2, 4]); |
| 7 | + |
| 8 | +export class InstrumentGridParser { |
| 9 | + parse(input = {}) { |
| 10 | + const sections = this.parseSections(input.sections); |
| 11 | + if (!sections.ok) { |
| 12 | + return sections; |
| 13 | + } |
| 14 | + const beatsPerBar = this.parsePositiveInteger(input.beatsPerBar, "beats per bar"); |
| 15 | + if (!beatsPerBar.ok) { |
| 16 | + return beatsPerBar; |
| 17 | + } |
| 18 | + const subdivision = this.parsePositiveInteger(input.subdivision, "timing subdivision"); |
| 19 | + if (!subdivision.ok) { |
| 20 | + return subdivision; |
| 21 | + } |
| 22 | + if (!SUPPORTED_SUBDIVISIONS.has(subdivision.value)) { |
| 23 | + return { |
| 24 | + message: `Unsupported timing subdivision: ${subdivision.value}. Use 1, 2, or 4.`, |
| 25 | + ok: false |
| 26 | + }; |
| 27 | + } |
| 28 | + const barCount = sections.value.reduce((total, section) => total + section.bars, 0); |
| 29 | + const stepsPerBar = beatsPerBar.value * subdivision.value; |
| 30 | + const timeline = []; |
| 31 | + const warnings = []; |
| 32 | + const laneCells = {}; |
| 33 | + for (const lane of LANE_NAMES) { |
| 34 | + const parsedLane = this.parseLane({ |
| 35 | + barCount, |
| 36 | + beatsPerBar: beatsPerBar.value, |
| 37 | + lane, |
| 38 | + sections: sections.value, |
| 39 | + source: input.lanes?.[lane], |
| 40 | + stepsPerBar, |
| 41 | + subdivision: subdivision.value |
| 42 | + }); |
| 43 | + if (!parsedLane.ok) { |
| 44 | + return parsedLane; |
| 45 | + } |
| 46 | + laneCells[lane] = parsedLane.cells; |
| 47 | + timeline.push(...parsedLane.events); |
| 48 | + warnings.push(...parsedLane.warnings); |
| 49 | + } |
| 50 | + const normalizedSections = sections.value.map((section) => ({ |
| 51 | + ...section, |
| 52 | + beatsPerBar: beatsPerBar.value, |
| 53 | + subdivision: subdivision.value, |
| 54 | + steps: section.bars * stepsPerBar |
| 55 | + })); |
| 56 | + return { |
| 57 | + barCount, |
| 58 | + beatCount: barCount * beatsPerBar.value, |
| 59 | + beatsPerBar: beatsPerBar.value, |
| 60 | + cells: laneCells, |
| 61 | + chordCount: timeline.filter((event) => event.kind === "chord").length, |
| 62 | + drumCount: timeline.filter((event) => event.kind === "drum").length, |
| 63 | + eventCount: timeline.length, |
| 64 | + lanes: LANE_NAMES, |
| 65 | + noteCount: timeline.filter((event) => event.kind === "note").length, |
| 66 | + ok: true, |
| 67 | + sectionSummary: normalizedSections.map((section) => `${section.label}: ${section.bars} bar${section.bars === 1 ? "" : "s"}`).join("; "), |
| 68 | + sections: normalizedSections, |
| 69 | + subdivision: subdivision.value, |
| 70 | + timeline, |
| 71 | + totalSteps: barCount * stepsPerBar, |
| 72 | + warningSummary: warnings.length ? warnings.join("; ") : "none", |
| 73 | + warnings |
| 74 | + }; |
| 75 | + } |
| 76 | + |
| 77 | + parseSections(source) { |
| 78 | + const text = String(source || "").trim(); |
| 79 | + if (!text) { |
| 80 | + return { message: "Malformed bar counts. Enter sections as label:bars, for example intro:1, loop:2.", ok: false }; |
| 81 | + } |
| 82 | + const sections = []; |
| 83 | + let startBar = 1; |
| 84 | + for (const part of text.split(",")) { |
| 85 | + const match = part.trim().match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(\d+)$/); |
| 86 | + if (!match) { |
| 87 | + return { message: `Malformed bar counts in section entry: ${part.trim() || "(empty)"}. Use label:bars.`, ok: false }; |
| 88 | + } |
| 89 | + const bars = Number(match[2]); |
| 90 | + if (!Number.isInteger(bars) || bars <= 0) { |
| 91 | + return { message: `Malformed bar counts for section ${match[1]}. Bars must be a positive integer.`, ok: false }; |
| 92 | + } |
| 93 | + sections.push({ |
| 94 | + bars, |
| 95 | + label: match[1], |
| 96 | + startBar |
| 97 | + }); |
| 98 | + startBar += bars; |
| 99 | + } |
| 100 | + return { ok: true, value: sections }; |
| 101 | + } |
| 102 | + |
| 103 | + parsePositiveInteger(value, label) { |
| 104 | + const number = Number(value); |
| 105 | + if (!Number.isInteger(number) || number <= 0) { |
| 106 | + return { message: `Invalid ${label}. Enter a positive whole number.`, ok: false }; |
| 107 | + } |
| 108 | + return { ok: true, value: number }; |
| 109 | + } |
| 110 | + |
| 111 | + parseLane({ barCount, beatsPerBar, lane, sections, source, stepsPerBar, subdivision }) { |
| 112 | + const text = String(source || "").trim(); |
| 113 | + const bars = text ? text.split("|").map((bar) => bar.trim()) : []; |
| 114 | + if (bars.length && bars.length !== barCount) { |
| 115 | + return { |
| 116 | + message: `Malformed bar counts for ${lane}. Expected ${barCount} bar${barCount === 1 ? "" : "s"} from the section map, received ${bars.length}.`, |
| 117 | + ok: false |
| 118 | + }; |
| 119 | + } |
| 120 | + const cells = []; |
| 121 | + const events = []; |
| 122 | + const warnings = []; |
| 123 | + for (let barIndex = 0; barIndex < barCount; barIndex += 1) { |
| 124 | + const tokens = bars[barIndex] ? bars[barIndex].split(/\s+/).filter(Boolean) : []; |
| 125 | + if (tokens.length && tokens.length !== stepsPerBar) { |
| 126 | + return { |
| 127 | + message: `Malformed bar counts for ${lane} bar ${barIndex + 1}. Expected ${stepsPerBar} beat slot${stepsPerBar === 1 ? "" : "s"}, received ${tokens.length}.`, |
| 128 | + ok: false |
| 129 | + }; |
| 130 | + } |
| 131 | + for (let stepIndex = 0; stepIndex < stepsPerBar; stepIndex += 1) { |
| 132 | + const token = tokens[stepIndex] || ""; |
| 133 | + const normalized = this.normalizeToken({ barIndex, beatsPerBar, lane, sections, stepIndex, subdivision, token }); |
| 134 | + if (!normalized.ok) { |
| 135 | + return normalized; |
| 136 | + } |
| 137 | + warnings.push(...normalized.warnings); |
| 138 | + cells.push(normalized.cell); |
| 139 | + events.push(...normalized.events); |
| 140 | + } |
| 141 | + } |
| 142 | + return { cells, events, ok: true, warnings }; |
| 143 | + } |
| 144 | + |
| 145 | + normalizeToken({ barIndex, beatsPerBar, lane, sections, stepIndex, subdivision, token }) { |
| 146 | + const value = token.trim(); |
| 147 | + const timing = this.timingForCell({ barIndex, beatsPerBar, sections, stepIndex, subdivision }); |
| 148 | + const cell = { |
| 149 | + bar: timing.bar, |
| 150 | + beat: timing.beat, |
| 151 | + lane, |
| 152 | + section: timing.section, |
| 153 | + subdivision: timing.subdivision, |
| 154 | + subdivisionStep: timing.subdivisionStep, |
| 155 | + token: value |
| 156 | + }; |
| 157 | + if (REST_TOKENS.has(value.toLowerCase())) { |
| 158 | + return { cell, events: [], ok: true, warnings: [] }; |
| 159 | + } |
| 160 | + if (lane === "drums") { |
| 161 | + return this.normalizeDrums({ cell, timing, value }); |
| 162 | + } |
| 163 | + if (lane === "chords") { |
| 164 | + if (!CHORD_PATTERN.test(value)) { |
| 165 | + return { message: `Invalid chord token "${value}" in ${lane} at bar ${timing.bar}, beat ${timing.beat}.`, ok: false }; |
| 166 | + } |
| 167 | + return { cell, events: [this.eventForCell({ cell, kind: "chord", value })], ok: true, warnings: [] }; |
| 168 | + } |
| 169 | + if (lane === "pad" && CHORD_PATTERN.test(value)) { |
| 170 | + return { cell, events: [this.eventForCell({ cell, kind: "chord", value })], ok: true, warnings: [] }; |
| 171 | + } |
| 172 | + if (!NOTE_PATTERN.test(value)) { |
| 173 | + return { message: `Invalid note token "${value}" in ${lane} at bar ${timing.bar}, beat ${timing.beat}. Use notes like C4 or rests (-).`, ok: false }; |
| 174 | + } |
| 175 | + return { cell, events: [this.eventForCell({ cell, kind: "note", value })], ok: true, warnings: [] }; |
| 176 | + } |
| 177 | + |
| 178 | + normalizeDrums({ cell, timing, value }) { |
| 179 | + const parts = value.toLowerCase().split("+").map((part) => part.trim()).filter(Boolean); |
| 180 | + const invalid = parts.filter((part) => !DRUM_TOKENS.has(part)); |
| 181 | + if (invalid.length) { |
| 182 | + return { |
| 183 | + cell, |
| 184 | + events: [], |
| 185 | + ok: true, |
| 186 | + warnings: [`Invalid drum token "${invalid.join("+")}" at bar ${timing.bar}, beat ${timing.beat}; token was skipped.`] |
| 187 | + }; |
| 188 | + } |
| 189 | + return { |
| 190 | + cell, |
| 191 | + events: parts.map((part) => this.eventForCell({ cell, kind: "drum", value: part })), |
| 192 | + ok: true, |
| 193 | + warnings: [] |
| 194 | + }; |
| 195 | + } |
| 196 | + |
| 197 | + eventForCell({ cell, kind, value }) { |
| 198 | + return { |
| 199 | + bar: cell.bar, |
| 200 | + beat: cell.beat, |
| 201 | + durationBeats: 1 / cell.subdivision, |
| 202 | + kind, |
| 203 | + lane: cell.lane, |
| 204 | + section: cell.section, |
| 205 | + subdivision: cell.subdivision, |
| 206 | + value |
| 207 | + }; |
| 208 | + } |
| 209 | + |
| 210 | + timingForCell({ barIndex, beatsPerBar, sections, stepIndex, subdivision }) { |
| 211 | + const bar = barIndex + 1; |
| 212 | + const section = sections.find((entry) => bar >= entry.startBar && bar < entry.startBar + entry.bars)?.label || "unknown"; |
| 213 | + return { |
| 214 | + bar, |
| 215 | + beat: Math.floor(stepIndex / subdivision) + 1, |
| 216 | + section, |
| 217 | + subdivision, |
| 218 | + subdivisionStep: (stepIndex % subdivision) + 1 |
| 219 | + }; |
| 220 | + } |
| 221 | +} |
0 commit comments