Dal PET-2001 ad HAL

Published on

in

,

Quarant’anni per arrivare all’ovvio

L’estate del 1980 la passai a impastare pizze in un locale di Berna. Diciassette anni, turni che finivano alle due di notte, mancetta a fine mese dal proprietario. Ogni settimana contavo quanto mancava. Non per un motorino, non per una vacanza: per un Commodore PET 2001.

A settembre lo portai a casa mio fratello scherzava che l’avevo comprato – 1 milione e mezzo di lire – in c.so Massimo a far marchette. Quattromila byte di RAM, schermo verde fosforo, tastiera integrata che sembrava una calcolatrice cresciuta troppo. I miei non capivano cosa ci avrei fatto. Io nemmeno, in realtà. Ma sapevo che dovevo averlo.

Passai l’inverno a disegnare il sistema solare coi caratteri semigrafici. Il Sole era un cerchio di blocchetti pieni, Giove un quadrato, Saturno con due barre orizzontali a fare gli anelli. L’animazione? Un ciclo FOR che spostava i pianeti di una colonna per volta. La magia stava nel far finta che quei pixel fossero orbite.

Quarant’anni dopo, scrivo codice che vede la mia mano e ascolta la mia voce. Non fingo più: il computer percepisce. O almeno, fa un’approssimazione statistica così buona che la differenza diventa filosofica.


Il lavoro sporco dell’interfaccia

Il codice che ho affinato in questi giorni — isteresi sulle soglie, calibrazione guidata, tolleranza agli errori nei comandi vocali — è esattamente il tipo di lavoro sporco che separa una dimostrazione da uno strumento usabile. Antonioni in Blow-Up faceva ingrandire fotografie cercando un dettaglio invisibile; qui ingrandisco soglie cercando la stabilità del gesto. La metafora regge: più guardi da vicino, più il segnale si confonde col rumore.

Che cos’è l’isteresi (e perché serve)

Immagina un termostato. Se accendesse il riscaldamento appena la temperatura scende sotto i 20 gradi e lo spegnesse appena risale sopra, finirebbe per accendersi e spegnersi ogni pochi secondi — il rumore delle misurazioni lo farebbe impazzire. Quindi usa due soglie: accende a 19, spegne a 21. Nel mezzo, ricorda cosa stava facendo.

Questo è il principio dell’isteresi: due soglie invece di una, e una memoria di stato. Nel mio codice funziona così:

if (state.wasPinching) {
    return dist < CONFIG.pinch.exit;  // 0.30
} else {
    return dist < CONFIG.pinch.enter; // 0.20
}

Se stavi già “pizzicando”, esci solo quando la distanza supera 0.30. Se non stavi pizzicando, entri solo quando scende sotto 0.20. La differenza — 0.10 — è il margine di sicurezza contro il rumore.

Chi ha studiato elettronica riconosce lo schema: è il circuito di Schmitt, il comparatore con retroazione positiva che trasforma un segnale rumoroso in un’onda pulita. Otto Schmitt lo sviluppò nel 1934, da studente, mentre studiava la propagazione nervosa nei calamari [1]. Lo descrisse nella sua tesi di dottorato nel 1937, chiamandolo “commutatore termionico”. Il nome dice tutto: una volta scattato, non torna indietro finché il segnale non scende sotto una seconda soglia.

Il ciclo di isteresi è lo stesso dei materiali ferromagnetici: magnetizzi un ferro, togli il campo, e il ferro resta magnetizzato. Serve un campo opposto per smagnetizzarlo. La “memoria” del sistema è geometrica — un’ellisse nel piano ingresso/uscita, non un punto.

Ma il dettaglio più bello è questo: il corpo ha inventato lo stesso meccanismo milioni di anni prima di Schmitt. I neuroni hanno un periodo refrattario — dopo aver scaricato, non rispondono per qualche millisecondo, anche se lo stimolo persiste. È isteresi biologica. Serve a evitare che il sistema nervoso vada in oscillazione. Schmitt, studiando i calamari, copiò dalla natura. Poi noi copiammo da lui. Il cerchio si chiude.


Calibrare il corpo (e farsi calibrare)

La calibrazione guidata è la parte più interessante sul piano didattico. Quando chiedo all’utente di fare “mano aperta” e poi “pugno”, sto facendo due cose: raccolgo dati personalizzati, ma anche insegno al sistema cosa significano quei gesti per quella persona. È l’equivalente del “premi tre volte il tasto A” dei giochi anni Ottanta per calibrare la leva di comando. L’interfaccia negozia col corpo.

Nel 1980 calibravo la leva. Nel 2024 calibro il sorriso. Il mezzo cambia, il negoziato resta.

Ma c’è un risvolto che non avevo considerato, finché un lettore non me l’ha fatto notare: la calibrazione non è neutra. Per farmi riconoscere dal sistema, devo performare un sorriso che il modello accetti come tale. Non un sorriso qualsiasi — il mio sorriso spontaneo potrebbe non bastare. Devo adattarmi io. È addestramento inverso: non è la macchina che impara me, sono io che imparo a essere leggibile dalla macchina.

È una forma sottile di disciplinamento. Il sistema ha un’idea statistica di cosa sia un “sorriso” — derivata da migliaia di volti usati per addestrarlo. Se il mio volto non rientra in quella distribuzione, devo correggermi. L’interfaccia standardizza l’emozione.

Il limite pratico — e qui tocca fare l’avvocato del diavolo — è che la calibrazione dura una sessione. Se la luce cambia, se l’utente si sposta, se indossa una maglia a righe che confonde il modello, tutto da rifare. MediaPipe Holistic è robusto, ma non è magia. È statistica con buone maniere.


La tolleranza all’errore

Il riconoscimento vocale sbaglia, soprattutto con l’italiano. “Più neve” diventa “Pineve”, “massimo” diventa “maschio”. Per gestire questi errori uso la distanza di Levenshtein — un algoritmo che conta quante modifiche servono per trasformare una parola in un’altra [2]. Se la risposta è “poche”, probabilmente l’utente intendeva quella parola.

if (distance <= Math.floor(keyword.length / 3)) {
    // Tollera 1-2 errori per parole di almeno 4 lettere
}

È un’euristica che funziona fino a quando non funziona. “Rosso” e “mosso” hanno distanza 1. “Stop” e “top” pure. Ma per un prototipo dimostrativo, è più che sufficiente. Il sistema perdona. Come un cameriere esperto che capisce l’ordine anche se il cliente borbotta.


HAL 9001, o della pareidolia comportamentale

HAL 9000 nel film. Ma io ricordavo 9001 — inciampo della memoria, o forse desiderio. Il 9001 sarebbe il modello successivo, quello che ha imparato dagli errori. Quello che non uccide l’equipaggio, che ammette di non sapere, che accetta di essere spento. Il modello che non esiste ancora.

Nel 1968 Kubrick immaginava un’interfaccia che osservava l’umano — leggeva le labbra, interpretava le esitazioni [3]. Nel 2024 la costruiamo con MediaPipe e cinquecento righe di JavaScript. La differenza? HAL era autonomo, ostile, cosciente (forse). Questo fiocco di neve che risponde al sorriso è un meccanismo: stimolo → risposta, senza intenzionalità.

Ma l’illusione è potente. Il cervello umano è progettato per vedere volontà ovunque — pareidolia comportamentale, la chiamano i neuroscienziati. Basta che qualcosa reagisca ai nostri gesti e lo trattiamo come interlocutore. Due occhi e una bocca su qualsiasi superficie, e vediamo un volto. Un sistema che risponde al sorriso, e gli parliamo.

De Sica lo sapeva. In Miracolo a Milano (1951) gli oggetti prendono vita, e il pubblico ci crede. Non perché sia plausibile: perché risponde. La reattività è il primo gradino dell’animismo. Lo sapevano gli sciamani, lo sanno i progettisti di interfacce. HAL 9001 — quando arriverà — dovrà fare i conti con questo: non basta essere intelligente, bisogna sembrare vivo. E forse è più facile sembrarlo che esserlo.


Quello che resta

Quello che ho costruito è un oggetto pedagogico: dimostra che l’interazione a più canali — gesti, voce, espressioni — è accessibile, non richiede apparecchiature dedicate, funziona in un navigatore. Ma dimostra anche i limiti: soglie da tarare, contesti da controllare, errori da gestire con garbo.

