238 lines
7.1 KiB
TypeScript
238 lines
7.1 KiB
TypeScript
import { Injectable } from '@angular/core';
|
|
|
|
export interface ChordSegment {
|
|
text: string;
|
|
chord?: string;
|
|
}
|
|
|
|
export interface ParsedLine {
|
|
segments: ChordSegment[];
|
|
text: string;
|
|
}
|
|
|
|
export interface ParsedSection {
|
|
type: 'verse' | 'chorus' | 'verse_num';
|
|
lines: ParsedLine[];
|
|
verseNumber?: number;
|
|
}
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class LyricsParserService {
|
|
|
|
/**
|
|
* Parse plain text (campo 'testo') into structured sections.
|
|
*/
|
|
parseText(raw: string): ParsedSection[] {
|
|
if (!raw) return [];
|
|
return this.parseSections(raw, false);
|
|
}
|
|
|
|
/**
|
|
* Parse text with inline chords (campo 'accordi') into structured sections.
|
|
*/
|
|
parseAccordi(raw: string): ParsedSection[] {
|
|
if (!raw) return [];
|
|
return this.parseSections(raw, true);
|
|
}
|
|
|
|
private parseSections(raw: string, withChords: boolean): ParsedSection[] {
|
|
const sections: ParsedSection[] = [];
|
|
let currentType: 'verse' | 'chorus' | 'verse_num' = 'verse';
|
|
let currentLines: ParsedLine[] = [];
|
|
let verseNumCounter = 0;
|
|
|
|
const lines = raw.split('\n');
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
|
|
// Detect section start tags
|
|
if (trimmed === '{start_verse}' || trimmed === '{sov}') {
|
|
this.pushSection(sections, currentType, currentLines);
|
|
currentType = 'verse';
|
|
currentLines = [];
|
|
continue;
|
|
}
|
|
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
|
|
this.pushSection(sections, currentType, currentLines);
|
|
currentType = 'chorus';
|
|
currentLines = [];
|
|
continue;
|
|
}
|
|
if (trimmed === '{start_verse_num}') {
|
|
this.pushSection(sections, currentType, currentLines);
|
|
currentType = 'verse_num';
|
|
verseNumCounter++;
|
|
currentLines = [];
|
|
continue;
|
|
}
|
|
|
|
// Detect section end tags — just skip them
|
|
if (trimmed === '{end_verse}' || trimmed === '{eov}' || trimmed === '{end_chorus}' || trimmed === '{eoc}' || trimmed === '{end_verse_num}') {
|
|
this.pushSection(sections, currentType, currentLines, currentType === 'verse_num' ? verseNumCounter : undefined);
|
|
currentLines = [];
|
|
continue;
|
|
}
|
|
|
|
// Skip structural tags (already handled above)
|
|
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
continue;
|
|
}
|
|
|
|
// Skip empty lines
|
|
if (trimmed.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
// Parse line
|
|
if (withChords) {
|
|
currentLines.push(this.parseChordLine(line));
|
|
} else {
|
|
// CLEAN CHORDS in text-only mode: remove [anything]
|
|
const cleanLine = line.replace(/\[[^\]]*\]/g, '').trim();
|
|
if (cleanLine.length > 0) {
|
|
currentLines.push({
|
|
text: cleanLine,
|
|
segments: [{ text: cleanLine }]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Push any remaining lines
|
|
this.pushSection(sections, currentType, currentLines);
|
|
|
|
return sections;
|
|
}
|
|
|
|
private pushSection(sections: ParsedSection[], type: 'verse' | 'chorus' | 'verse_num', lines: ParsedLine[], verseNumber?: number): void {
|
|
if (lines.length > 0) {
|
|
sections.push({ type, lines, verseNumber });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a single line containing inline chord tags.
|
|
* Format: "[RE]Tu sei Re[LA]" → segments with chords positioned above text
|
|
*
|
|
* The chord tag appears BEFORE the text it belongs to:
|
|
* [RE]Tu sei Re → chord "RE" above "Tu sei Re"
|
|
*
|
|
* But a chord can also appear at the END of text:
|
|
* sei Re Gesù[SOL] → text "sei Re Gesù" then chord "SOL" with empty text
|
|
*/
|
|
parseChordLine(line: string): ParsedLine {
|
|
const segments: ChordSegment[] = [];
|
|
// Clean up non-breaking spaces
|
|
const cleaned = line.replace(/\u00a0/g, ' ').trim();
|
|
|
|
// Regex to match [CHORD] tags and text between them
|
|
const chordRegex = /\[([^\]]+)\]/g;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = chordRegex.exec(cleaned)) !== null) {
|
|
// Text before this chord tag
|
|
const textBefore = cleaned.substring(lastIndex, match.index);
|
|
if (textBefore.length > 0) {
|
|
// This text has no chord above it (or belongs to previous chord)
|
|
if (segments.length > 0 && segments[segments.length - 1].text === '') {
|
|
// Previous segment had a chord but no text — attach this text to it
|
|
segments[segments.length - 1].text = textBefore;
|
|
} else {
|
|
segments.push({ text: textBefore });
|
|
}
|
|
}
|
|
|
|
// Add the chord as a new segment (text will be filled by next text chunk)
|
|
segments.push({ chord: match[1], text: '' });
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Remaining text after the last chord
|
|
const remaining = cleaned.substring(lastIndex);
|
|
if (remaining.length > 0) {
|
|
if (segments.length > 0 && segments[segments.length - 1].text === '') {
|
|
segments[segments.length - 1].text = remaining;
|
|
} else {
|
|
segments.push({ text: remaining });
|
|
}
|
|
}
|
|
|
|
// If no chords found, just return plain text
|
|
if (segments.length === 0) {
|
|
segments.push({ text: cleaned });
|
|
}
|
|
|
|
// Build plain text
|
|
const plainText = segments.map(s => s.text).join('');
|
|
|
|
return { segments, text: plainText };
|
|
}
|
|
|
|
private readonly scale = ['DO', 'DO#', 'RE', 'RE#', 'MI', 'FA', 'FA#', 'SOL', 'SOL#', 'LA', 'LA#', 'SI'];
|
|
private readonly flatScale = ['DO', 'REb', 'RE', 'MIb', 'MI', 'FA', 'SOLb', 'SOL', 'LAb', 'LA', 'SIb', 'SI'];
|
|
|
|
/**
|
|
* Transpose a single chord by a given number of semitones.
|
|
* Handles Italian notation.
|
|
*/
|
|
transposeChord(chord: string, semitones: number): string {
|
|
if (!chord || semitones === 0) return chord;
|
|
|
|
// Handle slash chords (e.g., DO/SOL)
|
|
if (chord.includes('/')) {
|
|
return chord.split('/')
|
|
.map(part => this.transposeChord(part.trim(), semitones))
|
|
.join('/');
|
|
}
|
|
|
|
const possibleRoots = [...this.scale, ...this.flatScale].sort((a, b) => b.length - a.length);
|
|
let root = '';
|
|
let suffix = '';
|
|
|
|
for (const r of possibleRoots) {
|
|
if (chord.startsWith(r)) {
|
|
root = r;
|
|
suffix = chord.substring(r.length);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!root) return chord;
|
|
|
|
let index = this.scale.indexOf(root);
|
|
if (index === -1) index = this.flatScale.indexOf(root);
|
|
if (index === -1) return chord;
|
|
|
|
let newIndex = (index + semitones) % 12;
|
|
if (newIndex < 0) newIndex += 12;
|
|
|
|
// Preserve the original notation style (sharp or flat) if possible
|
|
const useFlat = this.flatScale.includes(root);
|
|
const newRoot = useFlat ? this.flatScale[newIndex] : this.scale[newIndex];
|
|
|
|
return newRoot + suffix;
|
|
}
|
|
|
|
/**
|
|
* Transpose all chords in a parsed structure.
|
|
*/
|
|
transposeSections(sections: ParsedSection[], semitones: number): ParsedSection[] {
|
|
if (semitones === 0) return sections;
|
|
|
|
return sections.map(section => ({
|
|
...section,
|
|
lines: section.lines.map(line => ({
|
|
...line,
|
|
segments: line.segments.map(segment => ({
|
|
...segment,
|
|
chord: segment.chord ? this.transposeChord(segment.chord, semitones) : undefined
|
|
}))
|
|
}))
|
|
}));
|
|
}
|
|
}
|