From 2ac1156e2043ba9058fee6c5e0af23006fc2f20c Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 10 Mar 2026 00:23:04 +0100 Subject: [PATCH] optimize chords --- src/app/modules/songs/services/chord.ts | 11 + .../services/text-rendering.service.spec.ts | 253 +++++++++++++++- .../songs/services/text-rendering.service.ts | 272 +++++++++++++++--- .../songs/services/transpose.service.spec.ts | 8 +- .../songs/services/transpose.service.ts | 36 ++- 5 files changed, 514 insertions(+), 66 deletions(-) diff --git a/src/app/modules/songs/services/chord.ts b/src/app/modules/songs/services/chord.ts index 32f651d..3f37988 100644 --- a/src/app/modules/songs/services/chord.ts +++ b/src/app/modules/songs/services/chord.ts @@ -1,7 +1,18 @@ +export interface ChordAddDescriptor { + raw: string; + quality: 'major' | 'minor' | 'diminished' | 'augmented' | null; + extensions: string[]; + additions: string[]; + suspensions: string[]; + alterations: string[]; + modifiers: string[]; +} + export interface Chord { chord: string; length: number; position: number; slashChord: string | null; add: string | null; + addDescriptor?: ChordAddDescriptor | null; } diff --git a/src/app/modules/songs/services/text-rendering.service.spec.ts b/src/app/modules/songs/services/text-rendering.service.spec.ts index 15e1e63..919be05 100644 --- a/src/app/modules/songs/services/text-rendering.service.spec.ts +++ b/src/app/modules/songs/services/text-rendering.service.spec.ts @@ -2,8 +2,21 @@ import {TestBed} from '@angular/core/testing'; import {TextRenderingService} from './text-rendering.service'; import {LineType} from './line-type'; import {SectionType} from './section-type'; +import {TransposeService} from './transpose.service'; +import {ChordAddDescriptor} from './chord'; describe('TextRenderingService', () => { + const descriptor = (raw: string, partial: Partial) => + jasmine.objectContaining({ + raw, + quality: null, + extensions: [], + additions: [], + suspensions: [], + alterations: [], + modifiers: [], + ...partial, + }); const testText = `Strophe C D E F G A H Text Line 1-1 @@ -77,12 +90,12 @@ Cool bridge without any chords // c c# db c7 cmaj7 c/e void expect(sections[2].lines[0].chords).toEqual([ - {chord: 'c', length: 1, position: 0, add: null, slashChord: null}, - {chord: 'c#', length: 2, position: 2, add: null, slashChord: null}, - {chord: 'db', length: 2, position: 5, add: null, slashChord: null}, - {chord: 'c', length: 2, position: 8, add: '7', slashChord: null}, - {chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null}, - {chord: 'c', length: 3, position: 22, add: null, slashChord: 'e'}, + {chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}, + {chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null}, + {chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null}, + {chord: 'c', length: 2, position: 8, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}, + {chord: 'c', length: 5, position: 13, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}, + {chord: 'c', length: 3, position: 22, add: null, slashChord: 'e', addDescriptor: null}, ]); }); @@ -97,4 +110,232 @@ text`; void expect(sections[0].lines[1].type).toBe(LineType.text); void expect(sections[0].lines[1].text).toBe('text'); }); + + it('should ignore indented comment lines when comments are disabled', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe + # hidden comment +Text`; + + const sections = service.parse(text, null, false); + + void expect(sections[0].lines.length).toBe(1); + void expect(sections[0].lines[0].text).toBe('Text'); + }); + + it('should accept section headers with numbering and lowercase letters', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `strophe 1 +Text A +Refrain 2 +Text B`; + + const sections = service.parse(text, null); + + void expect(sections.length).toBe(2); + void expect(sections[0].type).toBe(SectionType.Verse); + void expect(sections[1].type).toBe(SectionType.Chorus); + }); + + it('should return an empty array for empty input', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + + void expect(service.parse('', null)).toEqual([]); + }); + + it('should ignore content before the first recognized section header', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Intro ohne Section +Noch eine Zeile +Strophe +Text`; + + const sections = service.parse(text, null); + + void expect(sections.length).toBe(1); + void expect(sections[0].lines.length).toBe(1); + void expect(sections[0].lines[0].text).toBe('Text'); + }); + + it('should keep comment lines when comments are enabled', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +# Kommentar +Text`; + + const sections = service.parse(text, null, true); + + void expect(sections[0].lines.length).toBe(2); + void expect(sections[0].lines[0].text).toBe('# Kommentar'); + void expect(sections[0].lines[1].text).toBe('Text'); + }); + + it('should support windows line endings', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = 'Strophe\r\nC D E\r\nText\r\nRefrain\r\nG A H'; + + const sections = service.parse(text, null); + + void expect(sections.length).toBe(2); + void expect(sections[0].lines[0].type).toBe(LineType.chord); + void expect(sections[1].lines[0].type).toBe(LineType.chord); + }); + + it('should not classify ordinary text with isolated note letters as a chord line', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +Anna geht heute baden +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].type).toBe(LineType.text); + void expect(sections[0].lines[0].chords).toBeNull(); + }); + + it('should preserve exact chord positions for spaced chord lines', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +C G/B Am +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].chords).toEqual([ + {chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null}, + {chord: 'G', length: 3, position: 8, add: null, slashChord: 'B', addDescriptor: null}, + {chord: 'A', length: 2, position: 17, add: 'm', slashChord: null, addDescriptor: descriptor('m', {quality: 'minor'})}, + ]); + }); + + it('should parse common international chord suffixes and slash chords after the suffix', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +Cmaj7 Dm7 Gsus4 Aadd9 Cmaj7/E +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].type).toBe(LineType.chord); + void expect(sections[0].lines[0].chords).toEqual([ + {chord: 'C', length: 5, position: 0, add: 'maj7', slashChord: null, addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}, + {chord: 'D', length: 3, position: 6, add: 'm7', slashChord: null, addDescriptor: descriptor('m7', {quality: 'minor', extensions: ['7']})}, + {chord: 'G', length: 5, position: 10, add: 'sus4', slashChord: null, addDescriptor: descriptor('sus4', {suspensions: ['4']})}, + {chord: 'A', length: 5, position: 16, add: 'add9', slashChord: null, addDescriptor: descriptor('add9', {additions: ['9']})}, + {chord: 'C', length: 7, position: 22, add: 'maj7', slashChord: 'E', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}, + ]); + }); + + it('should parse german chord suffixes', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +Hmoll Edur Cverm Faug +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].type).toBe(LineType.chord); + void expect(sections[0].lines[0].chords).toEqual([ + {chord: 'H', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}, + {chord: 'E', length: 4, position: 6, add: 'dur', slashChord: null, addDescriptor: descriptor('dur', {quality: 'major'})}, + {chord: 'C', length: 5, position: 11, add: 'verm', slashChord: null, addDescriptor: descriptor('verm', {quality: 'diminished'})}, + {chord: 'F', length: 4, position: 17, add: 'aug', slashChord: null, addDescriptor: descriptor('aug', {quality: 'augmented'})}, + ]); + }); + + it('should parse numeric and altered chord suffixes', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +C7 D9 E11 F13 Gadd#9 A7-5 +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].chords).toEqual([ + {chord: 'C', length: 2, position: 0, add: '7', slashChord: null, addDescriptor: descriptor('7', {extensions: ['7']})}, + {chord: 'D', length: 2, position: 3, add: '9', slashChord: null, addDescriptor: descriptor('9', {extensions: ['9']})}, + {chord: 'E', length: 3, position: 6, add: '11', slashChord: null, addDescriptor: descriptor('11', {extensions: ['11']})}, + {chord: 'F', length: 3, position: 10, add: '13', slashChord: null, addDescriptor: descriptor('13', {extensions: ['13']})}, + {chord: 'G', length: 6, position: 14, add: 'add#9', slashChord: null, addDescriptor: descriptor('add#9', {additions: ['#9']})}, + {chord: 'A', length: 4, position: 21, add: '7-5', slashChord: null, addDescriptor: descriptor('7-5', {extensions: ['7'], alterations: ['-5']})}, + ]); + }); + + it('should parse lowercase roots with suffixes and slash chords', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +emoll d/F# cmaj7/e +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].chords).toEqual([ + {chord: 'e', length: 5, position: 0, add: 'moll', slashChord: null, addDescriptor: descriptor('moll', {quality: 'minor'})}, + {chord: 'd', length: 4, position: 6, add: null, slashChord: 'F#', addDescriptor: null}, + {chord: 'c', length: 7, position: 11, add: 'maj7', slashChord: 'e', addDescriptor: descriptor('maj7', {quality: 'major', extensions: ['7']})}, + ]); + }); + + it('should treat compact prose-like tokens as text when the chord ratio is too low', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +Das ist C nicht D sondern Text +Text`; + + const sections = service.parse(text, null); + + void expect(sections[0].lines[0].type).toBe(LineType.text); + void expect(sections[0].lines[0].chords).toBeNull(); + }); + + it('should call the transpose service when a transpose mode is provided', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const transposeService = TestBed.inject(TransposeService); + const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough(); + const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough(); + const text = `Strophe +C D E +Text`; + + service.parse(text, {baseKey: 'C', targetKey: 'D'}); + + void expect(transposeSpy).toHaveBeenCalledTimes(2); + void expect(renderSpy).not.toHaveBeenCalled(); + }); + + it('should use renderChords when no transpose mode is provided', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const transposeService = TestBed.inject(TransposeService); + const transposeSpy = spyOn(transposeService, 'transpose').and.callThrough(); + const renderSpy = spyOn(transposeService, 'renderChords').and.callThrough(); + const text = `Strophe +C D E +Text`; + + service.parse(text, null); + + void expect(renderSpy).toHaveBeenCalledTimes(2); + void expect(transposeSpy).not.toHaveBeenCalled(); + }); + + it('should expose semantic descriptors for complex chord additions', () => { + const service: TextRenderingService = TestBed.inject(TextRenderingService); + const text = `Strophe +Cmaj7(add9) Dm7 Gsus4 A7-5 +Text`; + + const sections = service.parse(text, null); + const chords = sections[0].lines[0].chords ?? []; + + void expect(chords[0].addDescriptor).toEqual( + descriptor('maj7(add9)', { + quality: 'major', + extensions: ['7'], + modifiers: ['(add9)'], + }) + ); + void expect(chords[1].addDescriptor).toEqual(descriptor('m7', {quality: 'minor', extensions: ['7']})); + void expect(chords[2].addDescriptor).toEqual(descriptor('sus4', {suspensions: ['4']})); + void expect(chords[3].addDescriptor).toEqual(descriptor('7-5', {extensions: ['7'], alterations: ['-5']})); + }); }); diff --git a/src/app/modules/songs/services/text-rendering.service.ts b/src/app/modules/songs/services/text-rendering.service.ts index a598c1d..b40fc23 100644 --- a/src/app/modules/songs/services/text-rendering.service.ts +++ b/src/app/modules/songs/services/text-rendering.service.ts @@ -4,7 +4,7 @@ import {TransposeMode} from './transpose-mode'; import {SectionType} from './section-type'; import {Section} from './section'; import {LineType} from './line-type'; -import {Chord} from './chord'; +import {Chord, ChordAddDescriptor} from './chord'; import {Line} from './line'; @Injectable({ @@ -13,43 +13,94 @@ import {Line} from './line'; export class TextRenderingService { private transposeService = inject(TransposeService); - private regexSection = /(Strophe|Refrain|Bridge)/; + private readonly regexSection = /^\s*(Strophe|Refrain|Bridge)\b/i; + private readonly chordRoots = [ + 'C#', + 'Db', + 'D#', + 'Eb', + 'F#', + 'Gb', + 'G#', + 'Ab', + 'A#', + 'C', + 'D', + 'E', + 'F', + 'G', + 'A', + 'B', + 'H', + 'c#', + 'db', + 'd#', + 'eb', + 'f#', + 'gb', + 'g#', + 'ab', + 'a#', + 'c', + 'd', + 'e', + 'f', + 'g', + 'a', + 'b', + 'h', + ] as const; + private readonly suffixKeywords = ['moll', 'verm', 'maj', 'min', 'dur', 'dim', 'aug', 'sus', 'add', 'm'] as const; + private readonly suffixChars = new Set(['#', 'b', '+', '-', '(', ')']); public parse(text: string, transpose: TransposeMode | null, withComments = true): Section[] { if (!text) { return []; } - const arrayOfLines = text.split(/\r?\n/).filter(_ => _ && (!_.startsWith('#') || withComments)); + const indices = { [SectionType.Bridge]: 0, [SectionType.Chorus]: 0, [SectionType.Verse]: 0, }; - return arrayOfLines.reduce((array, line) => { + const sections: Section[] = []; + + for (const line of text.split(/\r?\n/)) { + if (!line || this.isCommentLine(line, withComments)) { + continue; + } + const type = this.getSectionTypeOfLine(line); - if (this.regexSection.exec(line) && type !== null) { - const section: Section = { + if (type !== null) { + sections.push({ type, number: indices[type]++, lines: [], - }; - return [...array, section]; + }); + continue; } - const lineOfLineText = this.getLineOfLineText(line, transpose); - if (array.length === 0) return array; - if (lineOfLineText) array[array.length - 1].lines.push(lineOfLineText); - return array; - }, [] as Section[]); + + if (sections.length === 0) { + continue; + } + + const renderedLine = this.getLineOfLineText(line, transpose); + if (renderedLine) { + sections[sections.length - 1].lines.push(renderedLine); + } + } + + return sections; } private getLineOfLineText(text: string, transpose: TransposeMode | null): Line | null { if (!text) return null; - const cords = this.readChords(text); - const hasMatches = cords.length > 0; + const chords = this.readChords(text); + const hasMatches = chords.length > 0; const type = hasMatches ? LineType.chord : LineType.text; - const line: Line = {type, text, chords: hasMatches ? cords : null}; + const line: Line = {type, text, chords: hasMatches ? chords : null}; return transpose !== null && transpose !== undefined ? this.transposeService.transpose(line, transpose.baseKey, transpose.targetKey) : this.transposeService.renderChords(line); } @@ -61,13 +112,13 @@ export class TextRenderingService { if (!match || match.length < 2) { return null; } - const typeString = match[1]; + const typeString = match[1].toLowerCase(); switch (typeString) { - case 'Strophe': + case 'strophe': return SectionType.Verse; - case 'Refrain': + case 'refrain': return SectionType.Chorus; - case 'Bridge': + case 'bridge': return SectionType.Bridge; } @@ -75,34 +126,171 @@ export class TextRenderingService { } private readChords(chordLine: string): Chord[] { - let match: string[] | null; const chords: Chord[] = []; + const tokens = chordLine.match(/\S+/g) ?? []; - // https://regex101.com/r/68jMB8/5 - const regex = - /(C#|C|Db|D#|D|Eb|E|F#|F|Gb|G#|G|Ab|A#|A|B|H|c#|c|db|d#|d|eb|e|f#|f|gb|g#|g|ab|a#|a|b|h)(\/(C#|C|Db|D#|D|Eb|E|F#|F|Gb|G#|G|Ab|A#|A|B|H|c#|c|db|d#|d|eb|e|f#|f|gb|g#|g|ab|a#|a|b|h))?(\d+|maj7)?/gm; - - while ((match = regex.exec(chordLine)) !== null) { - const chord: Chord = { - chord: match[1], - length: match[0].length, - position: regex.lastIndex - match[0].length, - slashChord: null, - add: null, - }; - if (match[3]) { - chord.slashChord = match[3]; + for (const token of tokens) { + const position = chordLine.indexOf(token, chords.length > 0 ? chords[chords.length - 1].position + chords[chords.length - 1].length : 0); + const chord = this.parseChordToken(token, position); + if (chord) { + chords.push(chord); } - if (match[4]) { - chord.add = match[4]; - } - - chords.push(chord); } const chordCount = chords.reduce((acc: number, cur: Chord) => acc + cur.length, 0); const lineCount = chordLine.replace(/\s/g, '').length; - const isChrod = chordCount * 1.2 > lineCount; - return isChrod ? chords : []; + const isChordLine = chordCount * 1.2 > lineCount; + return isChordLine ? chords : []; + } + + private isCommentLine(line: string, withComments: boolean): boolean { + return !withComments && line.trimStart().startsWith('#'); + } + + private parseChordToken(token: string, position: number): Chord | null { + const root = this.readChordRoot(token, 0); + if (!root) { + return null; + } + + let cursor = root.length; + const suffix = this.readChordSuffix(token, cursor); + cursor += suffix.length; + + let slashChord: string | null = null; + if (token[cursor] === '/') { + const slash = this.readChordRoot(token, cursor + 1); + if (!slash) { + return null; + } + slashChord = slash; + cursor += 1 + slash.length; + } + + if (cursor !== token.length) { + return null; + } + + return { + chord: root, + length: token.length, + position, + slashChord, + add: suffix || null, + addDescriptor: this.parseChordAddDescriptor(suffix || null), + }; + } + + private readChordRoot(token: string, start: number): string | null { + return this.chordRoots.find(root => token.startsWith(root, start)) ?? null; + } + + private readChordSuffix(token: string, start: number): string { + let cursor = start; + let suffix = ''; + + while (cursor < token.length) { + const keyword = this.suffixKeywords.find(entry => token.startsWith(entry, cursor)); + if (keyword) { + suffix += keyword; + cursor += keyword.length; + continue; + } + + const char = token[cursor]; + if (this.isDigit(char) || this.suffixChars.has(char)) { + suffix += char; + cursor += 1; + continue; + } + + break; + } + + return suffix; + } + + private isDigit(char: string | undefined): boolean { + return !!char && char >= '0' && char <= '9'; + } + + private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null { + if (!suffix) { + return null; + } + + let rest = suffix; + let quality: ChordAddDescriptor['quality'] = null; + + const qualityMatchers: Array<[string, NonNullable]> = [ + ['moll', 'minor'], + ['min', 'minor'], + ['maj', 'major'], + ['dur', 'major'], + ['dim', 'diminished'], + ['verm', 'diminished'], + ['aug', 'augmented'], + ['m', 'minor'], + ]; + + for (const [prefix, normalized] of qualityMatchers) { + if (rest.startsWith(prefix)) { + quality = normalized; + rest = rest.slice(prefix.length); + break; + } + } + + const descriptor: ChordAddDescriptor = { + raw: suffix, + quality, + extensions: [], + additions: [], + suspensions: [], + alterations: [], + modifiers: [], + }; + + while (rest.length > 0) { + const additionMatch = rest.match(/^add([#b+\-]?\d+)/); + if (additionMatch) { + descriptor.additions.push(additionMatch[1]); + rest = rest.slice(additionMatch[0].length); + continue; + } + + const suspensionMatch = rest.match(/^sus(\d*)/); + if (suspensionMatch) { + descriptor.suspensions.push(suspensionMatch[1] || 'sus'); + rest = rest.slice(suspensionMatch[0].length); + continue; + } + + const extensionMatch = rest.match(/^\d+/); + if (extensionMatch) { + descriptor.extensions.push(extensionMatch[0]); + rest = rest.slice(extensionMatch[0].length); + continue; + } + + const alterationMatch = rest.match(/^[#b+\-]\d+/); + if (alterationMatch) { + descriptor.alterations.push(alterationMatch[0]); + rest = rest.slice(alterationMatch[0].length); + continue; + } + + const modifierMatch = rest.match(/^\([^)]*\)/); + if (modifierMatch) { + descriptor.modifiers.push(modifierMatch[0]); + rest = rest.slice(modifierMatch[0].length); + continue; + } + + descriptor.modifiers.push(rest); + break; + } + + return descriptor; } } diff --git a/src/app/modules/songs/services/transpose.service.spec.ts b/src/app/modules/songs/services/transpose.service.spec.ts index 80cb2c8..c31c87e 100644 --- a/src/app/modules/songs/services/transpose.service.spec.ts +++ b/src/app/modules/songs/services/transpose.service.spec.ts @@ -14,7 +14,7 @@ describe('TransposeService', () => { it('should create map upwards', () => { const distance = service.getDistance('D', 'G'); - const map = service.getMap('D', 'G', distance); + const map = service.getMap('D', 'G'); if (map) { void expect(map['D']).toBe('G'); @@ -23,7 +23,7 @@ describe('TransposeService', () => { it('should create map downwards', () => { const distance = service.getDistance('G', 'D'); - const map = service.getMap('G', 'D', distance); + const map = service.getMap('G', 'D'); if (map) { void expect(map['G']).toBe('D'); @@ -32,7 +32,7 @@ describe('TransposeService', () => { it('should transpose enharmonic targets by semitone distance', () => { const distance = service.getDistance('C', 'Db'); - const map = service.getMap('C', 'Db', distance); + const map = service.getMap('C', 'Db'); void expect(distance).toBe(1); void expect(map?.['C']).toBe('Db'); @@ -41,7 +41,7 @@ describe('TransposeService', () => { it('should keep german B/H notation consistent', () => { const distance = service.getDistance('H', 'C'); - const map = service.getMap('H', 'C', distance); + const map = service.getMap('H', 'C'); void expect(distance).toBe(1); void expect(map?.['H']).toBe('C'); diff --git a/src/app/modules/songs/services/transpose.service.ts b/src/app/modules/songs/services/transpose.service.ts index aeb7bf5..6652adc 100644 --- a/src/app/modules/songs/services/transpose.service.ts +++ b/src/app/modules/songs/services/transpose.service.ts @@ -67,7 +67,7 @@ export class TransposeService { if (line.type !== LineType.chord || !line.chords) return line; const difference = this.getDistance(baseKey, targetKey); - const map = this.getMap(baseKey, targetKey, difference); + const map = this.getMap(baseKey, targetKey); const chords = difference !== 0 && map ? line.chords.map(chord => this.transposeChord(chord, map)) : line.chords; const renderedLine = this.renderLine(chords); @@ -85,8 +85,8 @@ export class TransposeService { } public getDistance(baseKey: string, targetKey: string): number { - const baseSemitone = this.keyToSemitone[baseKey]; - const targetSemitone = this.keyToSemitone[targetKey]; + const baseSemitone = this.getSemitone(baseKey); + const targetSemitone = this.getSemitone(targetKey); if (baseSemitone === undefined || targetSemitone === undefined) { return 0; @@ -95,8 +95,8 @@ export class TransposeService { return (targetSemitone - baseSemitone + 12) % 12; } - public getMap(baseKey: string, targetKey: string, difference: number): TransposeMap | null { - const cacheKey = `${baseKey}:${targetKey}:${difference}`; + public getMap(baseKey: string, targetKey: string): TransposeMap | null { + const cacheKey = `${baseKey}:${targetKey}`; const cachedMap = this.mapCache.get(cacheKey); if (cachedMap) { return cachedMap; @@ -108,6 +108,7 @@ export class TransposeService { return null; } + const difference = this.getDistance(baseKey, targetKey); const map: TransposeMap = {}; sourceScales.forEach((sourceScale, scaleIndex) => { const targetScale = targetScales[scaleIndex]; @@ -132,6 +133,8 @@ export class TransposeService { } private transposeChord(chord: Chord, map: TransposeMap): Chord { + // Intentional fallback: unknown chord tokens must stay visibly invalid as "X". + // Do not replace this with the original token without explicit product approval. const translatedChord = map[chord.chord] ?? 'X'; const translatedSlashChord = chord.slashChord ? (map[chord.slashChord] ?? 'X') : null; @@ -147,33 +150,38 @@ export class TransposeService { return Math.max(max, chord.position + this.renderChord(chord).length); }, 0); - let template = ''.padEnd(width, ' '); + const buffer = Array.from({length: width}, () => ' '); chords.forEach(chord => { const pos = chord.position; const renderedChord = this.renderChord(chord); - const newLength = renderedChord.length; + const requiredLength = pos + renderedChord.length; - if (template.length < pos + newLength) { - template = template.padEnd(pos + newLength, ' '); + while (buffer.length < requiredLength) { + buffer.push(' '); } - const pre = template.slice(0, pos); - const post = template.slice(pos + newLength); - - template = pre + renderedChord + post; + for (let i = 0; i < renderedChord.length; i++) { + buffer[pos + i] = renderedChord[i]; + } }); - return template.trimEnd(); + return buffer.join('').trimEnd(); } private renderChord(chord: Chord): string { + // Intentional fallback: unknown chord tokens must stay visibly invalid as "X". + // Do not replace this with the original token without explicit product approval. const renderedChord = scaleMapping[chord.chord] ?? 'X'; const renderedSlashChord = chord.slashChord ? (scaleMapping[chord.slashChord] ?? 'X') : ''; return renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : ''); } + private getSemitone(key: string): number | undefined { + return this.keyToSemitone[key]; + } + private getScaleVariants(key: string): ScaleVariants | null { const scales = getScaleType(key); return scales ? [scales[0], scales[1]] : null;