|
| 1 | +import { SNTimeUnit } from '@core/model/ticks'; |
| 2 | +import { SNAccidental } from '@core/model/music'; |
| 3 | +import { SNParserElement } from '@data/model'; |
| 4 | +import { |
| 5 | + SNParserNote, |
| 6 | + SNParserRest, |
| 7 | + SNParserTie, |
| 8 | + SNParserTuplet, |
| 9 | + SNParserNode, |
| 10 | +} from '@data/node'; |
| 11 | +import { noteValueToDuration } from '@core/utils/time-unit'; |
| 12 | +import { AbcTokenizer } from '../utils'; |
| 13 | +import { ElementParseError } from '../errors'; |
| 14 | + |
| 15 | +/** |
| 16 | + * ABC 元素解析器 |
| 17 | + * |
| 18 | + * 职责:解析小节内的音乐元素 |
| 19 | + * - 音符(Note) |
| 20 | + * - 休止符(Rest) |
| 21 | + * - 连音(Tuplet) |
| 22 | + * - 连音线(Tie) |
| 23 | + */ |
| 24 | +export class AbcElementParser { |
| 25 | + /** |
| 26 | + * 获取下一个ID的回调函数 |
| 27 | + */ |
| 28 | + private getNextId: (prefix: string) => string; |
| 29 | + |
| 30 | + constructor(getNextId: (prefix: string) => string) { |
| 31 | + this.getNextId = getNextId; |
| 32 | + } |
| 33 | + |
| 34 | + /** |
| 35 | + * 解析单个元素 |
| 36 | + * |
| 37 | + * @param elementData - 元素数据字符串 |
| 38 | + * @param timeUnit - 时间单位(可选) |
| 39 | + * @param defaultNoteLength - 默认音符长度(可选) |
| 40 | + * @returns 解析后的元素节点 |
| 41 | + * @throws {ElementParseError} 当无法解析元素时抛出 |
| 42 | + * |
| 43 | + * @example |
| 44 | + * ```typescript |
| 45 | + * // 解析音符 |
| 46 | + * parser.parseElement('C'); // SNParserNote |
| 47 | + * parser.parseElement('C#'); // SNParserNote (升C) |
| 48 | + * parser.parseElement('C4'); // SNParserNote (四分音符C) |
| 49 | + * |
| 50 | + * // 解析休止符 |
| 51 | + * parser.parseElement('z'); // SNParserRest |
| 52 | + * parser.parseElement('z4'); // SNParserRest (四分休止符) |
| 53 | + * |
| 54 | + * // 解析连音线 |
| 55 | + * parser.parseElement('-'); // SNParserTie |
| 56 | + * |
| 57 | + * // 解析连音 |
| 58 | + * parser.parseElement('(3ABC'); // SNParserTuplet (三连音) |
| 59 | + * ``` |
| 60 | + */ |
| 61 | + parseElement( |
| 62 | + elementData: string, |
| 63 | + timeUnit?: SNTimeUnit, |
| 64 | + defaultNoteLength?: number, |
| 65 | + ): SNParserElement { |
| 66 | + const trimmed = elementData.trim(); |
| 67 | + |
| 68 | + if (!trimmed) { |
| 69 | + throw new ElementParseError(elementData); |
| 70 | + } |
| 71 | + |
| 72 | + // 1. 解析连音线 |
| 73 | + if (trimmed === '-') { |
| 74 | + return new SNParserTie({ |
| 75 | + id: this.getNextId('tie'), |
| 76 | + style: 'slur', |
| 77 | + originStr: elementData, |
| 78 | + }); |
| 79 | + } |
| 80 | + |
| 81 | + // 2. 解析连音(Tuplet) |
| 82 | + const tupletMatch = trimmed.match(/^\((\d+)([\s\S]*?)\)?$/); |
| 83 | + if (tupletMatch) { |
| 84 | + return this.parseTuplet( |
| 85 | + tupletMatch, |
| 86 | + elementData, |
| 87 | + timeUnit, |
| 88 | + defaultNoteLength, |
| 89 | + ); |
| 90 | + } |
| 91 | + |
| 92 | + // 3. 解析休止符 |
| 93 | + if (trimmed.startsWith('z')) { |
| 94 | + return this.parseRest(trimmed, elementData, timeUnit, defaultNoteLength); |
| 95 | + } |
| 96 | + |
| 97 | + // 4. 解析音符 |
| 98 | + const noteMatch = trimmed.match( |
| 99 | + /^(\^+\/?|_+\/?|=?)([A-Ga-g])([,']*)(\d*)(\.*)$/, |
| 100 | + ); |
| 101 | + if (noteMatch) { |
| 102 | + return this.parseNote(noteMatch, trimmed, timeUnit, defaultNoteLength); |
| 103 | + } |
| 104 | + |
| 105 | + throw new ElementParseError(elementData); |
| 106 | + } |
| 107 | + |
| 108 | + /** |
| 109 | + * 解析连音(Tuplet) |
| 110 | + */ |
| 111 | + private parseTuplet( |
| 112 | + match: RegExpMatchArray, |
| 113 | + elementData: string, |
| 114 | + timeUnit?: SNTimeUnit, |
| 115 | + defaultNoteLength?: number, |
| 116 | + ): SNParserTuplet { |
| 117 | + const [, , innerNotesStr] = match; |
| 118 | + const innerNotes = AbcTokenizer.tokenize(innerNotesStr); |
| 119 | + |
| 120 | + return new SNParserTuplet({ |
| 121 | + id: this.getNextId('tuplet'), |
| 122 | + originStr: elementData, |
| 123 | + }).addChildren( |
| 124 | + innerNotes.map( |
| 125 | + (noteStr): SNParserNote => |
| 126 | + this.parseElement( |
| 127 | + noteStr, |
| 128 | + timeUnit, |
| 129 | + defaultNoteLength, |
| 130 | + ) as SNParserNote, |
| 131 | + ), |
| 132 | + ); |
| 133 | + } |
| 134 | + |
| 135 | + /** |
| 136 | + * 解析休止符 |
| 137 | + */ |
| 138 | + private parseRest( |
| 139 | + trimmed: string, |
| 140 | + elementData: string, |
| 141 | + timeUnit?: SNTimeUnit, |
| 142 | + defaultNoteLength?: number, |
| 143 | + ): SNParserRest { |
| 144 | + let duration: number; |
| 145 | + |
| 146 | + if (timeUnit) { |
| 147 | + const restStr = trimmed.slice(1); |
| 148 | + const durationStr = restStr.match(/^(\d+)/)?.[1]; |
| 149 | + const dotCount = (restStr.match(/\./g) || []).length; |
| 150 | + |
| 151 | + const noteValue = durationStr |
| 152 | + ? 1 / parseInt(durationStr, 10) |
| 153 | + : defaultNoteLength || 1 / 4; |
| 154 | + |
| 155 | + const dottedNoteValue = this.calculateDottedNoteValue( |
| 156 | + noteValue, |
| 157 | + dotCount, |
| 158 | + ); |
| 159 | + duration = noteValueToDuration(dottedNoteValue, timeUnit); |
| 160 | + } else { |
| 161 | + duration = parseInt(trimmed.slice(1), 10) || 1; |
| 162 | + } |
| 163 | + |
| 164 | + return new SNParserRest({ |
| 165 | + id: this.getNextId('rest'), |
| 166 | + duration, |
| 167 | + originStr: elementData, |
| 168 | + }); |
| 169 | + } |
| 170 | + |
| 171 | + /** |
| 172 | + * 解析音符 |
| 173 | + */ |
| 174 | + private parseNote( |
| 175 | + match: RegExpMatchArray, |
| 176 | + trimmed: string, |
| 177 | + timeUnit?: SNTimeUnit, |
| 178 | + defaultNoteLength?: number, |
| 179 | + ): SNParserNote { |
| 180 | + const [, accidentalStr, letter, octaveSymbols, durationStr] = match; |
| 181 | + |
| 182 | + // 解析变音记号 |
| 183 | + const accidental = this.parseAccidental(accidentalStr); |
| 184 | + |
| 185 | + // 解析八度 |
| 186 | + const baseOctave: number = letter === letter.toUpperCase() ? 3 : 4; |
| 187 | + const octaveOffset = octaveSymbols.split('').reduce((offset, sym) => { |
| 188 | + return sym === ',' ? offset - 1 : sym === "'" ? offset + 1 : offset; |
| 189 | + }, 0); |
| 190 | + const octave = baseOctave + octaveOffset; |
| 191 | + |
| 192 | + // 解析时值 |
| 193 | + let duration: number; |
| 194 | + if (timeUnit) { |
| 195 | + const noteValue = durationStr |
| 196 | + ? 1 / parseInt(durationStr, 10) |
| 197 | + : defaultNoteLength || 1 / 4; |
| 198 | + |
| 199 | + const dotCount = (trimmed.match(/\./g) || []).length; |
| 200 | + const dottedNoteValue = this.calculateDottedNoteValue( |
| 201 | + noteValue, |
| 202 | + dotCount, |
| 203 | + ); |
| 204 | + duration = noteValueToDuration(dottedNoteValue, timeUnit); |
| 205 | + } else { |
| 206 | + duration = durationStr ? parseInt(durationStr, 10) : 1; |
| 207 | + } |
| 208 | + |
| 209 | + return new SNParserNote({ |
| 210 | + id: this.getNextId('note'), |
| 211 | + originStr: trimmed, |
| 212 | + pitch: { |
| 213 | + letter: letter.toUpperCase(), |
| 214 | + octave, |
| 215 | + accidental, |
| 216 | + }, |
| 217 | + duration, |
| 218 | + }); |
| 219 | + } |
| 220 | + |
| 221 | + /** |
| 222 | + * 解析变音记号 |
| 223 | + * |
| 224 | + * @param accidentalStr - 变音记号字符串 |
| 225 | + * @returns 变音记号枚举值 |
| 226 | + */ |
| 227 | + private parseAccidental(accidentalStr: string): SNAccidental { |
| 228 | + if (!accidentalStr) return SNAccidental.NATURAL; |
| 229 | + |
| 230 | + switch (accidentalStr) { |
| 231 | + case '^': |
| 232 | + return SNAccidental.SHARP; |
| 233 | + case '^^': |
| 234 | + return SNAccidental.DOUBLE_SHARP; |
| 235 | + case '_': |
| 236 | + return SNAccidental.FLAT; |
| 237 | + case '__': |
| 238 | + return SNAccidental.DOUBLE_FLAT; |
| 239 | + case '=': |
| 240 | + return SNAccidental.NATURAL; |
| 241 | + default: |
| 242 | + return SNAccidental.NATURAL; |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + /** |
| 247 | + * 计算带附点的音符时值 |
| 248 | + * |
| 249 | + * @param noteValue - 基础音符时值 |
| 250 | + * @param dotCount - 附点数量 |
| 251 | + * @returns 带附点的音符时值 |
| 252 | + */ |
| 253 | + private calculateDottedNoteValue( |
| 254 | + noteValue: number, |
| 255 | + dotCount: number, |
| 256 | + ): number { |
| 257 | + if (dotCount === 0) return noteValue; |
| 258 | + return noteValue * (1 + 0.5 * (1 - Math.pow(0.5, dotCount))); |
| 259 | + } |
| 260 | + |
| 261 | + /** |
| 262 | + * 获取默认音符长度 |
| 263 | + * |
| 264 | + * 从父节点的 meta 中查找 noteLength 字段 |
| 265 | + * |
| 266 | + * @param node - 当前节点(可选) |
| 267 | + * @returns 默认音符长度 |
| 268 | + */ |
| 269 | + static getDefaultNoteLength(node?: SNParserNode): number { |
| 270 | + if (node) { |
| 271 | + let current: SNParserNode | undefined = node.parent; |
| 272 | + while (current) { |
| 273 | + const meta = current.meta as { noteLength?: string } | undefined; |
| 274 | + if (meta?.noteLength) { |
| 275 | + const match = meta.noteLength.match(/^(\d+)\/(\d+)$/); |
| 276 | + if (match) { |
| 277 | + const [, num, den] = match.map(Number); |
| 278 | + return num / den; |
| 279 | + } |
| 280 | + } |
| 281 | + current = current.parent; |
| 282 | + } |
| 283 | + } |
| 284 | + return 1 / 4; // 默认四分音符 |
| 285 | + } |
| 286 | +} |
0 commit comments