optimize chords

This commit is contained in:
2026-03-10 00:23:04 +01:00
parent 7170e4a08e
commit 2ac1156e20
5 changed files with 514 additions and 66 deletions

View File

@@ -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 { export interface Chord {
chord: string; chord: string;
length: number; length: number;
position: number; position: number;
slashChord: string | null; slashChord: string | null;
add: string | null; add: string | null;
addDescriptor?: ChordAddDescriptor | null;
} }

View File

@@ -2,8 +2,21 @@ import {TestBed} from '@angular/core/testing';
import {TextRenderingService} from './text-rendering.service'; import {TextRenderingService} from './text-rendering.service';
import {LineType} from './line-type'; import {LineType} from './line-type';
import {SectionType} from './section-type'; import {SectionType} from './section-type';
import {TransposeService} from './transpose.service';
import {ChordAddDescriptor} from './chord';
describe('TextRenderingService', () => { describe('TextRenderingService', () => {
const descriptor = (raw: string, partial: Partial<ChordAddDescriptor>) =>
jasmine.objectContaining({
raw,
quality: null,
extensions: [],
additions: [],
suspensions: [],
alterations: [],
modifiers: [],
...partial,
});
const testText = `Strophe const testText = `Strophe
C D E F G A H C D E F G A H
Text Line 1-1 Text Line 1-1
@@ -77,12 +90,12 @@ Cool bridge without any chords
// c c# db c7 cmaj7 c/e // c c# db c7 cmaj7 c/e
void expect(sections[2].lines[0].chords).toEqual([ void expect(sections[2].lines[0].chords).toEqual([
{chord: 'c', length: 1, position: 0, add: null, slashChord: null}, {chord: 'c', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'c#', length: 2, position: 2, add: null, slashChord: null}, {chord: 'c#', length: 2, position: 2, add: null, slashChord: null, addDescriptor: null},
{chord: 'db', length: 2, position: 5, add: null, slashChord: null}, {chord: 'db', length: 2, position: 5, add: null, slashChord: null, addDescriptor: null},
{chord: 'c', length: 2, position: 8, add: '7', slashChord: 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}, {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'}, {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].type).toBe(LineType.text);
void expect(sections[0].lines[1].text).toBe('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']}));
});
}); });

View File

