canti primo tag
This commit is contained in:
@@ -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
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user