C’è un dettaglio che non ho ancora detto. Questo codice — HTML, CSS, JavaScript, MediaPipe — l’ho scritto in un linguaggio che non è il mio. Per me è alieno come il BASIC lo era nel 1980, forse di più. L’ho fatto con Claude Code, conversando. Descrivevo cosa volevo, lui proponeva, io correggevo, lui riformulava. Programmazione conversazionale, potremmo chiamarla: scrivere codice per intenzioni, non per sintassi.

Un anno fa sarebbe stato un miracolo. Oggi è un martedì pomeriggio.

Il PET 2001 non vedeva nulla. Aspettava che premessi un tasto, e io dovevo sapere quale tasto, in quale ordine, con quale sintassi. Quarant’anni dopo, il computer aspetta che io sorrida — e per insegnargli a farlo, gli ho parlato. Non so se è progresso o solo un’interfaccia diversa per la stessa domanda: cosa vuoi che faccia?

Però una cosa è cambiata. Nel 1980, per costruire qualcosa, dovevi prima possedere la macchina — e possederla significava guadagnarsela. Poi dovevi imparare la lingua — e impararla significava ore di manuali, errori, ripartenze. Oggi la macchina è ovunque, e la lingua la puoi negoziare. Ma costruire richiede ancora la stessa merce rara: ore. Ore di prove, di “non è quello che intendevo”, di “riprova con l’isteresi”.

I franchi svizzeri li ho spesi quarant’anni fa. Le ore le spendo ancora. Solo che adesso le spendo parlando.


Note e riferimenti

[1] Schmitt, O.H. (1938). A Thermionic Trigger. Journal of Scientific Instruments, 15, 24-26. Lo sviluppò nel 1934 studiando gli assoni giganti dei calamari a Woods Hole; la tesi di dottorato è del 1937.

[2] Levenshtein, V.I. (1966). Binary Codes Capable of Correcting Deletions, Insertions, and Reversals. Soviet Physics Doklady, 10(8), 707-710.

[3] Kubrick, S. (1968). 2001: Odissea nello spazio. HAL 9000 legge le labbra di Bowman e Poole nella celebre scena della capsula.


allegato sorgente fatto in vibecoding

