
Chismes del Lince 38: El theremin de Psique de Plata
Era casi medianoche y el Lince de la Noche seguía una melodía por el pinsapar de Sierra Bermeja. Sonaba a piano, lo cual era un problema, porque en mitad del monte no hay pianos.
Pero lo había. En la vieja casa forestal quedaba un piano vertical, medio desafinado, que alguien abandonó hace décadas y que nadie había conseguido bajar de allí. Frente a él, con los ojos cerrados, tocaba Psique de Plata.
—No te quedes en la puerta, Lince —dijo sin dejar de tocar—. Se oye tu cabeza desde aquí. Estás pensando muy fuerte en la cena.
El Lince se sentó en el suelo, muy quieto. Sabía que Psique era una mutante con poderes psíquicos, y que no era un don cómodo. Ella se lo había contado una vez: de niña, cuando sus poderes empezaron a despertar, oía a la vez la mente de toda la gente que tenía alrededor, cientos de voces mezcladas. Lo único que las apagaba era el piano, porque tocarlo bien exige tanta concentración que no queda sitio para nada más.
—Hoy hay romería en el pueblo de abajo —explicó, con voz tranquila, pero apretando una tecla más fuerte de la cuenta—. Dos mil personas, dos mil voces. Necesito tocar y no puedo cargar con un piano a todas partes.
—Resuelto —dijo el Lince, levantándose con aire solemne—. Construiré un piano portátil de 88 teclas, con servomotores, un brazo hidráulico y…
—…y un camión para llevarlo —dijo una voz desde la ventana. La Gata Montesa estaba en el alféizar, con la cola colgando—. Lince, ¿cuántos pianos has movido en tu vida?
—Ninguno. Pero tengo mucha ilusión.
—Psique no necesita teclas. Necesita algo que le exija concentración total, sea donde sea. —La Gata sacó una placa Heltec de la mochila—. Esto tiene sensores táctiles dentro. Notan una mano cuando se acerca mucho, sin que llegue a tocarlos. Con eso se toca en el aire. Se llama theremin.
Psique abrió un ojo y dejó de tocar por primera vez en toda la noche.
—Un instrumento que se toca sin tocarlo —dijo—. Si me despisto medio segundo, desafina. —Sonrió—. Perfecto.
El Lince miró el piano, miró la placa y suspiró.
—¿Y mi piano de 88 teclas con servomotores?
—Lo guardamos para cuando tengamos presupuesto y un camión —dijo la Gata.
Introducción
Hoy construimos un ESP32 theremin: un instrumento que se toca moviendo la mano en el aire, sin tocar nada. La placa Heltec mide lo cerca que está tu mano de un cable, y el móvil convierte esa distancia en una nota.
No necesitas ningún sensor extra. Con la placa, un cable de puente (jumper) y un móvil con Chrome es suficiente, así que se puede hacer hoy mismo en casa.
La cadena es muy corta: mano → sensor táctil de la placa → Bluetooth → móvil → sonido. Pero en el camino aparecen tres ideas importantes: cómo puede una placa «notar» una mano a distancia, por qué la música se calcula con potencias de dos y por qué un dato ruidoso suena fatal si no lo suavizamos.
Por qué este chisme
Psique necesita un instrumento que pueda llevar a cualquier parte y que le exija atención total. Un theremin cumple las dos cosas: cabe en una mochila y no perdona despistes, porque la nota depende de dónde tengas la mano en cada instante.
También es la primera vez en la serie que usamos el sensor táctil capacitivo del ESP32, un sensor que ya viene dentro de la placa y casi nadie usa. Y es nuestra primera sonificación: convertir un dato en sonido.
La sonificación es una técnica real de la ciencia de datos. Hay quien «escucha» series de datos para detectar patrones que en una gráfica se escapan. Mantiene la línea de AIDARAC: sensor → dato → análisis, aquí con el oído como herramienta de análisis.
Cómo funciona un ESP32 theremin
La Gata plantea la primera pregunta: ¿cómo puede una placa notar una mano que ni siquiera la toca? La respuesta está en la capacidad eléctrica.
Un condensador son dos conductores separados por un aislante. Tu mano y el cable de la placa son dos conductores, y el aire que hay entre ellos hace de aislante. Cuanto más cerca está la mano, más capacidad hay entre ambos. Se mide en faradios (F), aunque aquí hablamos de cantidades minúsculas, del orden de los picofaradios.
Qué hace el ESP32
El pin táctil se carga y se descarga una y otra vez, y la placa cuenta cuántas veces lo consigue en un tiempo fijo. Con más capacidad tarda más en cargarse, así que cuenta menos. La función touchRead() devuelve ese contador: número alto = mano lejos, número bajo = mano cerca.
Ese número no tiene unidades de física: es un contador, no una medida de capacidad calibrada. Cada placa, cada cable y cada día dan valores distintos, por eso nuestra página tendrá un paso de calibración.
De distancia a nota
Psique no quiere una sirena que sube y baja: quiere música. En música, subir una octava es multiplicar por dos la frecuencia (Hz), y cada octava se divide en 12 semitonos. Entre un semitono y el siguiente, la frecuencia se multiplica por 2^(1/12), unas 1,0595 veces.
Por eso la nota se calcula con una potencia. Si partimos del La de 220 Hz y subimos s semitonos:
f = 220 \cdot 2^{s/12}
Con s entre 0 y 24 tenemos dos octavas, de 220 Hz a 880 Hz. La página convierte la posición de la mano (de 0 a 1) en s, y s en frecuencia para un oscilador de Web Audio, el mismo truco de sonido sin archivos que usamos en el chisme del telégrafo.
El dato tiembla: hay que suavizarlo
Un sensor real nunca da el mismo número dos veces seguidas. Si enviamos cada lectura tal cual, la nota tiembla como una vela al viento. La solución es un filtro de suavizado: cada nueva lectura solo mueve un poco el valor anterior.
suave = suave + \alpha \cdot (nuevo - suave)
Con α cercano a 0 el sonido es muy estable pero tarda en reaccionar. Con α cercano a 1 reacciona al instante, pero tiembla. Elegir α es decidir entre estabilidad y rapidez, y no hay un valor «correcto»: lo decide el músico.
Un theremin de verdad
El theremin original se inventó hacia 1920 y funciona con dos antenas y dos osciladores de radio: una antena controla el tono y otra el volumen. Nuestra versión imita el gesto con un sensor digital, así que no es lo mismo, y conviene decírselo al alumnado. Es un instrumento de juguete inspirado en uno real.
Qué necesitamos
Este chisme usa solo lo que ya tienes. No hay que soldar ni comprar nada.
- Una placa Heltec o TTGO LoRa32 (ESP32), con su cable USB.
- Un cable de puente (jumper) de los que acaban en un pin suelto.
- Opcional: un trocito de papel de aluminio para hacer una bola en el extremo libre del cable.
- Un móvil Android o un ordenador con Chrome (el navegador que permite Web Bluetooth).
- En el Arduino IDE: la placa «ESP32 Dev Module» y las bibliotecas Adafruit SSD1306 y Adafruit GFX, las mismas de los chismes anteriores.
El móvil hace de altavoz. Un altavoz Bluetooth aparte también sirve: si el móvil suena por él, Psique tiene un instrumento de verdad.
Construcción
La antena es un solo cable. Montarla lleva menos tiempo que subir el programa.
- Conecta un extremo del cable de puente al pin GPIO 13 de la placa, el mismo que usamos en el chisme 36. El otro extremo se queda libre, al aire.
- Si quieres más alcance, enrolla papel de aluminio en el extremo libre. Más superficie significa más capacidad y la mano se nota desde más lejos.
- En el Arduino IDE abre el programa de la placa (está en la sección siguiente), elige «ESP32 Dev Module» y súbelo.
- Al encender, mantén la mano lejos 2 segundos. La placa apunta lo que lee sin mano: es el reposo.
- Acerca la mano al cable. En la pantalla, el número «Valor» tiene que bajar y la barra, llenarse.
Los valores dependen de la placa, del cable y hasta del día, así que no hay un número «correcto». Apunta el que sale con la mano lejos y el que sale con el dedo casi tocando el cable: los usaremos más tarde.
Para ver la señal dibujada, abre en el Arduino IDE Herramientas → Serial Plotter a 115200 baudios. Es la forma más rápida de entender qué está midiendo la placa.
Si la lectura salta mucho sin tocar nada, prueba con otra fuente de alimentación, por ejemplo una batería externa en lugar del cargador del portátil. Algunas fuentes meten ruido eléctrico que el sensor también nota.



