Files
canti/src/app/pages/propose-canto/propose-canto.page.ts
T

1765 lines
62 KiB
TypeScript

import { Component, OnInit, OnDestroy, ViewChild, ElementRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule, ToastController, IonTextarea, PopoverController, NavController, AlertController } from '@ionic/angular';
import { createWorker } from 'tesseract.js';
import { CantiService } from '../../services/canti.service';
import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule, Router } from '@angular/router';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
import { YoutubePlayerService } from '../../services/youtube-player.service';
@Component({
selector: 'app-propose-canto',
templateUrl: './propose-canto.page.html',
styleUrls: ['./propose-canto.page.scss'],
standalone: true,
imports: [CommonModule, FormsModule, IonicModule, RouterModule]
})
export class ProposeCantoPage implements OnInit, OnDestroy {
@ViewChild('nativeTextarea', { static: false }) nativeTextarea!: ElementRef<HTMLTextAreaElement>;
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
@ViewChild('fileInput', { static: false }) fileInput!: ElementRef;
public cantiService = inject(CantiService);
private myCantiService = inject(MyCantiService);
private playlistService = inject(PlaylistService);
private navCtrl = inject(NavController);
public themeService = inject(ThemeService);
private route = inject(ActivatedRoute);
private router = inject(Router);
public lyricsParser = inject(LyricsParserService);
private alertCtrl = inject(AlertController);
public youtubePlayerService = inject(YoutubePlayerService);
showChordsPreview: boolean = true;
activeTab: string = 'editor';
transposeAmount: number = 0;
get highlightedHtml(): string {
if (!this.content) return '';
let escaped = this.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
escaped = escaped.replace(/\[([^\]]+)\]/g, (match, chord) => {
const transposed = this.transposeAmount !== 0
? this.lyricsParser.transposeChord(chord, this.transposeAmount)
: chord;
return `<span class="editor-chord">[${transposed}]</span>`;
});
const lines = escaped.split('\n');
let htmlLines: string[] = [];
let inChorus = false;
let inVerse = false;
let inVerseNum = false;
for (const line of lines) {
const trimmed = line.trim();
let currentInChorus = inChorus;
let currentInVerse = inVerse;
let currentInVerseNum = inVerseNum;
let isTagLine = false;
let processedContent = line;
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
inChorus = true;
currentInChorus = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_chorus}' || trimmed === '{eoc}') {
inChorus = false;
currentInChorus = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{start_verse}' || trimmed === '{sov}') {
inVerse = true;
currentInVerse = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_verse}' || trimmed === '{eov}') {
inVerse = false;
currentInVerse = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{start_verse_num}') {
inVerseNum = true;
currentInVerseNum = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed === '{end_verse_num}') {
inVerseNum = false;
currentInVerseNum = true;
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
} else if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
isTagLine = true;
processedContent = `<span class="editor-tag">${line}</span>`;
}
let classes = ['editor-line'];
if (currentInChorus) classes.push('chorus');
if (currentInVerse) classes.push('verse');
if (currentInVerseNum) classes.push('verse-num');
if (isTagLine) classes.push('tag-line');
const finalContent = processedContent || '&#8203;';
htmlLines.push(`<div class="${classes.join(' ')}">${finalContent}</div>`);
}
return htmlLines.join('\n');
}
get parsedSections(): ParsedSection[] {
const sections = this.lyricsParser.parseAccordi(this.content);
if (this.showChordsPreview && this.transposeAmount !== 0) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount);
}
return sections;
}
transposeUp() {
this.transposeAmount = (this.transposeAmount + 1) > 12 ? -11 : this.transposeAmount + 1;
}
transposeDown() {
this.transposeAmount = (this.transposeAmount - 1) < -12 ? 11 : this.transposeAmount - 1;
}
setDefaultTonalita() {
if (this.transposeAmount === 0 || !this.content) return;
const chordRegex = /\[([^\]]+)\]/g;
this.content = this.content.replace(chordRegex, (match, chord) => {
const transposed = this.lyricsParser.transposeChord(chord, this.transposeAmount);
return `[${transposed}]`;
});
this.transposeAmount = 0;
}
isAudioLoaded(): boolean {
return !!this.youtubeLink && this.cantiService.getYoutubeId(this.youtubeLink) !== null;
}
loadedYoutubeLink: string = '';
loadEditorAudio() {
if (!this.youtubeLink) return;
const videoId = this.cantiService.getYoutubeId(this.youtubeLink);
if (videoId) {
this.youtubePlayerService.initPlayer(this.editId || 'editing_song', 0, undefined, undefined, this.youtubeLink);
this.loadedYoutubeLink = this.youtubeLink;
}
}
togglePlayPause() {
const currentId = this.editId || 'editing_song';
const isLoaded = this.youtubePlayerService.currentCantoId() === currentId && this.loadedYoutubeLink === this.youtubeLink;
if (!isLoaded && this.youtubeLink) {
this.loadEditorAudio();
} else {
this.youtubePlayerService.togglePlayPause();
}
}
onSeek(event: any) {
const value = event.detail.value;
this.youtubePlayerService.seekTo(value);
}
formatSeconds(seconds: number): string {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
}
toggleChordsPreview() {
this.showChordsPreview = !this.showChordsPreview;
}
title: string = '';
author: string = '';
youtubeLink: string = '';
selectedLiturgico: number[] = [];
selectedTematico: number[] = [];
durata: string = '';
bpm: number | null = null;
get sortedLiturgico() {
return [...this.cantiService.indiceLiturgico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name));
}
get sortedTematico() {
return [...this.cantiService.indiceTematico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name));
}
editId: string | null = null;
private _content: string = '';
get content(): string { return this._content; }
set content(val: string) {
if (this._content !== val) {
this.saveToUndoStack(this._content);
this._content = val;
}
}
undoStack: string[] = [];
isProcessingOCR: boolean = false;
ocrProgress: number = 0;
isDraggingOver: boolean = false;
get isHighContrast(): boolean { return this.themeService.highContrast(); }
groupedChords = [
{
root: 'DO',
chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DOmaj7', 'DO4', 'DOdim', 'DOm7']
},
{
root: 'RE',
chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7']
},
{
root: 'MI',
chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MImaj7', 'MI4', 'MIdim']
},
{
root: 'FA',
chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FAmaj7', 'FA4', 'FAdim', 'FAm7']
},
{
root: 'SOL',
chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOLmaj7', 'SOL4', 'SOLdim', 'SOLm7']
},
{
root: 'LA',
chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LAmaj7', 'LA4', 'LAdim', 'LAm7']
},
{
root: 'SI',
chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SImaj7', 'SI4', 'SIdim']
}
];
selectedRootChord: string | null = null;
selectRoot(root: string) {
if (this.selectedRootChord === root) {
this.selectedRootChord = null;
} else {
this.selectedRootChord = root;
}
}
getVariations(): string[] {
if (!this.selectedRootChord) return [];
const group = this.groupedChords.find(g => g.root === this.selectedRootChord);
return group ? group.chords : [];
}
commonTags = [
{ label: 'Ritornello', start: '{start_chorus}', end: '{end_chorus}' },
{ label: 'Strofa', start: '{start_verse}', end: '{end_verse}' },
{ label: 'Strofa Num.', start: '{start_verse_num}', end: '{end_verse_num}' },
{ label: 'Inciso', start: '{start_bridge}', end: '{end_bridge}' },
{ label: 'Intro Accordi', start: '{start_chord}', end: '{end_chord}' },
{ label: 'Commento', start: '{c: ', end: '}' },
{ label: 'ChordPro Strofa', start: '{sov}', end: '{eov}' },
{ label: 'ChordPro Rit.', start: '{soc}', end: '{eoc}' }
];
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
ngOnInit() {
this.route.queryParams.subscribe(params => {
const editId = params['editId'];
if (editId) {
this.editId = editId;
// Find the song in standard canti, personal canti, or remote custom canti list
const song = [
...this.cantiService.canti(),
...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs(),
...this.playlistService.remoteShareCanti()
].find(c => c.id === editId);
if (song) {
this.title = song.titolo;
this.author = song.autore || '';
this.youtubeLink = song.link_youtube || '';
this.content = song.accordi || song.testo || '';
this.durata = song.durata || '';
this.bpm = song.bpm !== undefined ? song.bpm : null;
// Pre-populate liturgico and tematico lists
const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
const temIds = this.cantiService.indiceTematico().map(m => m.id);
this.selectedLiturgico = song.id_momenti?.filter((id: number) => litIds.includes(id)) || [];
this.selectedTematico = song.id_momenti?.filter((id: number) => temIds.includes(id)) || [];
}
}
});
}
ngOnDestroy() {
this.youtubePlayerService.stop();
}
async insertText(tag: string) {
const input = this.nativeTextarea.nativeElement;
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
this.content = this.content.substring(0, start) + tag + this.content.substring(end);
setTimeout(() => {
input.focus();
input.setSelectionRange(start + tag.length, start + tag.length);
}, 10);
}
insertChord(chord: string) {
this.insertText(`[${chord}]`);
}
undo() {
if (this.undoStack.length > 0) {
const previous = this.undoStack.pop();
if (previous !== undefined) {
this._content = previous;
}
}
}
private saveToUndoStack(val: string) {
if (this.undoStack.length >= 30) {
this.undoStack.shift();
}
this.undoStack.push(val);
}
takePhoto() {
this.cameraInput.nativeElement.click();
}
chooseFile() {
this.fileInput.nativeElement.click();
}
async deduceChords() {
if (!this.content) return;
// Salva nello stack degli undo
this.saveToUndoStack(this.content);
const lines = this.content.split('\n');
interface SectionLine {
originalIndex: number;
text: string;
}
interface Section {
type: 'verse' | 'chorus' | 'other';
startTag: string | null;
endTag: string | null;
lines: SectionLine[];
hasChords: boolean;
}
const sections: Section[] = [];
let currentSection: Section = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false };
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
const startMatch = trimmed.match(/^\{(start_verse|start_chorus|start_verse_num|sov|soc)\}/);
const endMatch = trimmed === '{end_verse}' || trimmed === '{eov}' || trimmed === '{end_chorus}' || trimmed === '{eoc}' || trimmed === '{end_verse_num}';
if (startMatch) {
if (currentSection.lines.length > 0 || currentSection.startTag) {
sections.push(currentSection);
}
let type: 'verse' | 'chorus' | 'other' = 'other';
const tag = startMatch[1];
if (tag === 'start_verse' || tag === 'start_verse_num' || tag === 'sov') {
type = 'verse';
} else if (tag === 'start_chorus' || tag === 'soc') {
type = 'chorus';
}
currentSection = { type, startTag: line, endTag: null, lines: [], hasChords: false };
} else if (endMatch) {
currentSection.endTag = line;
sections.push(currentSection);
currentSection = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false };
} else {
if (trimmed === '' && !currentSection.startTag) {
if (currentSection.lines.length > 0) {
sections.push(currentSection);
}
sections.push({ type: 'other', startTag: null, endTag: null, lines: [{ originalIndex: i, text: '' }], hasChords: false });
currentSection = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false };
} else {
const hasChords = /\[[^\]]+\]/.test(line);
if (hasChords) {
currentSection.hasChords = true;
}
if (currentSection.lines.length === 0 && !currentSection.startTag && trimmed !== '') {
currentSection.type = 'verse';
}
const isComment = trimmed.startsWith('{c:') || trimmed.startsWith('{comment:');
if (!isComment) {
currentSection.lines.push({ originalIndex: i, text: line });
}
}
}
}
if (currentSection.lines.length > 0 || currentSection.startTag) {
sections.push(currentSection);
}
const templateVerse = sections.find(s => s.type === 'verse' && s.hasChords);
const templateChorus = sections.find(s => s.type === 'chorus' && s.hasChords);
let deducedCount = 0;
const getWords = (text: string) => {
const words: { text: string; start: number; end: number }[] = [];
const regex = /\S+/g;
let match;
while ((match = regex.exec(text)) !== null) {
words.push({
text: match[0],
start: match.index,
end: match.index + match[0].length
});
}
return words;
};
const alignChords = (templateLine: string, targetLine: string): string => {
if (/\[[^\]]+\]/.test(targetLine)) {
return targetLine;
}
const parsedTemplate = this.lyricsParser.parseChordLine(templateLine);
const templateClean = parsedTemplate.text;
const targetClean = targetLine.replace(/\[[^\]]*\]/g, '');
if (!templateClean.trim() || !targetClean.trim()) {
return targetLine;
}
const chords: { chord: string; charIndex: number }[] = [];
let charAcc = 0;
parsedTemplate.segments.forEach(seg => {
if (seg.chord) {
chords.push({ chord: seg.chord, charIndex: charAcc });
}
charAcc += seg.text.length;
});
if (chords.length === 0) {
return targetLine;
}
const templateWords = getWords(templateClean);
const targetWords = getWords(targetClean);
const insertions: { chord: string; index: number }[] = [];
chords.forEach(c => {
const ratio = c.charIndex / Math.max(1, templateClean.length);
const wordIdx = templateWords.findIndex(w => Math.abs(w.start - c.charIndex) <= 1);
let targetIndex = 0;
if (wordIdx !== -1 && templateWords.length > 1 && targetWords.length > 1) {
const wRatio = wordIdx / (templateWords.length - 1);
const targetWIdx = Math.round(wRatio * (targetWords.length - 1));
targetIndex = targetWords[targetWIdx].start;
} else {
targetIndex = Math.round(ratio * targetClean.length);
}
insertions.push({ chord: c.chord, index: targetIndex });
});
insertions.sort((a, b) => b.index - a.index);
let result = targetClean;
insertions.forEach(ins => {
result = result.substring(0, ins.index) + `[${ins.chord}]` + result.substring(ins.index);
});
return result;
};
const newLines = [...lines];
sections.forEach(sec => {
if (!sec.hasChords && sec.type !== 'other') {
const template = sec.type === 'verse' ? templateVerse : templateChorus;
if (template) {
for (let j = 0; j < sec.lines.length; j++) {
const targetSecLine = sec.lines[j];
if (j < template.lines.length) {
const templateSecLine = template.lines[j];
const aligned = alignChords(templateSecLine.text, targetSecLine.text);
if (aligned !== targetSecLine.text) {
newLines[targetSecLine.originalIndex] = aligned;
deducedCount++;
}
}
}
}
}
});
if (deducedCount > 0) {
this.content = newLines.join('\n');
const toast = await this.toastController.create({
message: `Accordi dedotti con successo in ${deducedCount} righe!`,
duration: 3000,
color: 'success'
});
toast.present();
} else {
const toast = await this.toastController.create({
message: 'Nessuna strofa compatibile trovata o accordi già presenti.',
duration: 3000,
color: 'warning'
});
toast.present();
}
}
onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Mostra l'overlay solo se si sta trascinando un file
if (event.dataTransfer?.types.includes('Files')) {
this.isDraggingOver = true;
}
}
onDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
}
async onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDraggingOver = false;
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files[0];
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
if (file.type.indexOf('image') !== -1 || isPdf) {
await this.processFile(file);
} else {
const toast = await this.toastController.create({
message: 'Per favore, trascina un file immagine o PDF valido.',
duration: 3000,
color: 'warning'
});
toast.present();
}
}
}
async onFileSelected(event: any, isCamera: boolean) {
const file = event.target.files[0];
if (!file) {
console.log('[OCR-Capture] Nessun file selezionato.');
return;
}
await this.processFile(file);
event.target.value = '';
}
async onPaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
let hasImage = false;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
hasImage = true;
event.preventDefault(); // Prevent pasting the image representation as text
const blob = items[i].getAsFile();
if (blob) {
const file = new File([blob], 'pasted-image.png', { type: blob.type });
await this.processFile(file);
}
break;
}
}
if (!hasImage) {
const pastedText = event.clipboardData?.getData('text');
if (pastedText && this.looksLikeChordSheetOrSong(pastedText)) {
event.preventDefault();
const parsed = this.parsePastedChordSheet(pastedText);
this.insertText(parsed);
}
}
}
isTextLineChords(line: string, isItalian: boolean): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
const tokens = trimmed.split(/\s+/);
let chordCount = 0;
let nonChordWordCount = 0;
for (const token of tokens) {
let clean = token.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (this.isChordWord(clean, isItalian)) {
chordCount++;
} else {
if (clean.length > 4) {
nonChordWordCount++;
}
}
}
if (tokens.length === 0) return false;
const ratio = chordCount / tokens.length;
return ratio >= 0.6 && nonChordWordCount === 0;
}
getSectionType(line: string): 'chorus' | 'verse' | 'intro' | 'none' {
const trimmed = line.trim();
if (/^(ritornello|rit|chorus|refrain|coro)/i.test(trimmed)) {
return 'chorus';
}
if (/^(verso|strofa|verse|strophe|\d+(\.)?)/i.test(trimmed)) {
return 'verse';
}
if (/^(intro|introduzione|bridge|special|outro|strum)/i.test(trimmed)) {
return 'intro';
}
return 'none';
}
looksLikeChordSheetOrSong(text: string): boolean {
const lines = text.split('\n');
if (lines.length < 2) return false;
const words = text.split(/\s+/).map(t => ({ text: t }));
const isItalian = this.isItalianNotation(words);
let chordLinesCount = 0;
let sectionMarkersCount = 0;
for (const line of lines) {
if (this.getSectionType(line) !== 'none') {
sectionMarkersCount++;
}
if (this.isTextLineChords(line, isItalian)) {
chordLinesCount++;
}
}
return chordLinesCount > 0 || sectionMarkersCount > 0;
}
convertPureChordLine(line: string, isItalian: boolean): string {
return line.replace(/\S+/g, (match) => {
let clean = match.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (this.isChordWord(clean, isItalian)) {
return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`;
}
return match;
});
}
parsePastedChordSheet(text: string): string {
const lines = text.split('\n');
const processedLines: string[] = [];
const words = text.split(/\s+/).map(t => ({ text: t }));
const isItalian = this.isItalianNotation(words);
let inVerse = false;
let inChorus = false;
let hasAccumulatedLines = false;
const closeSection = () => {
if (inChorus) {
processedLines.push('{end_chorus}');
inChorus = false;
}
if (inVerse) {
processedLines.push('{end_verse}');
inVerse = false;
}
hasAccumulatedLines = false;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
if (hasAccumulatedLines) {
closeSection();
processedLines.push('');
}
continue;
}
const sectionType = this.getSectionType(line);
if (sectionType !== 'none') {
closeSection();
if (sectionType === 'chorus') {
processedLines.push('{start_chorus}');
inChorus = true;
} else {
processedLines.push('{start_verse}');
inVerse = true;
}
continue;
}
// If no section is open, open a default one (verse)
if (!inChorus && !inVerse) {
processedLines.push('{start_verse}');
inVerse = true;
}
// Check if this line is chords
const isChords = this.isTextLineChords(line, isItalian);
if (isChords) {
// Look ahead to see if the next line is lyrics (not empty, not chords, not section marker)
let nextLine = '';
let nextLineIndex = i + 1;
while (nextLineIndex < lines.length) {
const nextTrimmed = lines[nextLineIndex].trim();
if (nextTrimmed) {
if (this.getSectionType(lines[nextLineIndex]) === 'none' && !this.isTextLineChords(lines[nextLineIndex], isItalian)) {
nextLine = lines[nextLineIndex];
}
break;
}
nextLineIndex++;
}
if (nextLine) {
// Merge spatially!
const chords: { text: string; index: number }[] = [];
const regex = /\S+/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
let clean = match[0].toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (this.isChordWord(clean, isItalian)) {
chords.push({
text: `[${this.convertEnglishChordToItalian(clean, isItalian)}]`,
index: match.index
});
} else {
chords.push({
text: match[0],
index: match.index
});
}
}
// Merge chords with the next line (lyrics)
let merged = '';
let lyricIdx = 0;
let chordIdx = 0;
while (lyricIdx < nextLine.length || chordIdx < chords.length) {
if (chordIdx < chords.length && (lyricIdx === chords[chordIdx].index || lyricIdx >= nextLine.length)) {
merged += chords[chordIdx].text;
chordIdx++;
} else {
merged += nextLine[lyricIdx];
lyricIdx++;
}
}
processedLines.push(merged);
hasAccumulatedLines = true;
// Skip the next line since we consumed it
i = nextLineIndex;
} else {
// No lyrics line follows, just convert chords in place
processedLines.push(this.convertPureChordLine(line, isItalian));
hasAccumulatedLines = true;
}
} else {
// Plain text line
processedLines.push(this.wrapChords(line, this.getChordRegex(isItalian), isItalian));
hasAccumulatedLines = true;
}
}
closeSection();
return processedLines.join('\n');
}
async convertPdfToImages(file: File): Promise<Blob[]> {
console.log('[OCR-Capture] Caricamento PDF per conversione in immagini...');
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'assets/pdf.worker.min.js';
const arrayBuffer = await file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
console.log(`[OCR-Capture] PDF caricato con successo. Numero di pagine: ${pdf.numPages}`);
const blobs: Blob[] = [];
// Limit to maximum 5 pages to avoid extreme memory consumption or timeouts
const pagesToRender = Math.min(pdf.numPages, 5);
for (let i = 1; i <= pagesToRender; i++) {
console.log(`[OCR-Capture] Rendering pagina ${i}/${pagesToRender}...`);
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 2.0 }); // High resolution scale for OCR
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Impossibile ottenere il context 2D per il canvas.');
}
await page.render({
canvasContext: ctx,
viewport: viewport,
canvas: canvas
}).promise;
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(b => resolve(b), 'image/jpeg', 0.95);
});
if (blob) {
blobs.push(blob);
}
}
return blobs;
}
async processFile(file: File) {
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB, è PDF: ${isPdf}`);
this.isProcessingOCR = true;
this.ocrProgress = 0;
try {
if (isPdf) {
const pageBlobs = await this.convertPdfToImages(file);
console.log(`[OCR-Capture] PDF convertito in ${pageBlobs.length} immagini.`);
for (let idx = 0; idx < pageBlobs.length; idx++) {
const pageBlob = pageBlobs[idx];
const pageFile = new File([pageBlob], `page_${idx + 1}.jpg`, { type: 'image/jpeg' });
console.log(`[OCR-Capture] Elaborazione pagina ${idx + 1}/${pageBlobs.length} via OCR...`);
this.ocrProgress = (idx / pageBlobs.length);
const extractedText = await this.processImageOCR(pageFile);
if (extractedText) {
console.log(`[OCR-Capture] Pagina ${idx + 1} estratta con successo.`);
this.content += (this.content ? '\n\n' : '') + extractedText;
}
}
const toast = await this.toastController.create({
message: 'Scansione PDF completata!',
duration: 2000,
color: 'success'
});
toast.present();
} else {
console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...');
const compressedBlob = await this.resizeImage(file);
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' });
console.log(`[OCR-Capture] Dimensioni dopo compressione: ${(compressedFile.size / 1024).toFixed(1)} KB`);
console.log('[OCR-Capture] Avvio motore Tesseract OCR local...');
const extractedText = await this.processImageOCR(compressedFile);
if (extractedText) {
console.log('[OCR-Capture] Testo estratto con successo! Inserimento nell\'editor...');
this.content += (this.content ? '\n\n' : '') + extractedText;
const toast = await this.toastController.create({
message: 'Scansione completata!',
duration: 2000,
color: 'success'
});
toast.present();
} else {
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
}
}
} catch (error) {
console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error);
const errorToast = await this.toastController.create({
message: 'Errore durante la scansione del file.',
duration: 3000,
color: 'danger'
});
errorToast.present();
} finally {
this.isProcessingOCR = false;
this.ocrProgress = 0;
}
}
async resizeImage(file: File): Promise<Blob> {
console.log('[OCR-Capture] Caricamento immagine in memoria...');
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
console.log(`[OCR-Capture] Immagine caricata in memoria. Dimensioni originali: ${img.width}x${img.height}`);
const canvas = document.createElement('canvas');
const maxDim = 1200;
let width = img.width;
let height = img.height;
if (width > maxDim || height > maxDim) {
if (width > height) {
height = Math.round((height * maxDim) / width);
width = maxDim;
} else {
width = Math.round((width * maxDim) / height);
height = maxDim;
}
}
console.log(`[OCR-Capture] Ridimensionamento a: ${width}x${height}`);
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(blob => {
if (blob) {
console.log('[OCR-Capture] Compressione in JPEG completata con successo.');
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed'));
}
}, 'image/jpeg', 0.85);
} else {
reject(new Error('Canvas getContext 2d failed'));
}
};
img.onerror = (err) => {
console.error('[OCR-Capture] Impossibile caricare l\'immagine in memoria:', err);
reject(err);
};
img.src = URL.createObjectURL(file);
});
}
async processImageOCR(file: File): Promise<string> {
console.log('[OCR-Capture] Inizializzazione Worker Tesseract.js...');
const worker = await createWorker('ita', 1, {
logger: m => {
if (m.status === 'recognizing text') {
this.ocrProgress = m.progress;
console.log(`[OCR-Capture] Progresso OCR: ${(m.progress * 100).toFixed(0)}%`);
}
}
});
console.log('[OCR-Capture] Avvio riconoscimento caratteri (OCR) con blocks abilitato...');
const { data } = await worker.recognize(file, {}, { blocks: true });
console.log('[OCR-Capture] Riconoscimento caratteri terminato. Spegnimento worker...');
await worker.terminate();
console.log('[OCR-Capture] Spegnimento worker completato. Estrazione parole...');
// Flatten blocks hierarchy to get a flat words list
const words: any[] = [];
if (data && (data as any).blocks) {
const blocks = (data as any).blocks;
blocks.forEach((block: any) => {
if (block.paragraphs) {
block.paragraphs.forEach((paragraph: any) => {
if (paragraph.lines) {
paragraph.lines.forEach((line: any) => {
if (line.words) {
line.words.forEach((word: any) => {
words.push(word);
});
}
});
}
});
}
});
}
console.log(`[OCR-Capture] Parole estratte dal blocco gerarchico: ${words.length}`);
return this.parseSongSpatially(words);
}
sanitizeOcrChord(text: string): string {
if (!text) return text;
// First, heal separators like I, 1, l, |, \ between chords/notes to '/'
const separatorRegex = /\b([A-G]|DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?(m|min|maj|dim|aug|sus)?(\d*)([I1l|\\\/])([A-G]|DO|RE|MI|FA|SOL|LA|SI|\d+)(#|b|♭)?\b/gi;
text = text.replace(separatorRegex, (match, p1, p2, p3, p4, sep, p5, p6) => {
return `${p1}${p2 || ''}${p3 || ''}${p4 || ''}/${p5}${p6 || ''}`;
});
if (text.includes('/')) {
return text.split('/').map(part => this.sanitizeOcrChord(part.trim())).join('/');
}
let cleaned = text;
// Replace D0/d0 with DO/do
cleaned = cleaned.replace(/^D0/gi, 'DO');
// Replace S0L/s0l with SOL/sol
cleaned = cleaned.replace(/^S0L/gi, 'SOL');
// Clean H/sharp mismatches:
cleaned = cleaned.replace(/H#/gi, '#');
cleaned = cleaned.replace(/#H/gi, '#');
cleaned = cleaned.replace(/([CDEFGAB]|DO|RE|MI|FA|SOL|LA|SI)H/gi, '$1#');
// Clean duplicate sharps (e.g. ## -> #)
cleaned = cleaned.replace(/##+/g, '#');
// Clean duplicate chord letters at start (e.g. Ff#m -> F#m)
cleaned = cleaned.replace(/^([CDEFGAB])\1/gi, '$1');
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm'
cleaned = cleaned.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
return p1 + (p2 || '') + 'm' + (p3 || '');
});
return cleaned;
}
getChordRegex(isItalian: boolean): RegExp {
if (isItalian) {
return /^(DO|RE|MI|FA|SOL|LA|SI)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|\d+)(#|B|b)?)?$/i;
} else {
return /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i;
}
}
getMultiChordRegex(isItalian: boolean): RegExp {
if (isItalian) {
return /((?:DO|RE|MI|FA|SOL|LA|SI)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-|4|5|6)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|\d+)(?:#|B|b)?)?)/gi;
} else {
return /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-|4|5|6)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(?:#|B|b)?)?)/gi;
}
}
isItalianNotation(words: any[]): boolean {
if (!words || words.length === 0) return false;
// 1. Group words into horizontal lines
const heights = words.map(w => w.bbox ? (w.bbox.y1 - w.bbox.y0) : 10);
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
const verticalTolerance = avgHeight * 1.0;
const lines: any[][] = [];
words.forEach(word => {
if (!word.text) return;
const wordYCenter = word.bbox ? ((word.bbox.y0 + word.bbox.y1) / 2) : 0;
let added = false;
for (const line of lines) {
const avgLineYCenter = line.reduce((sum, w) => sum + (w.bbox ? ((w.bbox.y0 + w.bbox.y1) / 2) : 0), 0) / line.length;
if (Math.abs(wordYCenter - avgLineYCenter) < verticalTolerance) {
line.push(word);
added = true;
break;
}
}
if (!added) {
lines.push([word]);
}
});
const genericChordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i;
let englishChordsCount = 0;
let italianChordsCount = 0;
lines.forEach(line => {
let chordCount = 0;
line.forEach(w => {
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (genericChordRegex.test(clean)) {
chordCount++;
}
});
const ratio = line.length > 0 ? chordCount / line.length : 0;
if (ratio >= 0.5) {
line.forEach(w => {
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
const isEnglish = /^(C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i.test(clean);
const isItalian = /^(DO|RE|MI|FA|SOL|LA|SI)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|\d+)(#|B|b)?)?$/i.test(clean);
if (isEnglish && !isItalian) {
englishChordsCount++;
} else if (isItalian && !isEnglish) {
italianChordsCount++;
}
});
}
});
if (englishChordsCount === 0 && italianChordsCount === 0) {
// Fallback: search the entire words list for unambiguous chords
words.forEach(w => {
if (!w.text) return;
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
const isUnambiguousEnglish =
/^(C|D|G|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i.test(clean) ||
/^(A|E|F)(#|B|M|-|MIN|MAJ|AUG|DIM|4|5|6|7|9|11|13)/i.test(clean);
const isUnambiguousItalian =
/^(DO|RE|MI|FA|SOL|LA|SI)(#|B|M|-|MIN|MAJ|AUG|DIM|4|5|6|7|9|11|13)/i.test(clean);
if (isUnambiguousEnglish) englishChordsCount++;
if (isUnambiguousItalian) italianChordsCount++;
});
}
console.log(`[OCR-Capture] Chord line analysis - English chords: ${englishChordsCount}, Italian chords: ${italianChordsCount}`);
return italianChordsCount > englishChordsCount;
}
isChordWord(text: string, isItalian: boolean, allowLowercase: boolean = false): boolean {
if (!text || text.length === 0) return false;
const lower = text.toLowerCase();
// Skip common valid words written purely in lowercase unless allowLowercase is true
if (!allowLowercase && text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
return false;
}
let clean = text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
const chordRegex = this.getChordRegex(isItalian);
if (clean.includes('_')) {
const parts = clean.split('_').filter(p => p.length > 0);
if (parts.length === 0) return false;
return parts.every(p => chordRegex.test(p));
}
return chordRegex.test(clean);
}
convertEnglishChordToItalian(chord: string, isItalian: boolean = false): string {
if (isItalian) return chord;
if (!chord) return chord;
if (chord.includes('/')) {
return chord.split('/').map(part => this.convertEnglishChordToItalian(part.trim(), isItalian)).join('/');
}
const upper = chord.toUpperCase();
if (upper.startsWith('DO')) {
return chord;
}
if (upper.startsWith('FA')) {
if (upper.startsWith('FAUG') || upper.startsWith('FADD') || upper.startsWith('FALT')) {
return 'FA' + chord.slice(1);
}
return chord;
}
if (upper.startsWith('C')) {
return 'DO' + chord.slice(1);
}
if (upper.startsWith('D')) {
return 'RE' + chord.slice(1);
}
if (upper.startsWith('E')) {
return 'MI' + chord.slice(1);
}
if (upper.startsWith('F')) {
return 'FA' + chord.slice(1);
}
if (upper.startsWith('G')) {
return 'SOL' + chord.slice(1);
}
if (upper.startsWith('A')) {
return 'LA' + chord.slice(1);
}
if (upper.startsWith('B')) {
return 'SI' + chord.slice(1);
}
return chord;
}
isLabelLine(text: string): boolean {
return /^(Intro|Strofa|Rit|Special|Coro|Bridge|RIT|CHORUS|VERSE)/i.test(text.trim());
}
parseSongSpatially(words: any[]): string {
if (!words || words.length === 0) {
console.warn('[OCR-Capture] Nessuna parola ricevuta dall\'OCR.');
return '';
}
const isItalian = this.isItalianNotation(words);
console.log(`[OCR-Capture] Rilevata notazione italiana: ${isItalian}`);
// Preprocess words to split run-together chords like BA
const preprocessedWords: any[] = [];
words.forEach(w => {
if (!w.text) return;
const match = w.text.match(/^([ABCDEFG])([ABCDEFG])$/i);
if (match && !(match[1].toUpperCase() === 'F' && match[2].toUpperCase() === 'A')) {
const charWidth = (w.bbox.x1 - w.bbox.x0) / 2;
preprocessedWords.push({
text: match[1],
bbox: { ...w.bbox, x1: w.bbox.x0 + charWidth }
});
preprocessedWords.push({
text: match[2],
bbox: { ...w.bbox, x0: w.bbox.x0 + charWidth }
});
} else {
preprocessedWords.push(w);
}
});
words = preprocessedWords;
console.log(`[OCR-Capture] Parole totali ricevute dall'OCR: ${words.length}`);
const validWords = words.filter(w => w.text && w.text.trim().length > 0);
console.log(`[OCR-Capture] Parole valide dopo filtraggio: ${validWords.length}`);
if (validWords.length === 0) return '';
// Calculate average word height to set vertical tolerance
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
const verticalTolerance = avgHeight * 1.0;
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
// 2. Group words into horizontal lines
const lines: any[][] = [];
validWords.forEach(word => {
const wordYCenter = (word.bbox.y0 + word.bbox.y1) / 2;
let added = false;
for (const line of lines) {
const avgLineYCenter = line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1) / 2, 0) / line.length;
if (Math.abs(wordYCenter - avgLineYCenter) < verticalTolerance) {
line.push(word);
added = true;
break;
}
}
if (!added) {
lines.push([word]);
}
});
// Sort words horizontally in each line
lines.forEach(line => line.sort((a, b) => a.bbox.x0 - b.bbox.x0));
// Sort all lines vertically by average y0
lines.sort((a, b) => {
const avgA = a.reduce((sum, w) => sum + w.bbox.y0, 0) / a.length;
const avgB = b.reduce((sum, w) => sum + w.bbox.y0, 0) / b.length;
return avgA - avgB;
});
// 3. Classify lines as Chords vs. Text
const chordRegex = this.getChordRegex(isItalian);
const classifiedLines = lines.map(line => {
let chordCount = 0;
let hasLongNonChord = false;
line.forEach(w => {
if (this.isChordWord(w.text, isItalian, true)) {
chordCount++;
} else {
const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
if (clean.length > 5) {
hasLongNonChord = true;
}
}
});
const ratio = line.length > 0 ? chordCount / line.length : 0;
let isChords = false;
if (ratio >= 0.4 && line.length <= 10) {
if (!hasLongNonChord || ratio >= 0.75) {
isChords = true;
}
}
return {
words: line,
isChords: isChords,
yCenter: line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1)/2, 0) / line.length
};
});
// 4. Merge chords and text lines
const processedLines: string[] = [];
let inChorus = false;
let inVerse = false;
const chorusStartRegex = /^(R:|Rit\.|Rit|Ritornello|Coro)/i;
for (let i = 0; i < classifiedLines.length; i++) {
const current = classifiedLines[i];
if (current.isChords) {
const next = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
const nextText = next ? next.words.map(w => w.text).join(' ') : '';
if (next && !next.isChords && !this.isLabelLine(nextText)) {
// Merge spatially!
const merged = this.mergeChordsAndLyrics(current.words, next.words, isItalian);
const lineText = next.words.map(w => w.text).join(' ');
const isChorus = chorusStartRegex.test(lineText);
if (isChorus) {
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
} else if (!inChorus && !inVerse && lineText.length > 5) {
processedLines.push('{start_verse}');
inVerse = true;
}
processedLines.push(merged);
i++; // Skip next line because we consumed it!
} else {
// Chord line but no text below it: just wrap and print
const multiChordRegex = this.getMultiChordRegex(isItalian);
const expandedChords: string[] = [];
current.words.forEach(w => {
const parts = w.text.split(/(_)/);
const processedParts = parts.map((part: string) => {
if (part === '_') return ' _ ';
if (!part.trim()) return part;
let cleanText = part.toUpperCase().replace(/\s+/g, '');
cleanText = cleanText.replace(/\((.*?)\)/g, '/$1');
cleanText = cleanText.replace(/[\.\,]$/g, '');
cleanText = this.sanitizeOcrChord(cleanText);
if (this.isChordWord(cleanText, isItalian)) {
return `[${this.convertEnglishChordToItalian(cleanText, isItalian)}]`;
} else {
const matches = [...cleanText.matchAll(multiChordRegex)];
const fullMatchStr = matches.map(m => m[0]).join('');
if (matches.length > 0 && fullMatchStr === cleanText) {
return matches.map((m: any) => `[${this.convertEnglishChordToItalian(m[0].toUpperCase(), isItalian)}]`).join(' ');
} else {
return part;
}
}
});
expandedChords.push(processedParts.join(''));
});
const wrapped = expandedChords.join(' ');
if (wrapped) {
processedLines.push(wrapped);
}
}
} else {
const lineText = current.words.map(w => w.text).join(' ');
const isChorus = chorusStartRegex.test(lineText);
if (isChorus) {
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
} else if (!inChorus && !inVerse && lineText.length > 5) {
processedLines.push('{start_verse}');
inVerse = true;
}
processedLines.push(this.wrapChords(lineText, chordRegex, isItalian));
}
// If we see a large vertical gap, close open blocks
const lastProcessedLine = classifiedLines[i];
const currentNext = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
if (currentNext && lastProcessedLine) {
const gap = currentNext.yCenter - lastProcessedLine.yCenter;
if (gap > avgHeight * 3.5) {
if (inChorus) { processedLines.push('{end_chorus}'); inChorus = false; }
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
processedLines.push('');
}
}
}
if (inChorus) processedLines.push('{end_chorus}');
if (inVerse) processedLines.push('{end_verse}');
return processedLines.join('\n');
}
mergeChordsAndLyrics(chordWords: any[], textWords: any[], isItalian: boolean = false): string {
// Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)'
let mergedChordWords: any[] = [];
for (let i = 0; i < chordWords.length; i++) {
let cw = chordWords[i];
if (cw.text.startsWith('(') && mergedChordWords.length > 0) {
let prev = mergedChordWords[mergedChordWords.length - 1];
prev.text += cw.text;
prev.bbox.x1 = Math.max(prev.bbox.x1, cw.bbox.x1);
if (!prev.text.includes(')')) {
let j = i + 1;
while (j < chordWords.length) {
prev.text += chordWords[j].text;
prev.bbox.x1 = Math.max(prev.bbox.x1, chordWords[j].bbox.x1);
if (chordWords[j].text.includes(')')) {
i = j;
break;
}
j++;
}
}
} else {
let text = cw.text;
let bbox = { ...cw.bbox };
if (text.includes('(') && !text.includes(')')) {
let j = i + 1;
while (j < chordWords.length) {
text += chordWords[j].text;
bbox.x1 = Math.max(bbox.x1, chordWords[j].bbox.x1);
if (chordWords[j].text.includes(')')) {
i = j;
break;
}
j++;
}
}
mergedChordWords.push({ text, bbox });
}
}
// Sanitize common OCR errors in chords (e.g., 'Re(f fa#)' -> 'Re(fa#)')
mergedChordWords.forEach(cw => {
cw.text = cw.text.replace(/f\s*fa#/gi, 'fa#');
cw.text = cw.text.replace(/ff/gi, 'f');
cw.text = cw.text.replace(/m\s*mi/gi, 'mi');
cw.text = cw.text.replace(/mm/gi, 'm');
});
if (!textWords || textWords.length === 0) {
return mergedChordWords.map(c => {
const parts = c.text.split(/(_)/);
return parts.map((part: string) => {
if (part === '_') return ' _ ';
if (!part.trim()) return part;
let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
clean = this.sanitizeOcrChord(clean);
if (this.isChordWord(part, isItalian, true)) {
return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`;
} else {
return part;
}
}).join('');
}).join(' ');
}
const multiChordRegex = this.getMultiChordRegex(isItalian);
const expandedChordWords: any[] = [];
mergedChordWords.forEach(chord => {
const parts = chord.text.split(/(_)/);
let currentX = chord.bbox.x0;
const totalLen = chord.text.length || 1;
const widthPerChar = (chord.bbox.x1 - chord.bbox.x0) / totalLen;
parts.forEach((part: string) => {
const partLen = part.length;
const partWidth = partLen * widthPerChar;
const partX0 = currentX;
const partX1 = currentX + partWidth;
currentX = partX1;
if (part === '_' || !part.trim()) {
return;
}
let originalText = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
originalText = this.sanitizeOcrChord(originalText);
const matches = [...originalText.matchAll(multiChordRegex)];
if (matches.length === 0) {
expandedChordWords.push({
text: originalText,
bbox: { ...chord.bbox, x0: partX0, x1: partX1 }
});
} else {
const fullMatchStr = matches.map(m => m[0]).join('');
if (fullMatchStr === originalText) {
const matchCharWidth = (partX1 - partX0) / Math.max(1, originalText.length);
matches.forEach(match => {
const matchIndex = match.index!;
const matchLength = match[0].length;
const newX0 = partX0 + matchIndex * matchCharWidth;
const newX1 = partX0 + (matchIndex + matchLength) * matchCharWidth;
expandedChordWords.push({
text: match[0],
bbox: { ...chord.bbox, x0: newX0, x1: newX1 }
});
});
} else {
expandedChordWords.push({
text: originalText,
bbox: { ...chord.bbox, x0: partX0, x1: partX1 }
});
}
}
});
});
const chordAssignments = new Map<any, any[]>();
expandedChordWords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let closestWord: any = null;
let minDistance = Infinity;
textWords.forEach(textWord => {
let dist = 0;
if (chordX < textWord.bbox.x0) {
dist = textWord.bbox.x0 - chordX;
} else if (chordX > textWord.bbox.x1) {
dist = chordX - textWord.bbox.x1;
}
if (dist < minDistance) {
minDistance = dist;
closestWord = textWord;
}
});
if (closestWord) {
if (!chordAssignments.has(closestWord)) {
chordAssignments.set(closestWord, []);
}
chordAssignments.get(closestWord)!.push(chord);
}
});
let result = '';
textWords.forEach((textWord, index) => {
const assignedChords = chordAssignments.get(textWord) || [];
assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0);
const wordText = textWord.text;
let charWidth = (textWord.bbox.x1 - textWord.bbox.x0) / Math.max(1, wordText.length);
if (charWidth <= 0) charWidth = 6; // safe fallback
let lastCharIndex = 0;
let wordResult = '';
assignedChords.forEach(chord => {
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
let charIndex = Math.round((chordX - textWord.bbox.x0) / charWidth);
if (charIndex < 0) charIndex = 0;
if (charIndex > wordText.length) charIndex = wordText.length;
let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
cleanChord = this.sanitizeOcrChord(cleanChord);
if (this.isChordWord(chord.text, isItalian, true)) {
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord, isItalian)}]`;
} else {
wordResult += wordText.substring(lastCharIndex, charIndex);
}
lastCharIndex = charIndex;
});
wordResult += wordText.substring(lastCharIndex);
result += wordResult;
if (index < textWords.length - 1) {
result += ' ';
}
});
let finalResult = result.replace(/\]\[/g, '] [');
finalResult = finalResult.replace(/\]_\[/g, '] _ [');
finalResult = finalResult.replace(/\]_/g, '] _ ');
finalResult = finalResult.replace(/_\[/g, ' _ [');
// Normalize any duplicate spaces around underscores:
finalResult = finalResult.replace(/\s*_\s*/g, ' _ ');
console.log(`[OCR-Debug] Linea generata: ${finalResult}`);
return finalResult;
}
smartProcessOCR(text: string): string {
let lines = text.split('\n');
let processedLines: string[] = [];
const words = text.split(/\s+/).map(t => ({ text: t }));
const isItalian = this.isItalianNotation(words);
const chordRegex = isItalian
? /(DO|RE|MI|FA|SOL|LA|SI)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?/i
: /(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?/i;
let inChorus = false;
let inVerse = false;
lines.forEach((line) => {
let trimmed = line.trim();
if (!trimmed) {
if (inChorus) { processedLines.push('{end_chorus}'); inChorus = false; }
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
processedLines.push('');
return;
}
const isChorusMarker = /^(Rit|Ritornello|Chorus|CORO)/i.test(trimmed);
if (isChorusMarker && !inChorus) {
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
processedLines.push('{start_chorus}');
inChorus = true;
if (trimmed.length > 12) processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
} else {
if (!inVerse && !inChorus && trimmed.length > 5) {
processedLines.push('{start_verse}');
inVerse = true;
}
processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
}
});
if (inChorus) processedLines.push('{end_chorus}');
if (inVerse) processedLines.push('{end_verse}');
return processedLines.join('\n');
}
private wrapChords(line: string, regex: RegExp, isItalian: boolean): string {
let source = regex.source.replace(/^\^/, '').replace(/\$$/, '');
if (!source.startsWith('\\b')) source = '\\b' + source;
if (!source.endsWith('\\b')) source = source + '\\b';
const globalRegex = new RegExp(source, 'gi');
const parts = line.split(/(_)/);
return parts.map((part: string) => {
if (part === '_') return ' _ ';
return part.replace(globalRegex, (match) => {
const lower = match.toLowerCase();
if (['a', 'e', 'o', 'i'].includes(lower)) {
return match;
}
if (['la', 'mi', 're', 'do', 'si', 'fa', 'sol'].includes(lower)) {
return match;
}
const sanitized = this.sanitizeOcrChord(match.toUpperCase());
return `[${this.convertEnglishChordToItalian(sanitized, isItalian)}]`;
});
}).join('');
}
async saveToMyCanti() {
if (!this.title || !this.content) return;
// Automatically convert any [LAM]/[LAN], [REM]/[REN] etc. to [LAm], [REm] in content before saving
this.content = this.content.replace(/\[(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\]/g, (match, p1, p2, p3) => {
return `[${p1}${p2 || ''}m${p3 || ''}]`;
});
await this.proceedSaveToMyCanti();
}
private async proceedSaveToMyCanti() {
// Combine lit and tematico for id_momenti
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
const activePlaylistId = this.playlistService.activePlaylistId();
const isRemotePlaylist = activePlaylistId && activePlaylistId.startsWith('remote_');
if (isRemotePlaylist) {
// 1. Clone the song (generate a brand new my_... ID)
const savedCanto = await this.myCantiService.saveCanto({
titolo: this.title,
autore: this.author,
link_youtube: this.youtubeLink,
testo: this.content,
accordi: this.content,
id_momenti: id_momenti,
durata: this.durata || undefined,
bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined
});
// 2. Clone/convert remote playlist to local personal playlist
const remotePl = this.playlistService.remotePlaylist();
if (remotePl) {
const originalIds = remotePl.ids || [];
const updatedIds = originalIds.map((id: string) => id === this.editId ? savedCanto.id : id);
const songSettings = { ...(remotePl.songSettings || {}) };
if (this.editId && songSettings[this.editId]) {
songSettings[savedCanto.id] = { ...songSettings[this.editId] };
delete songSettings[this.editId];
}
const localName = remotePl.name.replace('[Remote] ', '');
// Force save as a new local playlist
this.playlistService.activePlaylistId.set(null);
await this.playlistService.savePlaylist(localName, updatedIds, songSettings);
}
// Navigate to the player with the new cloned song ID immediately
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
} else {
// Standard path
const savedCanto = await this.myCantiService.saveCanto({
id: this.editId || undefined,
titolo: this.title,
autore: this.author,
link_youtube: this.youtubeLink,
testo: this.content,
accordi: this.content, // Save to both fields for compatibility
id_momenti: id_momenti,
durata: this.durata || undefined,
bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined
});
if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) {
await this.playlistService.replaceSongIdInPlaylists(this.editId, savedCanto.id);
}
if (this.editId && this.editId.startsWith('remote_share_')) {
await this.playlistService.deleteRemoteShareCanto(this.editId);
}
if (this.editId && savedCanto && savedCanto.id && this.editId !== savedCanto.id) {
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
} else {
this.navCtrl.back();
}
}
}
}