canti primo tag

This commit is contained in:
David Frassi
2026-05-16 17:24:59 +02:00
commit 0c33fc6fcf
106 changed files with 28210 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
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
}))
}))
}));
}
}