validate chords #2

This commit is contained in:
2026-03-11 16:35:29 +01:00
parent ae4459f5ce
commit 9f47f259c0
4 changed files with 114 additions and 16 deletions

View File

@@ -13,7 +13,7 @@ export interface ChordValidationIssue {
lineText: string; lineText: string;
token: string; token: string;
suggestion: string | null; suggestion: string | null;
reason: 'alias' | 'minor_format' | 'major_format' | 'invalid_suffix' | 'unknown_token'; reason: 'alias' | 'minor_format' | 'major_format' | 'invalid_suffix' | 'unknown_token' | 'tab_character';
message: string; message: string;
} }
@@ -24,4 +24,6 @@ export interface Chord {
slashChord: string | null; slashChord: string | null;
add: string | null; add: string | null;
addDescriptor?: ChordAddDescriptor | null; addDescriptor?: ChordAddDescriptor | null;
prefix?: string;
suffix?: string;
} }

View File

@@ -276,6 +276,39 @@ Text`;
]); ]);
}); });
it('should keep parentheses around alternative chord groups', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C F G e C (F G)
Text`;
const sections = service.parse(text, null);
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('C F G e C (F G)');
void expect(sections[0].lines[0].chords).toEqual([
{chord: 'C', length: 1, position: 0, add: null, slashChord: null, addDescriptor: null},
{chord: 'F', length: 1, position: 2, add: null, slashChord: null, addDescriptor: null},
{chord: 'G', length: 1, position: 4, add: null, slashChord: null, addDescriptor: null},
{chord: 'e', length: 1, position: 6, add: null, slashChord: null, addDescriptor: null},
{chord: 'C', length: 1, position: 8, add: null, slashChord: null, addDescriptor: null},
{chord: 'F', length: 2, position: 11, add: null, slashChord: null, addDescriptor: null, prefix: '('},
{chord: 'G', length: 2, position: 14, add: null, slashChord: null, addDescriptor: null, suffix: ')'},
]);
});
it('should transpose multiple chords inside a parenthesized group', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe
C F G e C (F G)
Text`;
const sections = service.parse(text, {baseKey: 'C', targetKey: 'D'});
void expect(sections[0].lines[0].type).toBe(LineType.chord);
void expect(sections[0].lines[0].text).toBe('D G A f♯ D (G A)');
});
it('should treat compact prose-like tokens as text when the chord ratio is too low', () => { it('should treat compact prose-like tokens as text when the chord ratio is too low', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService); const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = `Strophe const text = `Strophe
@@ -399,4 +432,24 @@ Text`;
jasmine.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null}), jasmine.objectContaining({lineNumber: 2, token: 'Foo', reason: 'unknown_token', suggestion: null}),
]); ]);
}); });
it('should reject tabs on chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = 'Strophe\nC\tG\ta\nText';
void expect(service.validateChordNotation(text)).toContain(
jasmine.objectContaining({
lineNumber: 2,
token: '\t',
reason: 'tab_character',
})
);
});
it('should not flag tabs on non chord lines', () => {
const service: TextRenderingService = TestBed.inject(TextRenderingService);
const text = 'Strophe\nDas\tist normaler Text\nText';
void expect(service.validateChordNotation(text)).toEqual([]);
});
}); });

View File