@@ -4,7 +4,7 @@ import {TransposeMode} from './transpose-mode';
import {SectionType} from './section-type'; import {SectionType} from './section-type';
import {Section} from './section'; import {Section} from './section';
import {LineType} from './line-type'; import {LineType} from './line-type';
import {Chord} from './chord'; import {Chord, ChordAddDescriptor} from './chord';
import {Line} from './line'; import {Line} from './line';
@Injectable({ @Injectable({
@@ -13,43 +13,94 @@ import {Line} from './line';
export class TextRenderingService { export class TextRenderingService {
private transposeService = inject(TransposeService); 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[] { public parse(text: string, transpose: TransposeMode | null, withComments = true): Section[] {
if (!text) { if (!text) {
return []; return [];
} }
const arrayOfLines = text.split(/\r?\n/).filter(_ => _ && (!_.startsWith('#') || withComments));
const indices = { const indices = {
[SectionType.Bridge]: 0, [SectionType.Bridge]: 0,
[SectionType.Chorus]: 0, [SectionType.Chorus]: 0,
[SectionType.Verse]: 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); const type = this.getSectionTypeOfLine(line);
if (this.regexSection.exec(line) && type !== null) { if (type !== null) {
const section: Section = { sections.push({
type, type,
number: indices[type]++, number: indices[type]++,
lines: [], lines: [],
}; });
return [...array, section]; continue;
} }
const lineOfLineText = this.getLineOfLineText(line, transpose);
if (array.length === 0) return array; if (sections.length === 0) {
if (lineOfLineText) array[array.length - 1].lines.push(lineOfLineText); continue;
return array; }
}, [] as Section[]);
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 { private getLineOfLineText(text: string, transpose: TransposeMode | null): Line | null {
if (!text) return null; if (!text) return null;
const cords = this.readChords(text); const chords = this.readChords(text);
const hasMatches = cords.length > 0; const hasMatches = chords.length > 0;
const type = hasMatches ? LineType.chord : LineType.text; 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); 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) { if (!match || match.length < 2) {
return null; return null;
} }
const typeString = match[1]; const typeString = match[1].toLowerCase();
switch (typeString) { switch (typeString) {
case 'Strophe': case 'strophe':
return SectionType.Verse; return SectionType.Verse;
case 'Refrain': case 'refrain':
return SectionType.Chorus; return SectionType.Chorus;
case 'Bridge': case 'bridge':
return SectionType.Bridge; return SectionType.Bridge;
} }
@@ -75,34 +126,171 @@ export class TextRenderingService {
} }
private readChords(chordLine: string): Chord[] { private readChords(chordLine: string): Chord[] {
let match: string[] | null;
const chords: Chord[] = []; const chords: Chord[] = [];
const tokens = chordLine.match(/\S+/g) ?? [];
// https://regex101.com/r/68jMB8/5 for (const token of tokens) {
const regex = const position = chordLine.indexOf(token, chords.length > 0 ? chords[chords.length - 1].position + chords[chords.length - 1].length : 0);
/(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; const chord = this.parseChordToken(token, position);
if (chord) {
while ((match = regex.exec(chordLine)) !== null) { chords.push(chord);
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];
} }
if (match[4]) {
chord.add = match[4];
}
chords.push(chord);
} }
const chordCount = chords.reduce((acc: number, cur: Chord) => acc + cur.length, 0); const chordCount = chords.reduce((acc: number, cur: Chord) => acc + cur.length, 0);
const lineCount = chordLine.replace(/\s/g, '').length; const lineCount = chordLine.replace(/\s/g, '').length;
const isChrod = chordCount * 1.2 > lineCount; const isChordLine = chordCount * 1.2 > lineCount;
return isChrod ? chords : []; 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<ChordAddDescriptor['quality']>]> = [
['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;
} }
} }

View File

@@ -14,7 +14,7 @@ describe('TransposeService', () => {
it('should create map upwards', () => { it('should create map upwards', () => {
const distance = service.getDistance('D', 'G'); const distance = service.getDistance('D', 'G');
const map = service.getMap('D', 'G', distance); const map = service.getMap('D', 'G');
if (map) { if (map) {
void expect(map['D']).toBe('G'); void expect(map['D']).toBe('G');
@@ -23,7 +23,7 @@ describe('TransposeService', () => {
it('should create map downwards', () => { it('should create map downwards', () => {
const distance = service.getDistance('G', 'D'); const distance = service.getDistance('G', 'D');
const map = service.getMap('G', 'D', distance); const map = service.getMap('G', 'D');
if (map) { if (map) {
void expect(map['G']).toBe('D'); void expect(map['G']).toBe('D');
@@ -32,7 +32,7 @@ describe('TransposeService', () => {
it('should transpose enharmonic targets by semitone distance', () => { it('should transpose enharmonic targets by semitone distance', () => {
const distance = service.getDistance('C', 'Db'); 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(distance).toBe(1);
void expect(map?.['C']).toBe('Db'); void expect(map?.['C']).toBe('Db');
@@ -41,7 +41,7 @@ describe('TransposeService', () => {
it('should keep german B/H notation consistent', () => { it('should keep german B/H notation consistent', () => {
const distance = service.getDistance('H', 'C'); 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(distance).toBe(1);
void expect(map?.['H']).toBe('C'); void expect(map?.['H']).toBe('C');

View File

@@ -67,7 +67,7 @@ export class TransposeService {
if (line.type !== LineType.chord || !line.chords) return line; if (line.type !== LineType.chord || !line.chords) return line;
const difference = this.getDistance(baseKey, targetKey); 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 chords = difference !== 0 && map ? line.chords.map(chord => this.transposeChord(chord, map)) : line.chords;
const renderedLine = this.renderLine(chords); const renderedLine = this.renderLine(chords);
@@ -85,8 +85,8 @@ export class TransposeService {
} }
public getDistance(baseKey: string, targetKey: string): number { public getDistance(baseKey: string, targetKey: string): number {
const baseSemitone = this.keyToSemitone[baseKey]; const baseSemitone = this.getSemitone(baseKey);
const targetSemitone = this.keyToSemitone[targetKey]; const targetSemitone = this.getSemitone(targetKey);
if (baseSemitone === undefined || targetSemitone === undefined) { if (baseSemitone === undefined || targetSemitone === undefined) {
return 0; return 0;
@@ -95,8 +95,8 @@ export class TransposeService {
return (targetSemitone - baseSemitone + 12) % 12; return (targetSemitone - baseSemitone + 12) % 12;
} }
public getMap(baseKey: string, targetKey: string, difference: number): TransposeMap | null { public getMap(baseKey: string, targetKey: string): TransposeMap | null {
const cacheKey = `${baseKey}:${targetKey}:${difference}`; const cacheKey = `${baseKey}:${targetKey}`;
const cachedMap = this.mapCache.get(cacheKey); const cachedMap = this.mapCache.get(cacheKey);
if (cachedMap) { if (cachedMap) {
return cachedMap; return cachedMap;
@@ -108,6 +108,7 @@ export class TransposeService {
return null; return null;
} }
const difference = this.getDistance(baseKey, targetKey);
const map: TransposeMap = {}; const map: TransposeMap = {};
sourceScales.forEach((sourceScale, scaleIndex) => { sourceScales.forEach((sourceScale, scaleIndex) => {
const targetScale = targetScales[scaleIndex]; const targetScale = targetScales[scaleIndex];
@@ -132,6 +133,8 @@ export class TransposeService {
} }
private transposeChord(chord: Chord, map: TransposeMap): Chord { 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 translatedChord = map[chord.chord] ?? 'X';
const translatedSlashChord = chord.slashChord ? (map[chord.slashChord] ?? 'X') : null; 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); return Math.max(max, chord.position + this.renderChord(chord).length);
}, 0); }, 0);
let template = ''.padEnd(width, ' '); const buffer = Array.from({length: width}, () => ' ');
chords.forEach(chord => { chords.forEach(chord => {
const pos = chord.position; const pos = chord.position;
const renderedChord = this.renderChord(chord); const renderedChord = this.renderChord(chord);
const newLength = renderedChord.length; const requiredLength = pos + renderedChord.length;
if (template.length < pos + newLength) { while (buffer.length < requiredLength) {
template = template.padEnd(pos + newLength, ' '); buffer.push(' ');
} }
const pre = template.slice(0, pos); for (let i = 0; i < renderedChord.length; i++) {
const post = template.slice(pos + newLength); buffer[pos + i] = renderedChord[i];
}
template = pre + renderedChord + post;
}); });
return template.trimEnd(); return buffer.join('').trimEnd();
} }
private renderChord(chord: Chord): string { 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 renderedChord = scaleMapping[chord.chord] ?? 'X';
const renderedSlashChord = chord.slashChord ? (scaleMapping[chord.slashChord] ?? 'X') : ''; const renderedSlashChord = chord.slashChord ? (scaleMapping[chord.slashChord] ?? 'X') : '';
return renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : ''); return renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : '');
} }
private getSemitone(key: string): number | undefined {
return this.keyToSemitone[key];
}
private getScaleVariants(key: string): ScaleVariants | null { private getScaleVariants(key: string): ScaleVariants | null {
const scales = getScaleType(key); const scales = getScaleType(key);
return scales ? [scales[0], scales[1]] : null; return scales ? [scales[0], scales[1]] : null;