optimize transpose service

This commit is contained in:
2026-03-09 17:18:49 +01:00
parent f7e11b792c
commit 4141824b00
5 changed files with 179 additions and 48 deletions

View File

@@ -0,0 +1,15 @@
import {getScale, scaleMapping} from './key.helper';
describe('key.helper', () => {
it('should render Gb correctly', () => {
expect(scaleMapping['Gb']).toBe('G♭');
});
it('should expose a sharp-based scale for D', () => {
expect(getScale('D')).toEqual(['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'H']);
});
it('should keep flat-based spelling for Db', () => {
expect(getScale('Db')).toEqual(['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'B', 'H']);
});
});

View File

@@ -82,7 +82,7 @@ const scaleAssignment: {[key: string]: string[]} = {
C: KEYS_MAJOR_FLAT, C: KEYS_MAJOR_FLAT,
'C#': KEYS_MAJOR_FLAT, 'C#': KEYS_MAJOR_FLAT,
Db: KEYS_MAJOR_B, Db: KEYS_MAJOR_B,
D: KEYS_MAJOR_B, D: KEYS_MAJOR_FLAT,
'D#': KEYS_MAJOR_FLAT, 'D#': KEYS_MAJOR_FLAT,
Eb: KEYS_MAJOR_B, Eb: KEYS_MAJOR_B,
E: KEYS_MAJOR_FLAT, E: KEYS_MAJOR_FLAT,
@@ -125,7 +125,7 @@ export const scaleMapping: {[key: string]: string} = {
E: 'E', E: 'E',
F: 'F', F: 'F',
'F#': 'F♯', 'F#': 'F♯',
Gb: 'D♭', Gb: 'G♭',
G: 'G', G: 'G',
'G#': 'G♯', 'G#': 'G♯',
Ab: 'A♭', Ab: 'A♭',

View File

@@ -1,6 +1,8 @@
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {TransposeService} from './transpose.service'; import {TransposeService} from './transpose.service';
import {LineType} from './line-type';
import {Line} from './line';
describe('TransposeService', () => { describe('TransposeService', () => {
let service: TransposeService; let service: TransposeService;
@@ -12,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', distance); const map = service.getMap('D', 'G', distance);
if (map) { if (map) {
void expect(map['D']).toBe('G'); void expect(map['D']).toBe('G');
@@ -21,10 +23,71 @@ 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', distance); const map = service.getMap('G', 'D', distance);
if (map) { if (map) {
void expect(map['G']).toBe('D'); void expect(map['G']).toBe('D');
} }
}); });
it('should transpose enharmonic targets by semitone distance', () => {
const distance = service.getDistance('C', 'Db');
const map = service.getMap('C', 'Db', distance);
expect(distance).toBe(1);
expect(map?.['C']).toBe('Db');
expect(map?.['G']).toBe('Ab');
});
it('should keep german B/H notation consistent', () => {
const distance = service.getDistance('H', 'C');
const map = service.getMap('H', 'C', distance);
expect(distance).toBe(1);
expect(map?.['H']).toBe('C');
expect(map?.['B']).toBe('C#');
});
it('should render unknown chords as X', () => {
const line: Line = {
type: LineType.chord,
text: '',
chords: [
{chord: 'Q', add: 'sus4', slashChord: null, position: 0, length: 1},
],
};
const rendered = service.renderChords(line);
expect(rendered.text).toBe('Xsus4');
});
it('should render unknown slash chords as X', () => {
const line: Line = {
type: LineType.chord,
text: '',
chords: [
{chord: 'C', add: null, slashChord: 'Q', position: 0, length: 1},
],
};
const rendered = service.renderChords(line);
expect(rendered.text).toBe('C/X');
});
it('should transpose lines with long chord positions without truncating', () => {
const line: Line = {
type: LineType.chord,
text: '',
chords: [
{chord: 'C', add: null, slashChord: null, position: 120, length: 1},
],
};
const rendered = service.renderChords(line);
expect(rendered.text.length).toBe(121);
expect(rendered.text.endsWith('C')).toBeTrue();
});
}); });

View File

@@ -5,16 +5,56 @@ import {Chord} from './chord';
import {Line} from './line'; import {Line} from './line';
type TransposeMap = {[key: string]: string}; type TransposeMap = {[key: string]: string};
type ScaleVariants = [string[], string[]];
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class TransposeService { export class TransposeService {
private readonly keyToSemitone: Record<string, number> = {
C: 0,
'C#': 1,
Db: 1,
D: 2,
'D#': 3,
Eb: 3,
E: 4,
F: 5,
'F#': 6,
Gb: 6,
G: 7,
'G#': 8,
Ab: 8,
A: 9,
'A#': 10,
B: 10,
H: 11,
c: 0,
'c#': 1,
db: 1,
d: 2,
'd#': 3,
eb: 3,
e: 4,
f: 5,
'f#': 6,
gb: 6,
g: 7,
'g#': 8,
ab: 8,
a: 9,
'a#': 10,
b: 10,
h: 11,
};
private readonly mapCache = new Map<string, TransposeMap>();
public transpose(line: Line, baseKey: string, targetKey: string): Line { public transpose(line: Line, baseKey: string, targetKey: string): Line {
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, difference); const map = this.getMap(baseKey, targetKey, difference);
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);
@@ -32,50 +72,47 @@ export class TransposeService {
} }
public getDistance(baseKey: string, targetKey: string): number { public getDistance(baseKey: string, targetKey: string): number {
const scale = getScaleType(baseKey); const baseSemitone = this.keyToSemitone[baseKey];
if (!scale) { const targetSemitone = this.keyToSemitone[targetKey];
if (baseSemitone === undefined || targetSemitone === undefined) {
return 0; return 0;
} }
const primaryBaseIndex = scale[0].indexOf(baseKey); return (targetSemitone - baseSemitone + 12) % 12;
const primaryTargetIndex = scale[0].indexOf(targetKey);
if (primaryBaseIndex !== -1 && primaryTargetIndex !== -1) {
return (primaryTargetIndex - primaryBaseIndex) % 12;
} }
const secondaryBaseIndex = scale[1].indexOf(baseKey); public getMap(baseKey: string, targetKey: string, difference: number): TransposeMap | null {
const secondaryTargetIndex = scale[1].indexOf(targetKey); const cacheKey = `${baseKey}:${targetKey}:${difference}`;
const cachedMap = this.mapCache.get(cacheKey);
if (secondaryBaseIndex !== -1 && secondaryTargetIndex !== -1) { if (cachedMap) {
return (secondaryTargetIndex - secondaryBaseIndex) % 12; return cachedMap;
} }
return 0; const sourceScales = this.getScaleVariants(baseKey);
} const targetScales = this.getScaleVariants(targetKey);
if (!sourceScales || !targetScales) {
public getMap(baseKey: string, difference: number): TransposeMap | null {
const scale = getScaleType(baseKey);
if (!scale) {
return null; return null;
} }
const map: {[key: string]: string} = {};
const map: TransposeMap = {};
sourceScales.forEach((sourceScale, scaleIndex) => {
const targetScale = targetScales[scaleIndex];
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
const source = scale[0][i]; const source = sourceScale[i];
const mappedIndex = (i + difference + 12) % 12; const mappedIndex = (i + difference + 12) % 12;
map[source] = scale[0][mappedIndex]; map[source] = targetScale[mappedIndex];
}
for (let i = 0; i < 12; i++) {
const source = scale[1][i];
const mappedIndex = (i + difference + 12) % 12;
map[source] = scale[1][mappedIndex];
} }
});
this.mapCache.set(cacheKey, map);
return map; return map;
} }
private transposeChord(chord: Chord, map: TransposeMap): Chord { private transposeChord(chord: Chord, map: TransposeMap): Chord {
const translatedChord = map[chord.chord]; const translatedChord = map[chord.chord] ?? 'X';
const translatedSlashChord = chord.slashChord ? map[chord.slashChord] : null; const translatedSlashChord = chord.slashChord ? map[chord.slashChord] ?? 'X' : null;
return { return {
...chord, ...chord,
chord: translatedChord, chord: translatedChord,
@@ -84,23 +121,39 @@ export class TransposeService {
} }
private renderLine(chords: Chord[]): string { private renderLine(chords: Chord[]): string {
let template = ' '; const width = chords.reduce((max, chord) => {
return Math.max(max, chord.position + this.renderChord(chord).length);
}, 0);
let template = ''.padEnd(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 newLength = renderedChord.length;
const pre = template.substr(0, pos); if (template.length < pos + newLength) {
const post = template.substr(pos + newLength); template = template.padEnd(pos + newLength, ' ');
}
const pre = template.slice(0, pos);
const post = template.slice(pos + newLength);
template = pre + renderedChord + post; template = pre + renderedChord + post;
}); });
return template.trimRight(); return template.trimEnd();
} }
private renderChord(chord: Chord) { private renderChord(chord: Chord): string {
return scaleMapping[chord.chord] + (chord.add ? chord.add : '') + (chord.slashChord ? '/' + scaleMapping[chord.slashChord] : ''); const renderedChord = scaleMapping[chord.chord] ?? 'X';
const renderedSlashChord = chord.slashChord ? scaleMapping[chord.slashChord] ?? 'X' : '';
return renderedChord + (chord.add ?? '') + (renderedSlashChord ? '/' + renderedSlashChord : '');
}
private getScaleVariants(key: string): ScaleVariants | null {
const scales = getScaleType(key);
return scales ? [scales[0], scales[1]] : null;
} }
} }

View File

@@ -5,10 +5,10 @@ describe('Filter Helper', () => {
const song: Song = { const song: Song = {
title: 'Song Title', title: 'Song Title',
text: "This is a songtext, aa?bb!cc,dd.ee'ff", text: "This is a songtext, aa?bb!cc,dd.ee'ff",
legalOwner: '', legalOwner: 'other',
label: '', label: '',
id: '', id: '',
legalType: '', legalType: 'open',
artist: '', artist: '',
comment: '', comment: '',
edits: [], edits: [],
@@ -18,9 +18,9 @@ describe('Filter Helper', () => {
number: 1, number: 1,
legalOwnerId: '', legalOwnerId: '',
origin: '', origin: '',
status: '', status: 'draft',
tempo: 10, tempo: 10,
type: '', type: 'Misc',
termsOfUse: '', termsOfUse: '',
}; };