<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Snow Gesture Control</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            background: linear-gradient(to bottom, #0a1628 0%, #1a3a5c 50%, #2d5a7b 100%);
            min-height: 100vh;
            overflow: hidden;
            font-family: 'Segoe UI', system-ui, sans-serif;
        }

        #snow-canvas {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 1;
        }

        .camera-container {
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 100;
            border-radius: 12px;
            overflow: hidden;
            box-shadow: 0 8px 32px rgba(0,0,0,0.4);
            border: 2px solid rgba(255,255,255,0.2);
        }

        #video {
            width: 240px;
            height: 180px;
            transform: scaleX(-1);
            display: block;
        }

        #hand-canvas {
            position: absolute;
            top: 0;
            left: 0;
            width: 240px;
            height: 180px;
            transform: scaleX(-1);
            pointer-events: none;
        }

        .controls-panel {
            position: fixed;
            top: 20px;
            left: 20px;
            z-index: 100;
            background: rgba(10, 22, 40, 0.85);
            backdrop-filter: blur(10px);
            padding: 20px;
            border-radius: 12px;
            color: white;
            min-width: 280px;
            border: 1px solid rgba(255,255,255,0.1);
        }

        .controls-panel h2 {
            font-size: 1.1rem;
            margin-bottom: 15px;
            color: #7eb8da;
            font-weight: 500;
        }

        .stat-row {
            display: flex;
            justify-content: space-between;
            margin-bottom: 10px;
            font-size: 0.9rem;
        }

        .stat-label {
            color: #8899aa;
        }

        .stat-value {
            color: #fff;
            font-weight: 600;
            font-variant-numeric: tabular-nums;
        }

        .gesture-indicator {
            margin-top: 15px;
            padding: 12px;
            border-radius: 8px;
            text-align: center;
            font-weight: 500;
            transition: all 0.2s ease;
        }

        .gesture-none {
            background: rgba(100,100,100,0.3);
            color: #888;
        }

        .gesture-pinch {
            background: rgba(76, 175, 80, 0.3);
            color: #81c784;
            border: 1px solid rgba(76, 175, 80, 0.5);
        }

        .gesture-fist {
            background: rgba(244, 67, 54, 0.3);
            color: #e57373;
            border: 1px solid rgba(244, 67, 54, 0.5);
        }

        .gesture-open {
            background: rgba(33, 150, 243, 0.3);
            color: #64b5f6;
            border: 1px solid rgba(33, 150, 243, 0.5);
        }

        .gesture-mouth-o {
            background: rgba(255, 152, 0, 0.3);
            color: #ffb74d;
            border: 1px solid rgba(255, 152, 0, 0.5);
        }

        .gesture-smile {
            background: rgba(233, 30, 99, 0.3);
            color: #f48fb1;
            border: 1px solid rgba(233, 30, 99, 0.5);
        }

        .gesture-face-neutral {
            background: rgba(100, 100, 100, 0.3);
            color: #aaa;
        }

        /* Voice Control styles */
        .voice-indicator {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-top: 10px;
            padding: 8px 12px;
            border-radius: 6px;
            font-size: 0.85rem;
            transition: all 0.2s ease;
        }

        .voice-inactive {
            background: rgba(100, 100, 100, 0.2);
            color: #666;
        }

        .voice-listening {
            background: rgba(76, 175, 80, 0.2);
            color: #81c784;
            border: 1px solid rgba(76, 175, 80, 0.4);
        }

        .voice-processing {
            background: rgba(255, 193, 7, 0.2);
            color: #ffd54f;
            border: 1px solid rgba(255, 193, 7, 0.4);
        }

        .mic-icon {
            font-size: 1.2rem;
        }

        .mic-icon.pulse {
            animation: pulse 1s infinite;
        }

        @keyframes pulse {
            0%, 100% { opacity: 1; transform: scale(1); }
            50% { opacity: 0.6; transform: scale(1.1); }
        }

        .voice-transcript {
            font-size: 0.75rem;
            color: #888;
            margin-top: 5px;
            min-height: 18px;
            font-style: italic;
        }

        .voice-command-feedback {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.8);
            color: white;
            padding: 20px 40px;
            border-radius: 12px;
            font-size: 1.5rem;
            z-index: 200;
            opacity: 0;
            transition: opacity 0.3s;
            pointer-events: none;
        }

        .voice-command-feedback.visible {
            opacity: 1;
        }

        /* Calibration overlay */
        .calibration-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(10, 22, 40, 0.95);
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            z-index: 1001;
            color: white;
            text-align: center;
        }

        .calibration-step {
            max-width: 500px;
            padding: 40px;
        }

        .calibration-icon {
            font-size: 5rem;
            margin-bottom: 20px;
            animation: bounce 1s infinite;
        }

        @keyframes bounce {
            0%, 100% { transform: translateY(0); }
            50% { transform: translateY(-10px); }
        }

        .calibration-title {
            font-size: 1.8rem;
            margin-bottom: 15px;
            color: #7eb8da;
        }

        .calibration-instruction {
            font-size: 1.1rem;
            color: #aaa;
            margin-bottom: 30px;
            line-height: 1.6;
        }

        .calibration-progress {
            width: 100%;
            height: 8px;
            background: rgba(255,255,255,0.1);
            border-radius: 4px;
            overflow: hidden;
            margin-bottom: 20px;
        }

        .calibration-progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #4caf50, #81c784);
            border-radius: 4px;
            transition: width 0.3s ease;
        }

        .calibration-value {
            font-size: 2rem;
            font-weight: bold;
            color: #81c784;
            margin-bottom: 10px;
        }

        .calibration-skip {
            margin-top: 30px;
            padding: 10px 25px;
            background: transparent;
            border: 1px solid rgba(255,255,255,0.3);
            color: #888;
            border-radius: 6px;
            cursor: pointer;
            font-size: 0.9rem;
            transition: all 0.2s;
        }

        .calibration-skip:hover {
            border-color: #7eb8da;
            color: #7eb8da;
        }

        .instructions {
            position: fixed;
            bottom: 20px;
            left: 20px;
            z-index: 100;
            background: rgba(10, 22, 40, 0.85);
            backdrop-filter: blur(10px);
            padding: 15px 20px;
            border-radius: 12px;
            color: #8899aa;
            font-size: 0.85rem;
            line-height: 1.6;
            border: 1px solid rgba(255,255,255,0.1);
        }

        .instructions strong {
            color: #7eb8da;
        }

        .loading-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(10, 22, 40, 0.95);
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            z-index: 1000;
            color: white;
        }

        .loading-spinner {
            width: 50px;
            height: 50px;
            border: 3px solid rgba(126, 184, 218, 0.2);
            border-top-color: #7eb8da;
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin-bottom: 20px;
        }

        @keyframes spin {
            to { transform: rotate(360deg); }
        }

        .hidden {
            display: none !important;
        }

        .zoom-indicator {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 5rem;
            color: rgba(255,255,255,0.3);
            font-weight: 200;
            pointer-events: none;
            z-index: 50;
            transition: all 0.15s ease-out;
            text-shadow: 0 0 30px rgba(126, 184, 218, 0.5);
        }

        .zoom-indicator.active {
            color: rgba(255,255,255,0.8);
            text-shadow: 0 0 50px rgba(126, 184, 218, 0.8);
        }

        /* Barra zoom verticale */
        .zoom-bar-container {
            position: fixed;
            right: 280px;
            top: 50%;
            transform: translateY(-50%);
            width: 8px;
            height: 300px;
            background: rgba(255,255,255,0.1);
            border-radius: 4px;
            z-index: 100;
            overflow: hidden;
        }

        .zoom-bar-fill {
            position: absolute;
            bottom: 0;
            width: 100%;
            background: linear-gradient(to top, #2196f3, #64b5f6);
            border-radius: 4px;
            transition: height 0.1s ease-out;
            box-shadow: 0 0 15px rgba(33, 150, 243, 0.6);
        }

        .zoom-bar-marker {
            position: absolute;
            left: -6px;
            width: 20px;
            height: 3px;
            background: white;
            border-radius: 2px;
            transition: bottom 0.1s ease-out;
        }

        .zoom-labels {
            position: fixed;
            right: 295px;
            top: 50%;
            transform: translateY(-50%);
            height: 300px;
            display: flex;
            flex-direction: column;
            justify-content: space-between;
            color: rgba(255,255,255,0.4);
            font-size: 11px;
            z-index: 100;
        }

        /* Effetto bordo schermo per feedback zoom */
        .zoom-vignette {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            pointer-events: none;
            z-index: 40;
            transition: box-shadow 0.2s ease-out;
        }

        /* Frecce direzionali */
        .zoom-arrows {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            pointer-events: none;
            z-index: 45;
            opacity: 0;
            transition: opacity 0.3s;
        }

        .zoom-arrows.visible {
            opacity: 1;
        }

        .zoom-arrows svg {
            width: 200px;
            height: 200px;
        }
    </style>
</head>
<body>
    <div id="loading" class="loading-overlay">
        <div class="loading-spinner"></div>
        <div>Caricamento MediaPipe Holistic...</div>
    </div>

    <!-- Calibrazione guidata -->
    <div id="calibration" class="calibration-overlay hidden">
        <div class="calibration-step">
            <div class="calibration-icon" id="calib-icon">🖐️</div>
            <div class="calibration-title" id="calib-title">Calibrazione Gesti</div>
            <div class="calibration-instruction" id="calib-instruction">
                Mantieni la mano aperta davanti alla camera
            </div>
            <div class="calibration-progress">
                <div class="calibration-progress-fill" id="calib-progress" style="width: 0%"></div>
            </div>
            <div class="calibration-value" id="calib-value">—</div>
            <button class="calibration-skip" id="calib-skip">Salta calibrazione</button>
        </div>
    </div>

    <canvas id="snow-canvas"></canvas>

    <div class="zoom-indicator" id="zoom-indicator">×1.0</div>

    <!-- Barra zoom verticale -->
    <div class="zoom-bar-container" id="zoom-bar">
        <div class="zoom-bar-fill" id="zoom-bar-fill"></div>
        <div class="zoom-bar-marker" id="zoom-bar-marker"></div>
    </div>
    <div class="zoom-labels">
        <span>VICINO</span>
        <span>LONTANO</span>
    </div>

    <!-- Vignette per feedback visivo -->
    <div class="zoom-vignette" id="zoom-vignette"></div>

    <div class="controls-panel">
        <h2>❄️ Snow Parameters</h2>
        <div class="stat-row">
            <span class="stat-label">Fiocchi attivi</span>
            <span class="stat-value" id="flake-count">0</span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Densità</span>
            <span class="stat-value" id="density-value">100</span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Zoom</span>
            <span class="stat-value" id="zoom-value">1.0×</span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Distanza mano</span>
            <span class="stat-value" id="hand-distance">—</span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Pinch dist</span>
            <span class="stat-value" id="pinch-dist">—</span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Dita chiuse</span>
            <span class="stat-value" id="closed-fingers">—</span>
        </div>
        <div class="gesture-indicator gesture-none" id="gesture-indicator">
            Nessuna mano rilevata
        </div>

        <h2 style="margin-top: 20px; font-size: 1.1rem; color: #da7e9e;">😊 Face Control</h2>
        <div class="stat-row">
            <span class="stat-label">Bocca aperta</span>
            <span class="stat-value" id="mouth-open">—</span>
        </div>
        <div class="stat-row">
            <span class="stat-label">Sorriso</span>
            <span class="stat-value" id="smile-value">—</span>
        </div>
        <div class="gesture-indicator gesture-none" id="face-indicator">
            Nessun volto rilevato
        </div>

        <h2 style="margin-top: 20px; font-size: 1.1rem; color: #a8da7e;">🎤 Voice Control</h2>
        <div class="voice-indicator voice-inactive" id="voice-indicator">
            <span class="mic-icon" id="mic-icon">🎤</span>
            <span id="voice-status">Clicca per attivare</span>
        </div>
        <div class="voice-transcript" id="voice-transcript"></div>
    </div>

    <!-- Feedback comando vocale -->
    <div class="voice-command-feedback" id="voice-feedback"></div>

    <div class="instructions">
        <strong>🖐️ Mano aperta</strong> — zoom<br>
        <strong>🤏 Pinch / 😊 Sorriso</strong> — +neve<br>
        <strong>✊ Pugno / 😮 Bocca O</strong> — -neve<br>
        <strong>🎤 Voce:</strong> "più neve", "meno neve", "stop", "reset", "massimo", "colore [rosso/blu/verde]", "calibrazione"
    </div>

    <div class="camera-container">
        <video id="video" playsinline></video>
        <canvas id="hand-canvas"></canvas>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/holistic/holistic.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js"></script>

    <script>
        // === CONFIGURAZIONE ===
        const CONFIG = {
            initialDensity: 100,
            minDensity: 20,
            maxDensity: 500,
            densityStep: 5,
            minZoom: 0.3,
            maxZoom: 3.0,
            smoothingFactor: 0.15,
            gestureDebounce: 80,

            // Soglie con ISTERESI (enter/exit diversi per evitare flickering)
            pinch: {
                enter: 0.20,    // Entra in pinch quando dist < 0.20
                exit: 0.30      // Esce da pinch quando dist > 0.30
            },
            fist: {
                enter: 3,       // Entra in fist quando closedFingers >= 3
                exit: 2         // Esce da fist quando closedFingers <= 2
            },
            mouthO: {
                enter: 0.35,    // Soglia per entrare
                exit: 0.25      // Soglia per uscire
            },
            smile: {
                enter: 0.12,
                exit: 0.08
            },

            // Valori calibrati (saranno sovrascritti dalla calibrazione)
            calibrated: {
                pinchMin: 0.08,     // Pinch stretto calibrato
                pinchMax: 0.35,     // Mano aperta calibrata
                fistClosed: 4,      // Dita chiuse in pugno
                fistOpen: 0         // Dita chiuse mano aperta
            }
        };

        // === STATE ===
        const state = {
            density: CONFIG.initialDensity,
            zoom: 1.0,
            targetZoom: 1.0,
            lastGestureTime: 0,
            lastFaceGestureTime: 0,
            currentGesture: 'none',
            currentFaceGesture: 'none',
            handDetected: false,
            faceDetected: false,
            handSize: 0,
            mouthOpenRatio: 0,
            smileRatio: 0,
            // Voice control
            snowColor: 'white',
            isAnimating: true,
            isRainbow: false,
            // Isteresi - stati precedenti per evitare flickering
            wasPinching: false,
            wasFist: false,
            wasMouthO: false,
            wasSmiling: false,
            // Calibrazione
            isCalibrating: false,
            calibrationStep: 0,
            calibrationSamples: []
        };

        // === SNOW SYSTEM ===
        class Snowflake {
            constructor(canvas, zoom) {
                this.canvas = canvas;
                this.reset(zoom, true);
            }

            reset(zoom, initial = false) {
                const baseSize = (Math.random() * 3 + 1);
                this.size = baseSize * zoom;
                this.baseSize = baseSize;

                this.x = Math.random() * this.canvas.width;
                this.y = initial ? Math.random() * this.canvas.height : -10;

                this.speedY = (Math.random() * 1 + 0.5) * zoom;
                this.speedX = (Math.random() - 0.5) * 0.5;
                this.wobble = Math.random() * Math.PI * 2;
                this.wobbleSpeed = Math.random() * 0.02 + 0.01;
                this.opacity = Math.random() * 0.5 + 0.5;
            }

            update(zoom) {
                this.wobble += this.wobbleSpeed;
                this.x += this.speedX + Math.sin(this.wobble) * 0.5;
                this.y += this.speedY;
                this.size = this.baseSize * zoom;

                if (this.y > this.canvas.height + 10 ||
                    this.x < -10 ||
                    this.x > this.canvas.width + 10) {
                    this.reset(zoom);
                }
            }

            draw(ctx, color, isRainbow) {
                ctx.beginPath();
                ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);

                if (isRainbow) {
                    // Colore rainbow basato sulla posizione
                    const hue = (this.x / ctx.canvas.width * 360 + Date.now() * 0.1) % 360;
                    ctx.fillStyle = `hsla(${hue}, 80%, 70%, ${this.opacity})`;
                } else {
                    ctx.fillStyle = this.getColorWithOpacity(color, this.opacity);
                }
                ctx.fill();
            }

            getColorWithOpacity(color, opacity) {
                const colors = {
                    'white': `rgba(255, 255, 255, ${opacity})`,
                    'red': `rgba(255, 100, 100, ${opacity})`,
                    'blue': `rgba(100, 150, 255, ${opacity})`,
                    'green': `rgba(100, 255, 150, ${opacity})`,
                    'yellow': `rgba(255, 255, 100, ${opacity})`,
                    'purple': `rgba(200, 100, 255, ${opacity})`,
                };
                return colors[color] || colors['white'];
            }
        }

        class SnowSystem {
            constructor(canvasId) {
                this.canvas = document.getElementById(canvasId);
                this.ctx = this.canvas.getContext('2d');
                this.snowflakes = [];
                this.resize();
                window.addEventListener('resize', () => this.resize());

                // Pre-calcola colori per batching
                this.colorCache = {};
            }

            resize() {
                this.canvas.width = window.innerWidth;
                this.canvas.height = window.innerHeight;
            }

            setDensity(count) {
                const currentCount = this.snowflakes.length;

                if (count > currentCount) {
                    for (let i = currentCount; i < count; i++) {
                        this.snowflakes.push(new Snowflake(this.canvas, state.zoom));
                    }
                } else if (count < currentCount) {
                    this.snowflakes.length = count;
                }
            }

            // OTTIMIZZATO: batching per opacità
            update(zoom, color, isRainbow) {
                const ctx = this.ctx;
                ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

                if (isRainbow) {
                    // Rainbow: deve disegnare individualmente per colore unico
                    this.drawRainbow(zoom);
                } else {
                    // Batching per fasce di opacità (0.5-0.6, 0.6-0.7, etc.)
                    this.drawBatched(zoom, color);
                }
            }

            drawBatched(zoom, color) {
                const ctx = this.ctx;

                // Raggruppa fiocchi per fascia di opacità (5 fasce)
                const batches = [[], [], [], [], []];

                for (const flake of this.snowflakes) {
                    flake.update(zoom);
                    // Indice fascia: 0.5-0.6 = 0, 0.6-0.7 = 1, etc.
                    const batchIndex = Math.min(4, Math.floor((flake.opacity - 0.5) * 10));
                    batches[batchIndex].push(flake);
                }

                // Disegna ogni batch con un solo beginPath/fill
                const baseColors = {
                    'white': [255, 255, 255],
                    'red': [255, 100, 100],
                    'blue': [100, 150, 255],
                    'green': [100, 255, 150],
                    'yellow': [255, 255, 100],
                    'purple': [200, 100, 255],
                };
                const rgb = baseColors[color] || baseColors['white'];

                for (let i = 0; i < 5; i++) {
                    const batch = batches[i];
                    if (batch.length === 0) continue;

                    const opacity = 0.5 + i * 0.1 + 0.05; // Centro della fascia
                    ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${opacity})`;
                    ctx.beginPath();

                    for (const flake of batch) {
                        ctx.moveTo(flake.x + flake.size, flake.y);
                        ctx.arc(flake.x, flake.y, flake.size, 0, Math.PI * 2);
                    }

                    ctx.fill();
                }
            }

            drawRainbow(zoom) {
                const ctx = this.ctx;
                const now = Date.now() * 0.1;

                for (const flake of this.snowflakes) {
                    flake.update(zoom);
                    const hue = (flake.x / this.canvas.width * 360 + now) % 360;
                    ctx.fillStyle = `hsla(${hue}, 80%, 70%, ${flake.opacity})`;
                    ctx.beginPath();
                    ctx.arc(flake.x, flake.y, flake.size, 0, Math.PI * 2);
                    ctx.fill();
                }
            }
        }

        // === GESTURE DETECTION con ISTERESI ===
        class GestureDetector {
            constructor() {
                this.landmarks = null;
                // Debug values
                this.debugPinchDist = 0;
                this.debugClosedFingers = 0;
            }

            update(landmarks) {
                this.landmarks = landmarks;
            }

            distance2D(p1, p2) {
                return Math.sqrt(
                    Math.pow(p1.x - p2.x, 2) +
                    Math.pow(p1.y - p2.y, 2)
                );
            }

            getHandSize() {
                if (!this.landmarks) return 0;
                const wrist = this.landmarks[0];
                const middleMcp = this.landmarks[9];
                const pinkyMcp = this.landmarks[17];
                const palmHeight = this.distance2D(wrist, middleMcp);
                const palmWidth = this.distance2D(this.landmarks[5], pinkyMcp);
                return (palmHeight + palmWidth) / 2;
            }

            // Calcola distanza pinch normalizzata (per calibrazione e debug)
            getPinchDistance() {
                if (!this.landmarks) return 1;
                const thumbTip = this.landmarks[4];
                const indexTip = this.landmarks[8];
                const distance = this.distance2D(thumbTip, indexTip);
                const handSize = this.getHandSize();
                return handSize > 0 ? distance / handSize : 1;
            }

            // Conta dita chiuse (per calibrazione e debug)
            getClosedFingers() {
                if (!this.landmarks) return 0;
                const fingerTips = [this.landmarks[8], this.landmarks[12], this.landmarks[16], this.landmarks[20]];
                const fingerPips = [this.landmarks[6], this.landmarks[10], this.landmarks[14], this.landmarks[18]];
                let closed = 0;
                for (let i = 0; i < 4; i++) {
                    if (fingerTips[i].y > fingerPips[i].y) closed++;
                }
                return closed;
            }

            // ISTERESI: usa soglie diverse per entrare/uscire
            isPinching() {
                if (!this.landmarks) return false;
                const dist = this.getPinchDistance();
                this.debugPinchDist = dist;

                // Isteresi: soglia diversa per entrare vs uscire
                if (state.wasPinching) {
                    // Già in pinch: esce solo se dist > exit threshold
                    return dist < CONFIG.pinch.exit;
                } else {
                    // Non in pinch: entra solo se dist < enter threshold
                    return dist < CONFIG.pinch.enter;
                }
            }

            isFist() {
                if (!this.landmarks) return false;
                const closed = this.getClosedFingers();
                this.debugClosedFingers = closed;

                // Isteresi per fist
                if (state.wasFist) {
                    return closed > CONFIG.fist.exit;
                } else {
                    return closed >= CONFIG.fist.enter;
                }
            }

            isOpenHand() {
                if (!this.landmarks) return false;
                return !this.isPinching() && !this.isFist();
            }

            getGesture() {
                if (!this.landmarks) return 'none';

                const isPinch = this.isPinching();
                const isFist = this.isFist();

                // Aggiorna stati per isteresi
                state.wasPinching = isPinch;
                state.wasFist = isFist;

                if (isPinch) return 'pinch';
                if (isFist) return 'fist';
                if (this.isOpenHand()) return 'open';

                return 'none';
            }
        }

        // === FACE GESTURE DETECTION ===
        class FaceGestureDetector {
            constructor() {
                this.landmarks = null;
                this.mouthOpenRatio = 0;
                this.smileRatio = 0;

                // Soglie per rilevamento
                this.MOUTH_O_THRESHOLD = 0.4;   // Bocca aperta
                this.SMILE_THRESHOLD = 0.15;    // Sorriso
            }

            update(landmarks) {
                this.landmarks = landmarks;
                if (landmarks) {
                    this.calculateMouthMetrics();
                }
            }

            distance(p1, p2) {
                return Math.sqrt(
                    Math.pow(p1.x - p2.x, 2) +
                    Math.pow(p1.y - p2.y, 2)
                );
            }

            calculateMouthMetrics() {
                if (!this.landmarks) return;

                // Face Mesh landmark indices per la bocca:
                // 13 = labbro superiore centro interno
                // 14 = labbro inferiore centro interno
                // 61 = angolo bocca sinistro
                // 291 = angolo bocca destro
                // 0 = labbro superiore centro esterno
                // 17 = labbro inferiore centro esterno
                // 78 = labbro superiore sinistro
                // 308 = labbro superiore destro

                const upperLip = this.landmarks[13];
                const lowerLip = this.landmarks[14];
                const leftCorner = this.landmarks[61];
                const rightCorner = this.landmarks[291];
                const upperLipOuter = this.landmarks[0];
                const lowerLipOuter = this.landmarks[17];

                // Riferimento per normalizzazione (distanza tra gli occhi)
                const leftEye = this.landmarks[33];
                const rightEye = this.landmarks[263];
                const eyeDistance = this.distance(leftEye, rightEye);

                // Apertura verticale della bocca (normalizzata)
                const mouthHeight = this.distance(upperLip, lowerLip);
                this.mouthOpenRatio = mouthHeight / eyeDistance;

                // Larghezza bocca
                const mouthWidth = this.distance(leftCorner, rightCorner);

                // Sorriso: gli angoli della bocca sono più alti del centro
                // Calcoliamo quanto gli angoli sono sopra la linea centrale
                const centerY = (upperLip.y + lowerLip.y) / 2;
                const cornerAvgY = (leftCorner.y + rightCorner.y) / 2;

                // Se cornerAvgY < centerY, stiamo sorridendo (y cresce verso il basso)
                // Normalizziamo rispetto all'altezza della bocca
                const smileLift = (centerY - cornerAvgY);
                this.smileRatio = smileLift / eyeDistance;
            }

            // ISTERESI per bocca O
            isMouthO() {
                if (state.wasMouthO) {
                    return this.mouthOpenRatio > CONFIG.mouthO.exit;
                } else {
                    return this.mouthOpenRatio > CONFIG.mouthO.enter;
                }
            }

            // ISTERESI per sorriso
            isSmiling() {
                const smileCondition = state.wasSmiling
                    ? this.smileRatio > CONFIG.smile.exit
                    : this.smileRatio > CONFIG.smile.enter;
                return smileCondition && this.mouthOpenRatio < 0.3;
            }

            getExpression() {
                if (!this.landmarks) return 'none';

                const isMouthO = this.isMouthO();
                const isSmile = this.isSmiling();

                // Aggiorna stati per isteresi
                state.wasMouthO = isMouthO;
                state.wasSmiling = isSmile;

                if (isMouthO) return 'mouth-o';
                if (isSmile) return 'smile';

                return 'neutral';
            }
        }

        // === VOICE CONTROL con FUZZY MATCHING ===
        class VoiceController {
            constructor(onCommand) {
                this.onCommand = onCommand;
                this.recognition = null;
                this.isListening = false;
                this.restartCount = 0;
                this.isSupported = 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window;

                // Elementi UI
                this.indicator = document.getElementById('voice-indicator');
                this.micIcon = document.getElementById('mic-icon');
                this.statusText = document.getElementById('voice-status');
                this.transcript = document.getElementById('voice-transcript');
                this.feedback = document.getElementById('voice-feedback');

                // Comandi con regex word-boundary per evitare falsi positivi
                // Formato: { pattern: RegExp, action: Function, display: String }
                this.commands = [
                    // Densità neve (ordine importante: più specifici prima)
                    { pattern: /\b(più|piu)\s*neve\b/i, action: () => this.onCommand('add', 50), display: 'più neve' },
                    { pattern: /\bancora\s*neve\b/i, action: () => this.onCommand('add', 30), display: 'ancora neve' },
                    { pattern: /\bmeno\s*neve\b/i, action: () => this.onCommand('remove', 50), display: 'meno neve' },
                    { pattern: /\bpoca\s*neve\b/i, action: () => this.onCommand('remove', 30), display: 'poca neve' },
                    { pattern: /\btanta\s*neve\b/i, action: () => this.onCommand('set', 400), display: 'tanta neve' },
                    { pattern: /\bmassimo\b/i, action: () => this.onCommand('set', 500), display: 'massimo' },
                    { pattern: /\bminimo\b/i, action: () => this.onCommand('set', 20), display: 'minimo' },
                    { pattern: /\bnormale\b/i, action: () => this.onCommand('set', 100), display: 'normale' },
                    { pattern: /\breset\b/i, action: () => this.onCommand('reset'), display: 'reset' },

                    // Controllo animazione
                    { pattern: /\b(stop|fermo|ferma)\b/i, action: () => this.onCommand('stop'), display: 'stop' },
                    { pattern: /\bpausa\b/i, action: () => this.onCommand('stop'), display: 'pausa' },
                    { pattern: /\b(vai|via|go)\b/i, action: () => this.onCommand('start'), display: 'vai' },
                    { pattern: /\b(inizia|comincia)\b/i, action: () => this.onCommand('start'), display: 'inizia' },
                    { pattern: /\briprendi\b/i, action: () => this.onCommand('start'), display: 'riprendi' },

                    // Colori neve
                    { pattern: /\b(neve\s*)?bianca?\b/i, action: () => this.onCommand('color', 'white'), display: 'bianco' },
                    { pattern: /\b(neve\s*)?ross[ao]?\b/i, action: () => this.onCommand('color', 'red'), display: 'rosso' },
                    { pattern: /\b(neve\s*)?(blu|blue)\b/i, action: () => this.onCommand('color', 'blue'), display: 'blu' },
                    { pattern: /\b(neve\s*)?verde\b/i, action: () => this.onCommand('color', 'green'), display: 'verde' },
                    { pattern: /\b(neve\s*)?giall[ao]?\b/i, action: () => this.onCommand('color', 'yellow'), display: 'giallo' },
                    { pattern: /\b(neve\s*)?viola\b/i, action: () => this.onCommand('color', 'purple'), display: 'viola' },
                    { pattern: /\b(arcobaleno|rainbow)\b/i, action: () => this.onCommand('color', 'rainbow'), display: 'arcobaleno' },

                    // Zoom
                    { pattern: /\bzoom\s*(avanti|in)\b/i, action: () => this.onCommand('zoom', 2.0), display: 'zoom avanti' },
                    { pattern: /\bzoom\s*(indietro|out)\b/i, action: () => this.onCommand('zoom', 0.5), display: 'zoom indietro' },
                    { pattern: /\bzoom\s*normale\b/i, action: () => this.onCommand('zoom', 1.0), display: 'zoom normale' },

                    // Fun
                    { pattern: /\b(bufera|tempesta)\b/i, action: () => this.onCommand('blizzard'), display: 'bufera' },
                    { pattern: /\bcalma\b/i, action: () => this.onCommand('calm'), display: 'calma' },

                    // Calibrazione
                    { pattern: /\bcalibra(zione)?\b/i, action: () => this.onCommand('calibrate'), display: 'calibrazione' },
                ];

                this.init();
            }

            // Distanza di Levenshtein per fuzzy matching
            levenshtein(a, b) {
                const matrix = [];
                for (let i = 0; i <= b.length; i++) {
                    matrix[i] = [i];
                }
                for (let j = 0; j <= a.length; j++) {
                    matrix[0][j] = j;
                }
                for (let i = 1; i <= b.length; i++) {
                    for (let j = 1; j <= a.length; j++) {
                        if (b.charAt(i - 1) === a.charAt(j - 1)) {
                            matrix[i][j] = matrix[i - 1][j - 1];
                        } else {
                            matrix[i][j] = Math.min(
                                matrix[i - 1][j - 1] + 1,
                                matrix[i][j - 1] + 1,
                                matrix[i - 1][j] + 1
                            );
                        }
                    }
                }
                return matrix[b.length][a.length];
            }

            // Trova comando fuzzy se regex non matcha
            findFuzzyMatch(text) {
                const words = text.split(/\s+/);
                const keywords = ['neve', 'massimo', 'minimo', 'stop', 'pausa', 'rosso', 'blu', 'verde', 'bufera', 'calma', 'reset'];

                for (const word of words) {
                    for (const keyword of keywords) {
                        const distance = this.levenshtein(word.toLowerCase(), keyword);
                        // Tollera 1-2 errori per parole >= 4 caratteri
                        if (distance <= Math.floor(keyword.length / 3)) {
                            // Trova il comando corrispondente
                            for (const cmd of this.commands) {
                                if (cmd.display.toLowerCase().includes(keyword)) {
                                    return cmd;
                                }
                            }
                        }
                    }
                }
                return null;
            }

            init() {
                if (!this.isSupported) {
                    this.statusText.textContent = 'Non supportato';
                    this.indicator.style.opacity = '0.5';
                    return;
                }

                const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
                this.recognition = new SpeechRecognition();
                this.recognition.continuous = true;
                this.recognition.interimResults = true;
                this.recognition.lang = 'it-IT';

                this.recognition.onstart = () => {
                    this.isListening = true;
                    this.updateUI('listening');
                };

                this.recognition.onend = () => {
                    // Riavvia automaticamente se era in ascolto
                    if (this.isListening) {
                        this.restartCount++;
                        if (this.restartCount > 20) {
                            this.statusText.textContent = 'Riconoscimento instabile';
                            this.isListening = false;
                            this.updateUI('inactive');
                            return;
                        }
                        try {
                            this.recognition.start();
                        } catch (e) {
                            console.warn('Voice restart failed:', e);
                        }
                    } else {
                        this.updateUI('inactive');
                    }
                };

                this.recognition.onerror = (event) => {
                    console.log('Voice error:', event.error);
                    if (event.error === 'not-allowed') {
                        this.statusText.textContent = 'Microfono negato';
                    }
                };

                this.recognition.onresult = (event) => {
                    let finalTranscript = '';
                    let interimTranscript = '';

                    for (let i = event.resultIndex; i < event.results.length; i++) {
                        const transcript = event.results[i][0].transcript.toLowerCase().trim();
                        if (event.results[i].isFinal) {
                            finalTranscript = transcript;
                        } else {
                            interimTranscript = transcript;
                        }
                    }

                    // Mostra trascrizione
                    this.transcript.textContent = interimTranscript || finalTranscript;

                    // Processa comando finale
                    if (finalTranscript) {
                        this.processCommand(finalTranscript);
                    }
                };

                // Click per attivare/disattivare
                this.indicator.addEventListener('click', () => this.toggle());
                this.indicator.style.cursor = 'pointer';
            }

            toggle() {
                if (!this.isSupported) return;

                if (this.isListening) {
                    this.stop();
                } else {
                    this.start();
                }
            }

            start() {
                if (!this.isSupported || this.isListening) return;
                try {
                    this.recognition.start();
                    this.isListening = true;
                } catch (e) {
                    console.error('Voice start error:', e);
                }
            }

            stop() {
                if (!this.isListening) return;
                this.isListening = false;
                try {
                    this.recognition.stop();
                } catch (e) {}
                this.updateUI('inactive');
            }

            updateUI(status) {
                this.indicator.className = 'voice-indicator voice-' + status;

                if (status === 'listening') {
                    this.micIcon.classList.add('pulse');
                    this.statusText.textContent = 'In ascolto...';
                } else if (status === 'processing') {
                    this.micIcon.classList.remove('pulse');
                    this.statusText.textContent = 'Elaboro...';
                } else {
                    this.micIcon.classList.remove('pulse');
                    this.statusText.textContent = 'Clicca per attivare';
                    this.transcript.textContent = '';
                }
            }

            processCommand(text) {
                console.log('Voice command:', text);

                // Prima prova con regex (più preciso)
                for (const cmd of this.commands) {
                    if (cmd.pattern.test(text)) {
                        this.showFeedback(cmd.display);
                        cmd.action();
                        return;
                    }
                }

                // Se regex fallisce, prova fuzzy matching
                const fuzzyMatch = this.findFuzzyMatch(text);
                if (fuzzyMatch) {
                    this.showFeedback(fuzzyMatch.display + ' (fuzzy)');
                    fuzzyMatch.action();
                    return;
                }

                // Nessun comando riconosciuto
                this.transcript.textContent = `"${text}" - comando non riconosciuto`;
            }

            showFeedback(command) {
                this.feedback.textContent = `🎤 ${command}`;
                this.feedback.classList.add('visible');
                setTimeout(() => {
                    this.feedback.classList.remove('visible');
                }, 1500);
            }
        }

        // === CALIBRAZIONE GUIDATA ===
        class CalibrationSystem {
            constructor() {
                this.overlay = document.getElementById('calibration');
                this.icon = document.getElementById('calib-icon');
                this.title = document.getElementById('calib-title');
                this.instruction = document.getElementById('calib-instruction');
                this.progress = document.getElementById('calib-progress');
                this.value = document.getElementById('calib-value');
                this.skipBtn = document.getElementById('calib-skip');

                this.steps = [
                    {
                        icon: '🖐️',
                        title: 'Mano Aperta',
                        instruction: 'Mostra la mano aperta davanti alla camera e mantieni la posizione',
                        measure: 'pinchOpen',
                        duration: 2000
                    },
                    {
                        icon: '🤏',
                        title: 'Pinch',
                        instruction: 'Avvicina pollice e indice (pinch) e mantieni la posizione',
                        measure: 'pinchClosed',
                        duration: 2000
                    },
                    {
                        icon: '✊',
                        title: 'Pugno',
                        instruction: 'Chiudi la mano a pugno e mantieni la posizione',
                        measure: 'fistClosed',
                        duration: 2000
                    },
                    {
                        icon: '😊',
                        title: 'Sorriso',
                        instruction: 'Sorridi alla camera e mantieni il sorriso',
                        measure: 'smile',
                        duration: 2000
                    }
                ];

                this.currentStep = 0;
                this.samples = [];
                this.sampleTimer = null;
                this.isActive = false;

                this.skipBtn.addEventListener('click', () => this.skip());
            }

            start() {
                this.isActive = true;
                state.isCalibrating = true;
                this.currentStep = 0;
                this.overlay.classList.remove('hidden');
                this.showStep();
            }

            skip() {
                this.isActive = false;
                state.isCalibrating = false;
                this.overlay.classList.add('hidden');
                if (this.sampleTimer) clearInterval(this.sampleTimer);
            }

            showStep() {
                if (this.currentStep >= this.steps.length) {
                    this.finish();
                    return;
                }

                const step = this.steps[this.currentStep];
                this.icon.textContent = step.icon;
                this.title.textContent = step.title;
                this.instruction.textContent = step.instruction;
                this.progress.style.width = '0%';
                this.value.textContent = '—';
                this.samples = [];

                // Raccogli samples per la durata specificata
                let elapsed = 0;
                const interval = 100;

                this.sampleTimer = setInterval(() => {
                    elapsed += interval;
                    const percent = (elapsed / step.duration) * 100;
                    this.progress.style.width = percent + '%';

                    // Raccogli sample in base al tipo di misura
                    const sample = this.getSample(step.measure);
                    if (sample !== null) {
                        this.samples.push(sample);
                        this.value.textContent = sample.toFixed(2);
                    }

                    if (elapsed >= step.duration) {
                        clearInterval(this.sampleTimer);
                        this.processStep(step.measure);
                    }
                }, interval);
            }

            getSample(measureType) {
                switch (measureType) {
                    case 'pinchOpen':
                    case 'pinchClosed':
                        if (state.handDetected) {
                            return gestureDetector.getPinchDistance();
                        }
                        break;
                    case 'fistClosed':
                        if (state.handDetected) {
                            return gestureDetector.getClosedFingers();
                        }
                        break;
                    case 'smile':
                        if (state.faceDetected) {
                            return faceGestureDetector.smileRatio;
                        }
                        break;
                }
                return null;
            }

            processStep(measureType) {
                if (this.samples.length < 5) {
                    // Non abbastanza samples, usa default
                    this.currentStep++;
                    setTimeout(() => this.showStep(), 500);
                    return;
                }

                // Calcola media (scarta outliers)
                this.samples.sort((a, b) => a - b);
                const trimmed = this.samples.slice(
                    Math.floor(this.samples.length * 0.2),
                    Math.floor(this.samples.length * 0.8)
                );
                const avg = trimmed.reduce((a, b) => a + b, 0) / trimmed.length;

                // Applica calibrazione
                switch (measureType) {
                    case 'pinchOpen':
                        CONFIG.calibrated.pinchMax = avg;
                        // Aggiorna soglie con margine
                        CONFIG.pinch.exit = avg * 0.7;
                        break;
                    case 'pinchClosed':
                        CONFIG.calibrated.pinchMin = avg;
                        CONFIG.pinch.enter = avg * 1.3;
                        break;
                    case 'fistClosed':
                        CONFIG.calibrated.fistClosed = Math.round(avg);
                        CONFIG.fist.enter = Math.max(2, Math.round(avg) - 1);
                        break;
                    case 'smile':
                        CONFIG.smile.enter = avg * 0.8;
                        CONFIG.smile.exit = avg * 0.5;
                        break;
                }

                console.log(`Calibrato ${measureType}:`, avg);
                this.value.textContent = '✓ ' + avg.toFixed(2);

                this.currentStep++;
                setTimeout(() => this.showStep(), 800);
            }

            finish() {
                this.isActive = false;
                state.isCalibrating = false;
                this.overlay.classList.add('hidden');

                console.log('Calibrazione completata:', CONFIG.calibrated);
                console.log('Soglie pinch:', CONFIG.pinch);
                console.log('Soglie fist:', CONFIG.fist);
                console.log('Soglie smile:', CONFIG.smile);
            }
        }

        // === UI UPDATES ===
        function updateUI() {
            document.getElementById('flake-count').textContent = snow.snowflakes.length;
            document.getElementById('density-value').textContent = state.density;
            document.getElementById('zoom-value').textContent = state.zoom.toFixed(2) + '×';
            document.getElementById('hand-distance').textContent =
                state.handDetected ? (state.handSize * 100).toFixed(0) + '%' : '—';

            // Debug values - usa soglie con isteresi
            const pinchDist = gestureDetector.debugPinchDist;
            const isPinch = state.wasPinching;  // Usa stato isteresi
            document.getElementById('pinch-dist').textContent =
                state.handDetected ? pinchDist.toFixed(2) + (isPinch ? ' ✓' : '') : '—';
            document.getElementById('pinch-dist').style.color = isPinch ? '#4caf50' : '#fff';

            const closedFingers = gestureDetector.debugClosedFingers;
            const isFist = state.wasFist;  // Usa stato isteresi
            document.getElementById('closed-fingers').textContent =
                state.handDetected ? closedFingers + '/4' + (isFist ? ' ✓' : '') : '—';
            document.getElementById('closed-fingers').style.color = isFist ? '#f44336' : '#fff';

            const indicator = document.getElementById('gesture-indicator');
            indicator.className = 'gesture-indicator gesture-' + state.currentGesture;

            const gestureLabels = {
                'none': 'Nessuna mano rilevata',
                'open': '🖐️ Mano aperta — Zoom attivo',
                'pinch': '🤏 Pinch — Aumento densità',
                'fist': '✊ Pugno — Riduzione densità'
            };
            indicator.textContent = gestureLabels[state.currentGesture];

            // Zoom indicator
            const zoomIndicator = document.getElementById('zoom-indicator');
            zoomIndicator.textContent = '×' + state.zoom.toFixed(1);
            zoomIndicator.style.opacity = state.handDetected ? '1' : '0.15';
            zoomIndicator.classList.toggle('active', state.handDetected);

            // Zoom bar
            const zoomPercent = ((state.zoom - CONFIG.minZoom) / (CONFIG.maxZoom - CONFIG.minZoom)) * 100;
            document.getElementById('zoom-bar-fill').style.height = zoomPercent + '%';
            document.getElementById('zoom-bar-marker').style.bottom = zoomPercent + '%';
            document.getElementById('zoom-bar').style.opacity = state.handDetected ? '1' : '0.3';

            // Vignette effect - bordo blu quando zoom alto, scuro quando basso
            const vignette = document.getElementById('zoom-vignette');
            if (state.handDetected) {
                if (state.zoom > 2.0) {
                    // Zoom in - bordo blu brillante
                    const intensity = (state.zoom - 2.0) / (CONFIG.maxZoom - 2.0);
                    vignette.style.boxShadow = `inset 0 0 ${100 + intensity * 100}px rgba(33, 150, 243, ${0.3 + intensity * 0.4})`;
                } else if (state.zoom < 0.8) {
                    // Zoom out - bordo scuro
                    const intensity = (0.8 - state.zoom) / (0.8 - CONFIG.minZoom);
                    vignette.style.boxShadow = `inset 0 0 ${150 + intensity * 150}px rgba(0, 0, 0, ${0.3 + intensity * 0.5})`;
                } else {
                    vignette.style.boxShadow = 'none';
                }
            } else {
                vignette.style.boxShadow = 'none';
            }

            // Face debug values
            const mouthOpen = faceGestureDetector.mouthOpenRatio;
            const isMouthO = mouthOpen > faceGestureDetector.MOUTH_O_THRESHOLD;
            document.getElementById('mouth-open').textContent =
                state.faceDetected ? (mouthOpen * 100).toFixed(0) + '%' + (isMouthO ? ' 😮' : '') : '—';
            document.getElementById('mouth-open').style.color = isMouthO ? '#ff9800' : '#fff';

            const smile = faceGestureDetector.smileRatio;
            const isSmile = smile > faceGestureDetector.SMILE_THRESHOLD && mouthOpen < 0.3;
            document.getElementById('smile-value').textContent =
                state.faceDetected ? (smile * 100).toFixed(0) + '%' + (isSmile ? ' 😊' : '') : '—';
            document.getElementById('smile-value').style.color = isSmile ? '#e91e63' : '#fff';

            // Face indicator
            const faceIndicator = document.getElementById('face-indicator');
            const faceLabels = {
                'none': 'Nessun volto rilevato',
                'neutral': '😐 Espressione neutra',
                'mouth-o': '😮 Bocca O — Riduzione densità',
                'smile': '😊 Sorriso — Aumento densità'
            };
            faceIndicator.textContent = faceLabels[state.currentFaceGesture];
            faceIndicator.className = 'gesture-indicator gesture-' + state.currentFaceGesture;
        }

        // === MAIN SETUP ===
        const snow = new SnowSystem('snow-canvas');
        const gestureDetector = new GestureDetector();
        const faceGestureDetector = new FaceGestureDetector();
        const calibration = new CalibrationSystem();

        // Inizializza neve
        snow.setDensity(state.density);

        // Setup MediaPipe Holistic (combina Hands + Face Mesh)
        const videoElement = document.getElementById('video');
        const handCanvas = document.getElementById('hand-canvas');
        const handCtx = handCanvas.getContext('2d');

        const holistic = new Holistic({
            locateFile: (file) => {
                return `https://cdn.jsdelivr.net/npm/@mediapipe/holistic/${file}`;
            }
        });

        holistic.setOptions({
            modelComplexity: 1,
            smoothLandmarks: true,
            minDetectionConfidence: 0.5,
            minTrackingConfidence: 0.5,
            refineFaceLandmarks: true
        });

        console.log('Holistic inizializzato');

        holistic.onResults((results) => {
            // Disegna landmarks
            handCtx.save();
            handCtx.clearRect(0, 0, handCanvas.width, handCanvas.height);

            // === MANI ===
            // Holistic restituisce leftHandLandmarks e rightHandLandmarks separatamente
            const handLandmarks = results.rightHandLandmarks || results.leftHandLandmarks;

            if (handLandmarks) {
                state.handDetected = true;
                gestureDetector.update(handLandmarks);

                // Disegna connessioni e landmarks
                drawConnectors(handCtx, handLandmarks, HAND_CONNECTIONS,
                    {color: 'rgba(126, 184, 218, 0.6)', lineWidth: 2});
                drawLandmarks(handCtx, handLandmarks,
                    {color: '#7eb8da', lineWidth: 1, radius: 3});

                // Aggiorna stato
                state.handSize = gestureDetector.getHandSize();
                state.currentGesture = gestureDetector.getGesture();

                // Processa gesture
                processGesture();
            } else {
                state.handDetected = false;
                state.currentGesture = 'none';
                gestureDetector.update(null);
            }

            // === VOLTO ===
            if (results.faceLandmarks) {
                state.faceDetected = true;
                faceGestureDetector.update(results.faceLandmarks);

                state.mouthOpenRatio = faceGestureDetector.mouthOpenRatio;
                state.smileRatio = faceGestureDetector.smileRatio;
                state.currentFaceGesture = faceGestureDetector.getExpression();

                // Processa gesto facciale
                processFaceGesture();
            } else {
                state.faceDetected = false;
                state.currentFaceGesture = 'none';
                faceGestureDetector.update(null);
            }

            handCtx.restore();
            updateUI();
        });

        function processGesture() {
            const now = Date.now();

            // Zoom basato sulla dimensione della mano (sempre attivo quando mano visibile)
            if (state.handDetected) {
                // Mappa dimensione mano (0.1-0.4 tipico) a zoom
                const rawZoom = mapRange(state.handSize, 0.12, 0.45, CONFIG.minZoom, CONFIG.maxZoom);
                state.targetZoom = clamp(rawZoom, CONFIG.minZoom, CONFIG.maxZoom);

                // Smooth zoom
                state.zoom += (state.targetZoom - state.zoom) * CONFIG.smoothingFactor;
            }

            // Debounce per pinch/fist
            if (now - state.lastGestureTime < CONFIG.gestureDebounce) return;

            if (state.currentGesture === 'pinch') {
                state.density = Math.min(CONFIG.maxDensity, state.density + CONFIG.densityStep);
                snow.setDensity(state.density);
                state.lastGestureTime = now;
            } else if (state.currentGesture === 'fist') {
                state.density = Math.max(CONFIG.minDensity, state.density - CONFIG.densityStep);
                snow.setDensity(state.density);
                state.lastGestureTime = now;
            }
        }

        function processFaceGesture() {
            const now = Date.now();

            // Debounce per espressioni facciali
            if (now - state.lastFaceGestureTime < CONFIG.gestureDebounce) return;

            if (state.currentFaceGesture === 'smile') {
                // Sorriso = aumenta densità
                state.density = Math.min(CONFIG.maxDensity, state.density + CONFIG.densityStep);
                snow.setDensity(state.density);
                state.lastFaceGestureTime = now;
            } else if (state.currentFaceGesture === 'mouth-o') {
                // Bocca O = diminuisce densità
                state.density = Math.max(CONFIG.minDensity, state.density - CONFIG.densityStep);
                snow.setDensity(state.density);
                state.lastFaceGestureTime = now;
            }
        }

        // === UTILITIES ===
        function mapRange(value, inMin, inMax, outMin, outMax) {
            return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
        }

        function clamp(value, min, max) {
            return Math.min(Math.max(value, min), max);
        }

        // === VOICE COMMAND HANDLER ===
        function handleVoiceCommand(action, value) {
            switch (action) {
                case 'add':
                    state.density = Math.min(CONFIG.maxDensity, state.density + value);
                    snow.setDensity(state.density);
                    break;

                case 'remove':
                    state.density = Math.max(CONFIG.minDensity, state.density - value);
                    snow.setDensity(state.density);
                    break;

                case 'set':
                    state.density = clamp(value, CONFIG.minDensity, CONFIG.maxDensity);
                    snow.setDensity(state.density);
                    break;

                case 'reset':
                    state.density = CONFIG.initialDensity;
                    state.zoom = 1.0;
                    state.snowColor = 'white';
                    state.isRainbow = false;
                    state.isAnimating = true;
                    snow.setDensity(state.density);
                    break;

                case 'stop':
                    state.isAnimating = false;
                    break;

                case 'start':
                    state.isAnimating = true;
                    break;

                case 'color':
                    if (value === 'rainbow') {
                        state.isRainbow = true;
                    } else {
                        state.isRainbow = false;
                        state.snowColor = value;
                    }
                    break;

                case 'zoom':
                    state.zoom = clamp(value, CONFIG.minZoom, CONFIG.maxZoom);
                    break;

                case 'blizzard':
                    state.density = CONFIG.maxDensity;
                    state.zoom = 2.5;
                    snow.setDensity(state.density);
                    break;

                case 'calm':
                    state.density = 50;
                    state.zoom = 0.8;
                    snow.setDensity(state.density);
                    break;

                case 'calibrate':
                    calibration.start();
                    break;
            }

            updateUI();
        }

        // Inizializza controller vocale
        const voiceController = new VoiceController(handleVoiceCommand);

        // === ANIMATION LOOP ===
        function animate() {
            if (state.isAnimating) {
                snow.update(state.zoom, state.snowColor, state.isRainbow);
            }
            requestAnimationFrame(animate);
        }

        // === START CAMERA ===
        async function startCamera() {
            try {
                const stream = await navigator.mediaDevices.getUserMedia({
                    video: {
                        width: 640,
                        height: 480,
                        facingMode: 'user'
                    }
                });

                videoElement.srcObject = stream;
                await videoElement.play();

                // Setup dimensioni canvas hand overlay
                handCanvas.width = videoElement.videoWidth;
                handCanvas.height = videoElement.videoHeight;

                // Inizia processing con Holistic
                const camera = new Camera(videoElement, {
                    onFrame: async () => {
                        await holistic.send({image: videoElement});
                    },
                    width: 640,
                    height: 480
                });

                await camera.start();

                // Nascondi loading
                document.getElementById('loading').classList.add('hidden');

                // Avvia animazione neve
                animate();

            } catch (err) {
                console.error('Errore accesso camera:', err);
                document.getElementById('loading').innerHTML = `
                    <div style="color: #e57373; text-align: center; padding: 20px;">
                        <div style="font-size: 2rem; margin-bottom: 15px;">📷</div>
                        <div>Impossibile accedere alla webcam</div>
                        <div style="font-size: 0.85rem; margin-top: 10px; color: #888;">
                            Verifica i permessi del browser
                        </div>
                    </div>
                `;
            }
        }

        // Avvia
        startCamera();
    </script>
</body>
</html>

Leave a comment


Benvenuto su Salahzar.com

Qui trovi analisi critiche sull’intelligenza artificiale e le sue implicazioni sociali, scritte da chi viene da una impostazione umanistica e ha passato vent’anni a costruire mondi virtuali prima che diventassero “metaverso”.

Niente hype da Silicon Valley o entusiasmi acritici: sul tavolo ci sono le contraddizioni dell’innovazione tecnologica, i suoi miti fondativi, le narrazioni che usiamo per darle senso. Dai diari ucronici (storie alternative come strumento per capire i nostri bias cognitivi) alle newsletter settimanali sugli sviluppi dell’AI che richiedono aggiornamenti continui perché i trimestri sono già preistoria.

Se cerchi guide su come “fare soldi con ChatGPT” o liste di prompt miracolosi, sei nel posto sbagliato. Se invece ti interessa capire cosa sta succedendo davvero – tra hype, opportunità concrete e derive distopiche – sei nel posto giusto.

Umanesimo digitale senza retorica, analisi senza paternalismi, ironia senza cinismo.


Join the Club

Stay updated with our latest tips and other news by joining our newsletter.