@@ -8,11 +8,13 @@ import {Chord, ChordAddDescriptor, ChordValidationIssue} from './chord';
import {Line} from './line'; import {Line} from './line';
interface ParsedValidationToken { interface ParsedValidationToken {
prefix: string;
root: string; root: string;
suffix: string; suffix: string;
slashChord: string | null; slashChord: string | null;
rootWasAlias: boolean; rootWasAlias: boolean;
slashWasAlias: boolean; slashWasAlias: boolean;
tokenSuffix: string;
} }
interface ChordLineValidationResult { interface ChordLineValidationResult {
@@ -225,8 +227,15 @@ export class TextRenderingService {
return {issues: [], isChordLike: false}; return {issues: [], isChordLike: false};
} }
const issues: ChordValidationIssue[] = [];
if (line.includes('\t')) {
issues.push(this.createTabCharacterIssue(line, lineNumber));
}
return { return {
issues: parsedTokens issues: [
...issues,
...parsedTokens
.map(entry => { .map(entry => {
if (!entry.parsed) { if (!entry.parsed) {
return this.createUnknownTokenIssue(line, lineNumber, entry.token); return this.createUnknownTokenIssue(line, lineNumber, entry.token);
@@ -235,6 +244,7 @@ export class TextRenderingService {
return this.createValidationIssue(line, lineNumber, entry.token, entry.parsed); return this.createValidationIssue(line, lineNumber, entry.token, entry.parsed);
}) })
.filter((issue): issue is ChordValidationIssue => issue !== null), .filter((issue): issue is ChordValidationIssue => issue !== null),
],
isChordLike: true, isChordLike: true,
}; };
} }
@@ -244,18 +254,19 @@ export class TextRenderingService {
} }
private parseChordToken(token: string, position: number): Chord | null { private parseChordToken(token: string, position: number): Chord | null {
const root = this.readChordRoot(token, 0); const decoratedToken = this.splitTokenDecorators(token);
const root = this.readChordRoot(decoratedToken.core, 0);
if (!root) { if (!root) {
return null; return null;
} }
let cursor = root.length; let cursor = root.length;
const suffix = this.readChordSuffix(token, cursor); const suffix = this.readChordSuffix(decoratedToken.core, cursor);
cursor += suffix.length; cursor += suffix.length;
let slashChord: string | null = null; let slashChord: string | null = null;
if (token[cursor] === '/') { if (decoratedToken.core[cursor] === '/') {
const slash = this.readChordRoot(token, cursor + 1); const slash = this.readChordRoot(decoratedToken.core, cursor + 1);
if (!slash) { if (!slash) {
return null; return null;
} }
@@ -263,7 +274,7 @@ export class TextRenderingService {
cursor += 1 + slash.length; cursor += 1 + slash.length;
} }
if (cursor !== token.length) { if (cursor !== decoratedToken.core.length) {
return null; return null;
} }
@@ -274,6 +285,8 @@ export class TextRenderingService {
slashChord, slashChord,
add: suffix || null, add: suffix || null,
addDescriptor: this.parseChordAddDescriptor(suffix || null), addDescriptor: this.parseChordAddDescriptor(suffix || null),
prefix: decoratedToken.prefix || undefined,
suffix: decoratedToken.suffix || undefined,
}; };
} }
@@ -350,19 +363,20 @@ export class TextRenderingService {
} }
private parseValidationToken(token: string): ParsedValidationToken | null { private parseValidationToken(token: string): ParsedValidationToken | null {
const root = this.readValidationChordRoot(token, 0); const decoratedToken = this.splitTokenDecorators(token);
const root = this.readValidationChordRoot(decoratedToken.core, 0);
if (!root) { if (!root) {
return null; return null;
} }
let cursor = root.length; let cursor = root.length;
const suffix = this.readValidationChordSuffix(token, cursor); const suffix = this.readValidationChordSuffix(decoratedToken.core, cursor);
cursor += suffix.length; cursor += suffix.length;
let slashChord: string | null = null; let slashChord: string | null = null;
let slashWasAlias = false; let slashWasAlias = false;
if (token[cursor] === '/') { if (decoratedToken.core[cursor] === '/') {
const slash = this.readValidationChordRoot(token, cursor + 1); const slash = this.readValidationChordRoot(decoratedToken.core, cursor + 1);
if (!slash) { if (!slash) {
return null; return null;
} }
@@ -371,16 +385,18 @@ export class TextRenderingService {
cursor += 1 + slash.length; cursor += 1 + slash.length;
} }
if (cursor !== token.length) { if (cursor !== decoratedToken.core.length) {
return null; return null;
} }
return { return {
prefix: decoratedToken.prefix,
root: root.root, root: root.root,
suffix, suffix,
slashChord, slashChord,
rootWasAlias: root.wasAlias, rootWasAlias: root.wasAlias,
slashWasAlias, slashWasAlias,
tokenSuffix: decoratedToken.suffix,
}; };
} }
@@ -449,6 +465,17 @@ export class TextRenderingService {
}; };
} }
private createTabCharacterIssue(line: string, lineNumber: number): ChordValidationIssue {
return {
lineNumber,
lineText: line,
token: '\t',
suggestion: null,
reason: 'tab_character',
message: 'Tabulatoren sind in Akkordzeilen nicht erlaubt, bitte Leerzeichen verwenden',
};
}
private getCanonicalChordToken(parsed: ParsedValidationToken): string { private getCanonicalChordToken(parsed: ParsedValidationToken): string {
const descriptor = this.parseChordAddDescriptor(parsed.suffix); const descriptor = this.parseChordAddDescriptor(parsed.suffix);
const normalizedSuffix = this.normalizeSuffix(parsed.suffix); const normalizedSuffix = this.normalizeSuffix(parsed.suffix);
@@ -468,7 +495,7 @@ export class TextRenderingService {
? (this.isMinorRoot(root) ? this.toMinorRoot(parsed.slashChord) : this.toMajorRoot(parsed.slashChord)) ? (this.isMinorRoot(root) ? this.toMinorRoot(parsed.slashChord) : this.toMajorRoot(parsed.slashChord))
: null; : null;
return root + suffix + (slashChord ? `/${slashChord}` : ''); return parsed.prefix + root + suffix + (slashChord ? `/${slashChord}` : '') + parsed.tokenSuffix;
} }
private normalizeSuffix(suffix: string): string { private normalizeSuffix(suffix: string): string {
@@ -531,6 +558,20 @@ export class TextRenderingService {
return root[0].toUpperCase() + root.slice(1); return root[0].toUpperCase() + root.slice(1);
} }
private splitTokenDecorators(token: string): {prefix: string; core: string; suffix: string} {
const prefixMatch = token.match(/^\(+/);
const suffixMatch = token.match(/\)+$/);
const prefix = prefixMatch?.[0] ?? '';
const suffix = suffixMatch?.[0] ?? '';
const core = token.slice(prefix.length, token.length - suffix.length);
return {
prefix,
core,
suffix,
};
}
private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null { private parseChordAddDescriptor(suffix: string | null): ChordAddDescriptor | null {
if (!suffix) { if (!suffix) {
return null; return null;

View File

@@ -174,8 +174,10 @@ export class TransposeService {
// Do not replace this with the original token without explicit product approval. // 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') : '';
const prefix = chord.prefix ?? '';
const suffix = chord.suffix ?? '';
return renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : ''); return prefix + renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : '') + suffix;
} }
private getSemitone(key: string): number | undefined { private getSemitone(key: string): number | undefined {