El código de la placa
El programa tiene tres partes y solo la primera es el chisme. La PARTE 1 es la antena: mide cuánto cae el número del sensor cuando acercas la mano. La PARTE 2 (la pantalla) y la PARTE 3 (el Bluetooth) puedes copiarlas sin leerlas.
Antes de subirlo, lee y predice (apunta la respuesta y luego comprueba):
- Si en
MUESTRASpones 1 en lugar de 8, ¿la lectura será más estable o más temblorosa? - Si pones la mano cerca durante el arranque, ¿qué valor de reposo tendrá la placa y qué pasará después con la barra de la pantalla?
- Si cambias
PIN_ANTENAa 14, ¿hay que mover el cable? ¿Por qué ese número y no cualquiera?
/*
Chisme 38 · El theremin de Psique de Plata
Acercas la mano a un cable de la placa y el movil toca una nota: cuanto mas
cerca la mano, mas aguda la nota. La placa mide con su sensor tactil
(capacitivo), lo manda por Bluetooth y la pagina del movil hace el sonido.
Este programa tiene 3 partes. Solo la PARTE 1 es el chisme:
PARTE 1 · LA ANTENA <- LEE ESTA. Mide lo cerca que esta tu mano.
PARTE 2 · LA PANTALLA <- NO ES NECESARIA. Solo dibuja el valor en la OLED.
PARTE 3 · EL BLUETOOTH <- Como en el chisme 37. No hace falta tocarla.
(y al final, setup() y loop())
COMO CONECTARLO (sin soldar):
Un cable de puente (jumper) con un extremo en el pin GPIO 13 de la placa y
el otro extremo LIBRE, al aire. Ese cable es la antena.
Mejora: enrolla en el extremo libre una bola de papel de aluminio. Mas superficie
= mas distancia a la que nota tu mano.
IMPORTANTE: al encender, manten la mano LEJOS de la antena durante 2 segundos.
La placa apunta como "lee" el pin sin mano (el REPOSO).
PUERTO SERIE: Herramientas -> Monitor Serie, a 115200 baudios.
(O mejor: Herramientas -> Serial Plotter, para ver la senal dibujada.)
Librerias: Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
Si al compilar dice "Sketch too big":
Herramientas -> Partition Scheme -> "Huge APP (3MB No OTA/1MB SPIFFS)".
*/
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
/* ============================================================
PARTE 1 · LA ANTENA (lo importante)
touchRead() cuenta cuantas veces puede cargar y descargar el pin en un
tiempo fijo. Cuanta mas capacidad hay (tu mano cerca), menos veces lo logra:
numero ALTO = mano lejos numero BAJO = mano cerca
Ese numero NO tiene unidades: es un contador, no una medida calibrada.
El pin 13 es un pin tactil del ESP32 (T4). Otros: 2, 4, 12, 14, 15, 27, 32, 33.
============================================================ */
#define PIN_ANTENA 13 // el cable-antena va aqui (GPIO 13)
const int MUESTRAS = 8; // cuantas lecturas promediamos cada vez
int reposo = 0; // como lee el pin SIN mano cerca
int valor = 0; // la ultima lectura
int leerAntena() { // media de varias lecturas: menos temblor
long suma = 0;
for (int i = 0; i < MUESTRAS; i++) suma += touchRead(PIN_ANTENA);
return suma / MUESTRAS;
}
void calibrarReposo() { // 30 lecturas en 1,5 s, con la mano LEJOS
long suma = 0;
for (int i = 0; i < 30; i++) {
suma += leerAntena();
delay(50);
}
reposo = suma / 30;
Serial.println(String("Reposo = ") + reposo);
}
/* ============================================================
PARTE 2 · LA PANTALLA (NO ES NECESARIA: el theremin funciona sin ella)
Escribe el valor y dibuja una barra: mas llena = mano mas cerca.
============================================================ */
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16
#define OLED_ADDR 0x3C
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
volatile bool conectado = false; // ¿hay un movil conectado?
void pintarPantalla(String aviso) {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "THEREMIN - conectado" : "THEREMIN - esperando");
display.setTextSize(2);
display.setCursor(0, 14);
display.print(String("Valor ") + valor);
display.setTextSize(1);
display.setCursor(0, 34);
display.print(String("Reposo ") + reposo);
// La barra se llena del todo cuando el valor cae al 30 % del reposo
int ancho = 0;
if (reposo > 0) ancho = constrain((long)(reposo - valor) * 100 / (reposo * 70 / 100), 0, 100) * 126 / 100;
display.drawRect(0, 46, 128, 8, SSD1306_WHITE);
display.fillRect(1, 47, ancho, 6, SSD1306_WHITE);
display.setCursor(0, 56);
display.print(aviso);
display.display();
}
void arrancarPantalla() {
Wire.begin(OLED_SDA, OLED_SCL);
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW); delay(20); digitalWrite(OLED_RST, HIGH);
display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
}
/* ============================================================
PARTE 3 · EL BLUETOOTH (como en el chisme 37)
La placa se anuncia como "Heltec-Theremin". Tiene dos "buzones":
TX: por aqui la placa AVISA a la pagina:
"V73" = lectura actual de la antena (25 veces por segundo)
"R78" = el valor de REPOSO (sin mano)
RX: por aqui LLEGAN las ordenes de la pagina:
"R" = vuelve a calibrar el reposo (con la mano LEJOS) "?" = dime el reposo
============================================================ */
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_RX "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_TX "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
BLECharacteristic *tx = nullptr;
String ordenPendiente = ""; // orden llegada del movil (la atiende loop())
volatile bool hayOrden = false;
volatile bool enviarReposo = false; // ¿hay que mandar el reposo al movil?
class AlConectar : public BLEServerCallbacks {
void onConnect(BLEServer *s) { conectado = true; enviarReposo = true; }
void onDisconnect(BLEServer *s) { conectado = false; BLEDevice::startAdvertising(); }
};
class AlEscribir : public BLECharacteristicCallbacks { // llega algo desde la pagina
void onWrite(BLECharacteristic *c) {
ordenPendiente = String(c->getValue().c_str()); // la guardamos; loop() la atendera
hayOrden = true;
}
};
void avisar(String texto) { // manda un mensaje corto a la pagina
if (!conectado || !tx) return;
tx->setValue((uint8_t *)texto.c_str(), texto.length());
tx->notify();
}
void atenderOrden(String orden) {
if (orden.length() == 0) return;
if (orden[0] == 'R') { // R = calibra de nuevo, mano LEJOS
pintarPantalla("Mano LEJOS...");
calibrarReposo();
}
enviarReposo = true; // 'R' y '?' acaban igual: mandamos el reposo
}
void arrancarBluetooth() {
BLEDevice::init("Heltec-Theremin");
BLEServer *servidor = BLEDevice::createServer();
servidor->setCallbacks(new AlConectar());
BLEService *svc = servidor->createService(SERVICE_UUID);
tx = svc->createCharacteristic(UUID_TX, BLECharacteristic::PROPERTY_NOTIFY);
tx->addDescriptor(new BLE2902());
BLECharacteristic *rx = svc->createCharacteristic(UUID_RX,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
rx->setCallbacks(new AlEscribir());
svc->start();
BLEAdvertising *pub = BLEDevice::getAdvertising();
pub->addServiceUUID(SERVICE_UUID);
pub->setScanResponse(true);
BLEDevice::startAdvertising();
}
/* ============================================================
setup() se ejecuta UNA vez al encender. loop() se repite sin parar.
============================================================ */
void setup() {
Serial.begin(115200);
arrancarPantalla();
pintarPantalla("Mano LEJOS 2 s...");
delay(300);
calibrarReposo(); // apunta como lee el pin SIN mano
arrancarBluetooth();
Serial.println("Listo. Acerca la mano al cable.");
}
unsigned long ultimaLectura = 0;
unsigned long ultimaPantalla = 0;
void loop() {
if (hayOrden) { // ¿ha llegado una orden del movil?
hayOrden = false;
atenderOrden(ordenPendiente);
}
if (enviarReposo) { // ¿toca mandar el reposo?
enviarReposo = false;
avisar(String("R") + reposo);
}
if (millis() - ultimaLectura >= 40) { // 25 veces por segundo...
ultimaLectura = millis();
valor = leerAntena(); // ...medimos la antena
Serial.println(valor); // (el Serial Plotter la dibuja)
avisar(String("V") + valor); // ...y se la mandamos al movil
}
if (millis() - ultimaPantalla >= 150) { // la pantalla se refresca mas despacio
ultimaPantalla = millis();
pintarPantalla(conectado ? "Mueve la mano" : "Abre la pagina");
}
delay(2);
}
La página del móvil
Guárdala como theremin.html y ábrela con Chrome. En el código, la PARTE 1 convierte el número del sensor en una nota y la PARTE 2 hace sonar el oscilador. Las partes 3 y 4 (pantalla y Bluetooth) no hace falta leerlas.
La página tiene un botón Probar sin placa: inventa lecturas con un poco de ruido y un deslizador hace de mano. Así puedes oír el theremin antes de conectar nada, y la clase puede probarlo aunque falte alguna placa.
Lee y predice:
- ¿Qué nota sonará con la mano a mitad de camino (posición 0,5, o sea, 12 semitonos por encima del La de 220 Hz)?
- ¿Qué pasa con el sonido si pones α = 1 y qué pasa si pones α = 0,05?
- En la escala pentatónica, ¿puede sonar una nota fuera de la escala aunque muevas la mano a lo loco?
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chisme 38 · El theremin de Psique de Plata</title>
<style>
/* Los colores, en un solo sitio: cámbialos y cambia toda la página */
:root { --fondo:#eef2ef; --texto:#10201c; --suave:#4d625b; --verde:#1f5b4a;
--bruto:#9aa9a3; --linea:#7b3fa0; --aviso:#ffd23f; }
@media (prefers-color-scheme: dark) {
:root { --fondo:#0f1a17; --texto:#e8f1ed; --suave:#9db3aa; --verde:#5fc4a2; --linea:#c79bff; }
}
body { margin:0; padding:1.5rem 1rem; background:var(--fondo); color:var(--texto);
font:18px/1.45 system-ui, sans-serif; text-align:center; }
h1 { font-size:1.5rem; margin:0 0 .3rem; }
p { color:var(--suave); margin:.3rem 0; }
button { font:inherit; font-weight:700; padding:.7rem 1.1rem; margin:.3rem;
border:2px solid var(--verde); background:var(--verde); color:var(--fondo);
border-radius:.9rem; cursor:pointer; touch-action:manipulation; }
button:disabled { opacity:.45; cursor:not-allowed; }
/* La nota, en grande */
#nota { font-size:4rem; font-weight:700; min-height:5rem; margin:.8rem auto 0; color:var(--linea); }
#nota.callada { color:var(--suave); font-size:2rem; padding-top:1.6rem; }
#frecuencia { min-height:1.6rem; }
#barra { height:1.1rem; max-width:30rem; margin:.5rem auto; border:2px solid var(--suave);
border-radius:.6rem; overflow:hidden; }
#relleno { height:100%; width:0; background:var(--linea); }
canvas { width:100%; max-width:34rem; height:9rem; display:block; margin:.6rem auto;
border-bottom:2px solid var(--suave); }
#simulado { display:none; background:var(--aviso); color:#10201c; font-weight:700; padding:.6rem 1rem;
border-radius:.9rem; max-width:34rem; margin:.8rem auto; }
#simulado.visible { display:block; }
.fila { max-width:34rem; margin:.6rem auto; display:flex; align-items:center; gap:.6rem;
justify-content:center; flex-wrap:wrap; }
.fila label { color:var(--suave); }
input[type=range] { width:12rem; }
select { font:inherit; padding:.3rem; border-radius:.5rem; }
#datos { font-family:ui-monospace, monospace; font-size:.95rem; color:var(--suave); }
</style>
</head>
<body>
<h1>El theremin de Psique de Plata</h1>
<p>Acerca la mano al cable de la placa. Cuanto más cerca, más aguda la nota.</p>
<p id="estado">Sin conectar.</p>
<button id="botonConectar">Conectar la placa</button>
<button id="botonSimular">Probar sin placa</button>
<div id="simulado">DATOS SIMULADOS: mueve el deslizador «Mano simulada». No viene de la placa.</div>
<!-- aria-live: los lectores de pantalla anuncian solos cada cambio -->
<div id="nota" class="callada" aria-live="polite">Mano lejos</div>
<div id="frecuencia"></div>
<div id="barra"><div id="relleno"></div></div>
<canvas id="grafica" width="680" height="180" aria-label="Gráfica de la señal: gris = dato bruto, morado = dato suavizado"></canvas>
<p>Gris: lo que mide el sensor. Morado: el dato ya suavizado.</p>
<p id="datos">Sin datos.</p>
<div class="fila" id="filaSimulada" style="display:none">
<label for="manoSimulada">Mano simulada</label>
<input type="range" id="manoSimulada" min="0" max="100" value="0">
</div>
<div class="fila">
<label for="suavizado" id="etiquetaSuavizado">Suavizado α = 0,30</label>
<input type="range" id="suavizado" min="5" max="100" value="30">
</div>
<div class="fila">
<label for="volumen">Volumen</label>
<input type="range" id="volumen" min="0" max="100" value="30">
</div>
<div class="fila">
<label for="escala">Notas</label>
<select id="escala">
<option value="continuo">Continuo (sirena)</option>
<option value="cromatica">Todos los semitonos</option>
<option value="mayor">La mayor</option>
<option value="pentatonica" selected>Pentatónica de La (no suena mal)</option>
</select>
<label for="forma">Sonido</label>
<select id="forma">
<option value="sine" selected>Seno (suave)</option>
<option value="triangle">Triángulo</option>
<option value="square">Cuadrada (áspera)</option>
</select>
</div>
<button id="botonCerca" disabled>Calibrar mano cerca</button>
<button id="botonReposo" disabled>Recalibrar reposo</button>
<button id="botonSilencio">Silenciar</button>
<button id="botonCSV" disabled>Descargar CSV</button>
<p id="mensaje"></p>
<script>
/* ============================================================
PARTE 1 · DEL NÚMERO A LA NOTA (léela)
La placa nos AVISA 25 veces por segundo con mensajes cortos:
"V73" la lectura del sensor táctil (número ALTO = mano lejos, BAJO = mano cerca)
"R78" el valor de REPOSO: lo que lee la placa sin ninguna mano cerca
Aquí hacemos tres cosas: suavizar -> calcular posición (0 a 1) -> convertir en nota.
============================================================ */
const NOTA_BASE_HZ = 220; // La3: la nota más grave
const SEMITONOS = 24; // dos octavas: de 220 Hz a 880 Hz
const UMBRAL_MANO = 0.08; // por debajo de esta posición no hay mano: silencio
const NOMBRES = ["Do", "Do#", "Re", "Re#", "Mi", "Fa", "Fa#", "Sol", "Sol#", "La", "La#", "Si"];
const ESCALAS = { // qué semitonos (desde el La) están permitidos en cada octava
continuo: null, // null = cualquier frecuencia
cromatica: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
mayor: [0, 2, 4, 5, 7, 9, 11],
pentatonica: [0, 2, 4, 7, 9]
};
let reposo = null; // lo que lee la placa sin mano
let cerca = null; // lo que lee con la mano lo más cerca que quieres tocar
let suave = null; // el dato suavizado
let alfa = 0.3; // cuánto pesa cada lectura nueva (0 a 1)
// Filtro de suavizado: cada lectura nueva solo mueve un poco el valor anterior.
function suavizar(nuevo) {
suave = (suave === null) ? nuevo : suave + alfa * (nuevo - suave);
return suave;
}
// Posición de la mano: 0 = lejos (reposo), 1 = lo más cerca (calibrado).
function posicion(valor) {
if (reposo === null) return 0;
const final = (cerca !== null) ? cerca : reposo * 0.3; // si no calibras, suponemos el 30 % del reposo
const rango = reposo - final;
if (rango < 3) return 0; // rango demasiado pequeño: no fiable
return Math.min(1, Math.max(0, (reposo - valor) / rango));
}
// Si hay escala, elegimos el semitono permitido más cercano.
function cuantizar(semitonos, grados) {
if (!grados) return semitonos;
let mejor = 0, distancia = Infinity;
for (let octava = 0; octava <= 2; octava++) {
for (const grado of grados) {
const candidato = 12 * octava + grado;
if (candidato > SEMITONOS) continue;
const d = Math.abs(candidato - semitonos);
if (d < distancia) { distancia = d; mejor = candidato; }
}
}
return mejor;
}
// ¡La fórmula de la música! f = 220 · 2^(s/12)
function frecuencia(semitonos) {
return NOTA_BASE_HZ * Math.pow(2, semitonos / 12);
}
function nombreNota(semitonos) {
const midi = Math.round(57 + semitonos); // el La3 es la nota MIDI 57
return NOMBRES[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1);
}
// Se ejecuta CADA VEZ que llega una lectura (de la placa o simulada).
function procesar(valor) {
const s = suavizar(valor);
const x = posicion(s);
const tocando = x >= UMBRAL_MANO;
const semitono = cuantizar(x * SEMITONOS, ESCALAS[escala]);
const f = frecuencia(semitono);
sonar(tocando, f);
mostrar(valor, s, x, tocando, semitono, f);
if (calibrando) acumularCerca(s);
if (filas.length < 20000) filas.push([Math.round(performance.now()), valor, s.toFixed(1), x.toFixed(3), tocando ? semitono.toFixed(2) : "", tocando ? f.toFixed(1) : "", tocando ? nombreNota(semitono) : ""]);
}
function alRecibirAviso(texto) {
const numero = parseInt(texto.substring(1), 10);
if (isNaN(numero)) return;
if (texto[0] === "R") { // la placa nos dice su reposo
reposo = numero;
suave = null;
document.getElementById("botonCerca").disabled = false;
document.getElementById("botonReposo").disabled = false;
return;
}
if (texto[0] === "V") procesar(numero); // una lectura nueva
}
/* ============================================================
PARTE 2 · EL SONIDO (lo importante, pero corto)
Un oscilador de Web Audio genera la onda. Solo hay que decirle la frecuencia (Hz)
y abrir o cerrar el volumen. setTargetAtTime hace que el cambio sea suave (sin clics).
============================================================ */
let audio = null, oscilador = null, ganancia = null;
let escala = "pentatonica", volumen = 0.15, silencio = false;
function prepararAudio() { // hay que llamarla al pulsar un botón: el navegador lo exige
if (!audio && window.AudioContext) {
audio = new AudioContext();
ganancia = audio.createGain();
ganancia.gain.value = 0;
ganancia.connect(audio.destination);
oscilador = audio.createOscillator();
oscilador.type = document.getElementById("forma").value;
oscilador.connect(ganancia);
oscilador.start();
}
if (audio && audio.state === "suspended") audio.resume();
}
function sonar(tocando, f) {
if (!audio) return;
const t = audio.currentTime;
oscilador.frequency.setTargetAtTime(f, t, escala === "continuo" ? 0.03 : 0.01);
ganancia.gain.setTargetAtTime((tocando && !silencio) ? volumen : 0, t, 0.03);
}
/* ============================================================
PARTE 3 · LA PANTALLA Y LOS BOTONES (NO ES NECESARIA para que suene)
Dibuja la nota, la gráfica y los datos; calibra; simula; descarga el CSV.
============================================================ */
const historialBruto = [], historialSuave = []; // las últimas 150 lecturas
const filas = [["ms", "bruto", "suave", "posicion", "semitonos", "hz", "nota"]];
let calibrando = null;
function mostrar(valor, s, x, tocando, semitono, f) {
const nota = document.getElementById("nota");
nota.classList.toggle("callada", !tocando);
nota.textContent = tocando ? nombreNota(semitono) : "Mano lejos";
document.getElementById("frecuencia").textContent = tocando ? Math.round(f) + " Hz" : "";
document.getElementById("relleno").style.width = Math.round(x * 100) + "%";
document.getElementById("datos").textContent =
"bruto " + valor + " · suave " + s.toFixed(1) + " · reposo " + reposo + " · cerca " + (cerca === null ? "sin calibrar" : cerca.toFixed(1));
historialBruto.push(valor); historialSuave.push(s);
if (historialBruto.length > 150) { historialBruto.shift(); historialSuave.shift(); }
dibujar();
}
function dibujar() {
const lienzo = document.getElementById("grafica");
const c = lienzo.getContext("2d");
const estilo = getComputedStyle(document.body);
c.clearRect(0, 0, lienzo.width, lienzo.height);
const maximo = Math.max(reposo || 0, ...historialBruto, 10) * 1.1;
const paso = lienzo.width / 150;
function linea(datos, color, ancho) {
c.beginPath(); c.strokeStyle = color; c.lineWidth = ancho;
datos.forEach((d, i) => {
const y = lienzo.height - (d / maximo) * lienzo.height;
if (i === 0) c.moveTo(0, y); else c.lineTo(i * paso, y);
});
c.stroke();
}
linea(historialBruto, estilo.getPropertyValue("--bruto"), 1.5);
linea(historialSuave, estilo.getPropertyValue("--linea"), 3);
}
function acumularCerca(s) { // mientras calibramos, anotamos los valores
calibrando.suma += s; calibrando.n++;
if (performance.now() > calibrando.fin) {
cerca = calibrando.suma / calibrando.n;
calibrando = null;
document.getElementById("mensaje").textContent = "Calibrado: con la mano cerca el sensor lee " + cerca.toFixed(1) + ".";
}
}
document.getElementById("botonCerca").onclick = function () {
calibrando = { suma: 0, n: 0, fin: performance.now() + 2500 };
document.getElementById("mensaje").textContent = "Acerca la mano todo lo que quieras tocar y déjala quieta 2 segundos...";
};
document.getElementById("botonReposo").onclick = function () {
document.getElementById("mensaje").textContent = "Aleja la mano 2 segundos...";
if (simulando) { reposo = 78; suave = null; return; }
enviarOrden("R"); // la placa vuelve a medir el reposo
};
document.getElementById("botonSilencio").onclick = function () {
silencio = !silencio;
this.textContent = silencio ? "Quitar silencio" : "Silenciar";
};
document.getElementById("volumen").oninput = function () { volumen = this.value / 100 * 0.5; };
document.getElementById("suavizado").oninput = function () {
alfa = this.value / 100;
document.getElementById("etiquetaSuavizado").textContent = "Suavizado α = " + alfa.toFixed(2).replace(".", ",");
};
document.getElementById("escala").onchange = function () { escala = this.value; };
document.getElementById("forma").onchange = function () { if (oscilador) oscilador.type = this.value; };
volumen = document.getElementById("volumen").value / 100 * 0.5;
escala = document.getElementById("escala").value;
document.getElementById("botonCSV").onclick = function () { // los datos se descargan EN TU móvil; no salen a ningún servidor
const texto = filas.map(f => f.join(",")).join("\n");
const enlace = document.createElement("a");
enlace.href = URL.createObjectURL(new Blob([texto], { type: "text/csv" }));
enlace.download = "theremin.csv";
enlace.click();
};
// Modo SIN PLACA: inventamos lecturas con un poco de ruido para poder probar la página.
let simulando = false, relojSimulado = null;
document.getElementById("botonSimular").onclick = function () {
prepararAudio();
simulando = !simulando;
document.getElementById("simulado").classList.toggle("visible", simulando);
document.getElementById("filaSimulada").style.display = simulando ? "flex" : "none";
this.textContent = simulando ? "Dejar de simular" : "Probar sin placa";
clearInterval(relojSimulado);
if (simulando) {
reposo = 78; suave = null;
document.getElementById("botonCerca").disabled = false;
document.getElementById("botonReposo").disabled = false;
document.getElementById("botonCSV").disabled = false;
document.getElementById("estado").textContent = "Simulando: sin placa.";
relojSimulado = setInterval(function () {
const mano = document.getElementById("manoSimulada").value / 100;
const ruido = Math.round(Math.random() * 4 - 2); // ±2: el sensor real nunca es exacto
procesar(Math.round(reposo - mano * 0.7 * reposo) + ruido);
}, 40);
} else {
sonar(false, 220);
document.getElementById("estado").textContent = "Sin conectar.";
}
};
/* ============================================================
PARTE 4 · EL BLUETOOTH (casi igual que en el chisme 37; no hace falta tocarlo)
============================================================ */
const SERVICIO = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const UUID_RX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; // aquí ESCRIBIMOS las órdenes
const UUID_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; // por aquí RECIBIMOS los avisos
let dispositivo, canalOrdenes;
const estado = document.getElementById("estado");
const espera = ms => new Promise(r => setTimeout(r, ms));
async function enviarOrden(orden) {
if (!canalOrdenes) return;
const datos = new TextEncoder().encode(orden);
try {
if (canalOrdenes.writeValueWithoutResponse) await canalOrdenes.writeValueWithoutResponse(datos);
else await canalOrdenes.writeValue(datos);
} catch (e) { estado.textContent = "No se pudo enviar: " + e.message; }
}
async function abrirCanales() {
for (let intento = 1; intento <= 3; intento++) { // a veces falla la primera vez: reintentamos
try {
const servidor = await dispositivo.gatt.connect();
await espera(600);
const servicio = await servidor.getPrimaryService(SERVICIO);
canalOrdenes = await servicio.getCharacteristic(UUID_RX);
const canalAvisos = await servicio.getCharacteristic(UUID_TX);
canalAvisos.addEventListener("characteristicvaluechanged", e => alRecibirAviso(new TextDecoder().decode(e.target.value)));
await canalAvisos.startNotifications();
estado.textContent = "Conectada a " + dispositivo.name + ". Acerca la mano al cable.";
document.getElementById("botonCSV").disabled = false;
enviarOrden("?"); // "?" = dime tu reposo
return;
} catch (e) {
if (intento === 3) throw e;
await espera(800);
}
}
}
document.getElementById("botonConectar").onclick = async function () {
if (!navigator.bluetooth) { estado.textContent = "Este navegador no permite Bluetooth. Usa Chrome."; return; }
prepararAudio(); // al pulsar este botón el navegador nos deja sonar
try {
estado.textContent = "Buscando la placa...";
dispositivo = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [SERVICIO] });
dispositivo.addEventListener("gattserverdisconnected", () => {
estado.textContent = "Conexión perdida. Pulsa «Conectar la placa».";
canalOrdenes = null;
sonar(false, 220);
document.getElementById("botonCerca").disabled = true;
document.getElementById("botonReposo").disabled = true;
});
if (simulando) document.getElementById("botonSimular").click(); // si estaba simulando, paramos
reposo = null; suave = null;
await abrirCanales();
} catch (e) {
estado.textContent = "Error: " + e.message + " (elige «Heltec-Theremin» en la lista).";
}
};
</script>
</body>
</html>
Cómo probarlo
Empieza por lo más fácil, que es no usar la placa.
- Abre la página en Chrome y pulsa Probar sin placa. Sube el volumen del móvil y mueve el deslizador «Mano simulada»: tiene que sonar una melodía distinta en cada posición.
- Cambia entre «Continuo (sirena)» y «Pentatónica». Con la sirena cuesta muchísimo acertar una nota; con la escala, casi imposible sonar mal.
- Ahora pulsa Conectar la placa y elige «Heltec-Theremin». Con la mano lejos, la página dice «Mano lejos» y no suena nada.
- Acerca la mano al cable. La nota sube a medida que te acercas.
- Pulsa Calibrar mano cerca, acerca la mano todo lo que quieras tocar y déjala quieta 2 segundos. Ese será el final de tu teclado invisible.
Si algo falla, mira esto antes de nada:
- No suena: sube el volumen del móvil, comprueba que no está en «Silenciar» y que la mano no está demasiado lejos.
- La pantalla de la placa no cambia al acercar la mano: revisa que el cable esté bien metido en el GPIO 13.
- La nota se mueve sola: sube el suavizado (α más bajo) o aleja el cable de otros cables y del cargador.
- Suena una nota sin mano, o no suena nada aunque te acerques: el reposo se midió mal (por ejemplo, con la mano cerca al encender). Pulsa «Recalibrar reposo» con la mano lejos.
Qué estamos midiendo realmente
Psique toca una nota, pero la placa no mide notas ni distancias. Mide un contador: cuántas veces carga y descarga el pin en un tiempo fijo. Ese número no tiene unidades y no es una medida de capacidad calibrada.
Por eso conviene distinguir entre dato y medida. El dato es el número que sale del sensor. Una medida es un dato con unidades, calibrado y con su incertidumbre conocida. Aquí solo tenemos lo primero: medir no significa medir bien.
Qué cambia el número (además de tu mano)
El valor depende de la longitud del cable, de la bola de aluminio, de la humedad del aire y de la piel, de si tocas la placa con la otra mano y hasta de la fuente de alimentación. Dos placas con el mismo programa darán números distintos, y la misma placa puede dar otros mañana.
Esto también sirve para hablar de correlación y causalidad. Si el valor baja, la mano cerca es una causa probable, pero no la única posible. Para estar seguro hay que repetir la prueba cambiando una sola cosa cada vez.
El ruido y por qué promediamos
Si dejas la placa quieta, el número oscila unas unidades arriba y abajo. Es ruido aleatorio. Si promediamos N lecturas independientes, ese ruido se reduce más o menos √N veces. Con las 8 muestras de la placa, unas 2,8 veces menos.
La página suaviza además con el filtro de α. Cuanto más suavizas, menos tiembla la nota, pero más tarda en reaccionar a tu mano. Con α = 0,3 y 25 lecturas por segundo el retraso es del orden de una décima de segundo. Para un músico, eso se nota.
Distancia no es lo mismo que nota
Nuestra página convierte la caída del número en semitonos de forma lineal. Pero la capacidad no cambia de forma lineal con la distancia: suele cambiar mucho cerca del cable y poco lejos. Así que mover la mano 1 cm cerca de la antena cambia la nota más que moverla 1 cm lejos. Comprobarlo con una regla es uno de los retos.
Privacidad
Todo ocurre en tu móvil y en tu placa. No usamos el micrófono ni la cámara y ningún dato sale a internet. El CSV se descarga en tu propio dispositivo.
Para el aula
El theremin es un buen pretexto para que el alumnado haga ciencia con una sola placa y una regla. Estas actividades van de lo más sencillo a lo más completo.
1. ¿Qué variable medimos?
Antes de tocar, que cada pareja conteste por escrito: ¿qué variable mide el sensor?, ¿en qué unidad?, ¿qué errores puede tener? La respuesta correcta es que mide un contador sin unidades. Descubrirlo ya es el primer resultado de la clase.
2. Distancia contra valor
Pega una regla a la mesa. Con el cable fijo, coloca la mano a 0, 1, 2, 5, 10 y 20 cm y apunta el «Valor» de la pantalla. Repite cada distancia 5 veces. Haz la media y dibuja la gráfica de valor frente a distancia.
Preguntas para discutir: ¿es una recta? ¿a partir de qué distancia el sensor ya no nota la mano? ¿cuánto varían las 5 repeticiones?
3. Una hipótesis con grupo de control
Hipótesis: «una bola de aluminio aumenta el alcance del theremin». Diseña la prueba: define qué significa «alcance» (por ejemplo, la distancia a la que el valor cae 10 unidades), mide con y sin bola y cambia solo esa variable. Que otro grupo repita la prueba con otra placa y compare.
4. Ruido y promedios
Deja la placa quieta un minuto y mira el Serial Plotter. Cambia MUESTRAS de 8 a 1 y vuelve a mirar. ¿Cuánto se reduce el temblor? ¿Coincide con el factor √8 que predice la teoría? Si no coincide, ¿por qué puede ser?
5. Música y matemáticas
Con la fórmula f = 220 · 2^(s/12), calcula a mano la frecuencia de 5 notas. Comprueba que con s = 12 sale justo el doble (440 Hz). ¿Por qué los semitonos no se reparten sumando la misma cantidad de Hz?
6. Un instrumento para todos
Psique necesita un instrumento sin teclas. Pide al alumnado que piense en otras personas que no pueden usar un piano o una guitarra: ¿qué sensores servirían como instrumento? ¿qué tendrían que cambiar en la página para que fuera accesible?
Retos
Cada reto parte del anterior. No hace falta hacerlos todos.
- Haz que funcione. Toca la escala pentatónica de abajo arriba y de arriba abajo con la mano, sin saltarte ninguna nota. Si lo consigues, ya tienes el rango bien calibrado.
- Modifica algo. Cambia
NOTA_BASE_HZa 440 oSEMITONOSa 36 en la página. ¿Qué pasa con el rango del instrumento? Después añade una escala nueva aESCALAS, por ejemplo la pentatónica menor (0, 3, 5, 7, 10). - Añade un sensor. Conecta un segundo cable a otro pin táctil (por ejemplo, el GPIO 32). Que la placa mande su lectura con otra letra (
W) y que la página la use como volumen: un theremin de verdad tiene una antena para el tono y otra para el volumen. - Guarda los datos. Toca durante 30 segundos, descarga el CSV y ábrelo en una hoja de cálculo. ¿Cuántas filas tiene? ¿Cada cuánto tiempo llega una lectura?
- Representa los datos. Dibuja el valor bruto y el suavizado frente al tiempo. Marca con colores los tramos en que la mano estaba lejos, a media distancia y cerca. Haz también la gráfica de valor frente a distancia del apartado del aula.
- Automatiza el proceso. Haz que la placa calcule la nota (la misma fórmula en C++, con
powf) y la escriba en su pantalla. Así Psique verá la nota sin mirar el móvil. - Haz que otro dispositivo participe. Con dos placas y LoRa, como en el chisme 28, una lee la mano y la otra, a distancia, hace sonar un zumbador pasivo. Para hacerlo sonar busca la función
ledcWriteTone. - Conviértelo en una investigación. Elige una pregunta, por ejemplo: «¿depende el alcance de la humedad del aire?». Mide el alcance durante varios días a distintas horas, anota la humedad (el Heltec con DHT22 del chisme 26 puede hacerlo) y concluye qué puedes y qué no puedes afirmar con tus datos.
Qué hemos aprendido
A lo largo del chisme hemos visto que una placa puede «notar» una mano sin que la toque, porque la mano cambia la capacidad eléctrica del cable. El sensor entrega un contador sin unidades, y hay que calibrarlo para que signifique algo.
También hemos aprendido que la música se calcula con potencias de dos, que un dato ruidoso necesita promedios y filtros, y que cada filtro tiene un precio: más estabilidad significa más retraso. Y nos hemos llevado una idea de ciencia de datos, la sonificación: escuchar los datos en lugar de mirarlos.
La noche acabó en la casa forestal. Psique tocó el theremin con los ojos cerrados y la mano flotando sobre el cable. En el pueblo, la romería seguía con sus dos mil voces, pero ya no le llegaban: no había sitio para ellas.
—Ahora déjame probar a mí —pidió el Lince.
El Lince acercó la zarpa al cable y sonó algo parecido a un gato atrapado en una lavadora.
—Eso es un maullido —dijo la Gata—. Pon la pentatónica, que no suena mal ni a ti.
Psique abrió un ojo, sonrió, y por una vez no dijo en qué estaba pensando el Lince. Era la cena.
Para seguir investigando
Si quieres comprobar lo que cuenta touchRead(), la documentación oficial de Espressif lo explica: devuelve el número de ciclos de carga y descarga en un tiempo fijo, y ese valor cambia cuando el pin «tocado» tiene mayor capacidad equivalente. Está en la API táctil de Arduino-ESP32.
En MonteSteam tienes los otros chismes de la placa que usamos aquí:
- Chisme 25 · El letrero del Lince: la base del Bluetooth entre la placa y el móvil.
- Chisme 26 · Termómetro Bluetooth con Heltec y DHT22: un sensor real para el reto de la humedad.
- Chisme 28 · Lince seguro: dos placas y LoRa, para el reto 7.
- El chisme 37, el telégrafo de Justicia Ciega: otro chisme con Web Audio y un botón de la placa.
Y toda la serie está en la colección de Chismes del Lince.
Recuerda que esto es un prototipo educativo y un instrumento de juguete: no es un theremin profesional ni un dispositivo terapéutico.


