optimize chords
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user