LeggoFacile: Come Ho Scritto una PWA per la Lettura e la Dislessia

Data di pubblicazione:

Premessa doverosa:
Non sono un medico, un logopedista o un neuropsichiatra infantile. Sono semplicemente un genitore che sviluppa software. Questo progetto non ha alcuna valenza clinica: è nato come strumento informatico pratico per dare una mano a casa con i compiti, togliendo ansia e frustrazione davanti alla pagina scritta.

Passi le giornate a discutere di SLA, infrastrutture cloud, policy di sicurezza e pipeline di rilascio. Poi spegni il portatile, ti siedi vicino a un bambino delle elementari alle prese con i compiti davanti a tre righe di testo fitto sul libro di scuola, e ti rendi conto di quanto siamo abituati a dare per scontate certe cose.

Per chi affronta la dislessia o difficoltà nella prima fase di lettura, la fatica non è la voglia di studiare: è il carico mentale della decodifica. Prima ancora di capire il senso della storia, il cervello spende un'enorme quantità di energia per decifrare ogni singola lettera e sillaba. Quando il testo è denso o compatto, scatta il blocco: righe saltate, perdita del segno e rifiuto della lettura.

Prima di scrivere codice ho cercato cosa offriva il mercato:

  • Piattaforme proprietarie chiuse con costosi abbonamenti mensili.
  • App piene di banner pubblicitari, pop-up e suoni caotici che aumentano solo la distrazione.
  • Strumenti vocali calibrati su adulti: se il bambino esita o pronuncia una sillaba in modo incerto, il software segna errore rosso. Con un bambino significa vederlo chiudere il tablet demoralizzato dopo tre minuti.

A quel punto ho aperto l'editor: cosa serve davvero per dare un aiuto concreto, senza trasformare la lettura in un esame continuo?

È nata così LeggoFacile: una Progressive Web App (PWA) senza fronzoli, leggera, che gira su qualsiasi browser ed è pensata per fare poche cose, ma fatte bene.

Interfaccia di lettura e sillabazione LeggoFacile


1. Il Parser di Sillabazione in JavaScript

Per agevolare la lettura, il primo passo è spezzare la parola in sillabe visivamente distinte. La lingua italiana però ha diverse eccezioni: le doppie che si dividono (gat-to), la "s" impura che rimane con la sillaba successiva (fe-sta), i nessi indivisibili consonante+liquida (li-bro, te-a-tro), il gruppo cq (ac-qua), i digrammi (ch, gl, gn, sc) e gli iati vocali (po-e-ta).

Invece di appoggiarmi a server remoti, ho implementato un motore fonotattico client-side che analizza i caratteri al volo:

// Motore di sillabazione fonotattica italiana client-side
function syllabifyItalian(word) {
  if (!word) return [];
  const clean = word.toLowerCase().trim();
  if (clean.length <= 3) return [clean];

  const VOWELS = "aeiouyàèéìòù";
  const isVowel = ch => VOWELS.includes(ch);
  const n = clean.length;
  const breaks = new Array(n).fill(false);

  const inseparableDigraphs = ["ch", "gh", "gn", "gl", "sc"];
  const liquidClusters = ["b", "c", "d", "f", "g", "p", "t", "v"];

  for (let i = 0; i < n - 1; i++) {
    const c1 = clean[i];
    const c2 = clean[i + 1];
    const c3 = i + 2 < n ? clean[i + 2] : "";

    // 1. Doppie: si dividono sempre (gat-to)
    if (!isVowel(c1) && c1 === c2) {
      breaks[i] = true;
      continue;
    }

    // 2. Gruppo "cq": si divide come le doppie (ac-qua)
    if (c1 === 'c' && c2 === 'q') {
      breaks[i] = true;
      continue;
    }

    // 3. Digrammi inseparabili (ch, gh, gn, gl, sc)
    if (inseparableDigraphs.includes(c1 + c2)) {
      continue;
    }

    // 4. 'S' impura: rimane con la sillaba seguente (fe-sta)
    if (c1 === 's' && !isVowel(c2) && c2 !== 's') {
      continue;
    }

    // 5. Consonante + liquida (li-bro, a-pri-re)
    if (liquidClusters.includes(c1) && (c2 === 'l' || c2 === 'r')) {
      continue;
    }

    // 6. Due consonanti consecutive tra vocali: si dividono (can-to, por-ta)
    if (!isVowel(c1) && !isVowel(c2) && i > 0 && isVowel(clean[i - 1])) {
      if (clean[i - 1] !== 's') {
        breaks[i] = true;
        continue;
      }
    }

    // 7. Vocale + singola consonante: break classico (ca-ne, ro-sa)
    if (isVowel(c1) && !isVowel(c2) && c3 && isVowel(c3)) {
      breaks[i] = true;
      continue;
    }

    // 8. Iato: due vocali forti si separano (po-e-ta)
    const strongVowels = "aeoàèéò";
    if (strongVowels.includes(c1) && strongVowels.includes(c2)) {
      breaks[i] = true;
      continue;
    }
  }

  // Ricostruzione sillabe
  const syllables = [];
  let current = "";
  for (let i = 0; i < n; i++) {
    current += clean[i];
    if (breaks[i]) {
      syllables.push(current);
      current = "";
    }
  }
  if (current) syllables.push(current);
  return syllables;
}

