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 })) })) })); } }