Ogni sillaba viene inserita in card pastello ad alto contrasto, con font ad alta leggibilità (Lexend e Fredoka) e spaziatura maggiorata per azzerare l'affollamento visivo.


2. Auto-Ascolto Vocale e "Antifurto" sulla Prima Sillaba

La modalità di auto-ascolto usa le Web Speech API native (webkitSpeechRecognition), ma per un bambino della primaria serviva gestire due problemi:

  1. Falsi negativi: il motore vocale standard boccia la parola se il bambino mangia una vocale finale o esita a metà sillaba.
  2. Falsi positivi: se l'algoritmo fosse troppo tollerante, basterebbe pronunciare la prima sillaba ("gat...") per far avanzare la parola senza leggere il resto.

Nel codice abbiamo risolto combinando la distanza di Levenshtein con un controllo di desinenza:

function verifyFullWordSpeech(heardText, targetWord) {
  const cleanTarget = targetWord.toLowerCase().replace(/[^a-zàèéìòù]/gi, '');
  const targetSyllables = syllabifyItalian(cleanTarget);
  const isMultisyllabic = targetSyllables.length > 1;
  const firstSyllable = targetSyllables[0];
  const lastSyllable = targetSyllables[targetSyllables.length - 1];

  const heardTokens = sanitizeTokens(heardText);

  for (const token of heardTokens) {
    if (token === cleanTarget) return { isMatch: true };

    if (isMultisyllabic) {
      // Se ha letto SOLO la prima sillaba, non avanzare
      if (token === firstSyllable || token.length < cleanTarget.length * 0.8) {
        continue;
      }

      // Verifica che la desinenza finale sia compatibile
      const tokenEnding = token.slice(-Math.max(2, lastSyllable.length));
      if (calculateWordSimilarity(tokenEnding, lastSyllable) < 0.5 && !token.endsWith(lastSyllable)) {
        continue;
      }

      // Se la somiglianza globale è >= 80%, valida il tentativo
      if (calculateWordSimilarity(token, cleanTarget) >= 0.80) {
        return { isMatch: true };
      }
    } else {
      if (calculateWordSimilarity(token, cleanTarget) >= 0.75) {
        return { isMatch: true };
      }
    }
  }
  return { isMatch: false };
}

Se il tentativo è corretto, l'app avanza alla parola successiva. L'obiettivo non è fare un esame di dizione, ma premiare la lettura fluida.


3. Dalla Foto al Testo: OCR Locale con Tesseract.js

Ribattere a mano le storie o i testi del libro scolastico ogni giorno sarebbe una perdita di tempo.

Abbiamo integrato Tesseract.js direttamente nel browser:

  • Tocchi il pulsante fotocamera e scatti la foto alla pagina del libro.
  • L'elaborazione OCR avviene al 100% in locale nel browser, senza inviare immagini a server remoti.
  • Il testo estratto viene ripulito automaticamente da punteggiatura e formattazione, pronto per essere sillabato e letto.

4. Audio Sintetizzato via Web Audio API e Badge Animali

Nei software tradizionali l'errore è spesso accompagnato da suoni striduli o penalità: per chi fa già fatica, è il modo migliore per far salire l'ansia da prestazione.

In LeggoFacile l'approccio è diverso:

  • Zero file MP3 esterni: i suoni sono sintetizzati a runtime via Web Audio API (OscillatorNode).
  • Nessuna penalità: se il riconoscimento fallisce, un oscillatore sawtooth genera una buffa pernacchiotta cartoonesca accompagnata dal dondolio della card. Il bambino ride, non si sente giudicato e riprova con leggerezza.
  • Badge Collezionabili: ogni sessione terminata sblocca avatar progressivi:
    • 🐱 Gattino (Attento)
    • 🐶 Cagnolino (Fedele)
    • 🦁 Leoncino (Coraggioso)
    • 🦊 Volpina (Intelligente)
    • 🦄 Unicorno (Magico)
    • 🐲 Draghetto (Campione)

5. Privacy-First e Offline Puro

In un'applicazione pensata per l'infanzia, la privacy è fondamentale:

  • Nessun server backend: l'app è una PWA statica memorizzata in cache tramite Service Worker.
  • Nessun cookie, nessun tracciamento: zero analytics, zero chiamate verso terze parti.
  • Dati solo su IndexedDB locale: se un genitore o un logopedista vuole monitorare parole ostiche o tempi, i dati rimangono isolati nel browser del dispositivo.

Considerazioni Finali

A volte noi programmatori ci perdiamo dietro discussioni su microservizi, performance estreme e pattern architetturali complessi.

Ma quando vedi un bambino delle elementari che supera la paura della pagina scritta, si diverte a sbloccare un cucciolo e finisce la sua storia in autonomia, capisci il senso autentico del nostro mestiere: usare la tecnologia per togliere attrito e rendere le persone un po' più libere.