Batman Sonar documentación
Proyecto open source · Prueba de concepto

Un sonar real con el parlante y el micrófono de tu laptop

Inspirado en el sonar de The Dark Knight, pero honesto sobre sus límites. Emite chirps por el parlante, graba los ecos con el micrófono integrado y mide la distancia a las superficies por tiempo de vuelo, con filtro adaptado, promediado coherente y detección CFAR. Esta guía documenta todo: la teoría, cómo instalarlo, cada decisión de diseño, los fallos que encontramos en hardware real y cómo los resolvimos, para que cualquiera pueda replicarlo.

Python ≥ 3.11 tests 88/88 ✓ numpy · scipy · sounddevice sin hardware extra CLI sonar
~2.9 cm
resolución teórica en la banda 2–8 kHz
< 2 cm
error en simulación (test de propiedades)
0.26 m
objeto real a 30 cm, medido de forma repetible
+18 dB
ganancia del promediado con 64 pings
Visión general

Qué es y qué logra

Un murciélago grita y escucha. Este proyecto hace lo mismo con piezas que ya tienes: el parlante emite un chirp (un barrido de frecuencia corto), el sonido rebota en una pared o un objeto, y el micrófono graba el eco. Como el sonido viaja a ~343 m/s, cada milisegundo de retraso equivale a ~17 cm de distancia (ida y vuelta).

📡 Mide distancias

Lista de ecos con distancia, retardo y SNR. Salida en tabla o JSON.

📈 Visualiza

A-scan (perfil de eco), modo en vivo con waterfall y radar polar girando el equipo a mano.

🩺 Se autodiagnostica

Mide la respuesta en frecuencia de tu hardware y recomienda la banda que sí funciona.

🧪 Probado sin hardware

Un simulador acústico permite correr 88 tests con latencia aleatoria, ruido, clipping y fallos de buffer.

🛡️ Seguro por diseño

Tope de amplitud que el adaptador de audio verifica antes de reproducir cualquier cosa.

🧭 Errores accionables

Cada fallo dice qué pasó y cómo resolverlo, con instrucciones por sistema operativo.

⚠️ Seguridad

El casi-ultrasonido (18–22 kHz) a alto volumen molesta a perros y gatos y puede dañar los tweeters de parlantes pequeños. La amplitud digital por defecto es baja (0.1) y hay un tope duro (--max-amplitude, por defecto 0.5). Mantén moderado el volumen del sistema. La banda audible (2–8 kHz u 8–16 kHz) se oye como un "tic" agudo: es molesto, no peligroso.

Lo que NO hace (todavía)

Con un solo micrófono sólo hay distancia, no dirección. No "ve a través de paredes" ni reconstruye imágenes 3D como en la película. Ver limitaciones.

Antes de empezar

Requisitos

Hardware

  • Laptop con parlante y micrófono integrados
  • Tapa abierta (cerrada apaga el mic)
  • Opcional: cinta métrica para validar

Software

  • Python ≥ 3.11
  • uv (gestor de entornos)
  • Linux: libportaudio2

Ambiente

  • Cuarto silencioso, sin ventilador ni A/C
  • Pared plana y dura a 0.5–2 m
  • Nada blando cerca (cortinas, sofás)

Versiones con las que se verificó esta documentación

ComponenteVersiónRol
Python3.12.10Intérprete
numpy2.5.4Arrays, FFT
scipy1.18.1Filtros, correlación, Hilbert, Welch, CFAR
matplotlib3.11.2A-scan, live, radar polar
sounddevice0.5.6PortAudio: reproducir y grabar con el mismo reloj
typer (+ rich)0.27.3CLI y tablas en terminal
pytest · hypothesisgrupo devTests unitarios y de propiedades

Plataforma de pruebas reales: Windows 11 en una laptop ASUS ROG/TUF con audio Realtek y Armoury Crate.

Tutorial

Paso a paso: de cero a tu primer eco

Marca cada paso al terminarlo; tu progreso se guarda en este navegador.

0 de 12
≈ 2 min · una sola vez

Instala uv y Python

uv crea el entorno virtual e instala las dependencias exactas fijadas en uv.lock.

Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Sólo Linux: la librería nativa de audio
sudo apt install libportaudio2
≈ 1 min

Obtén el código e instala dependencias

Terminal
git clone https://github.com/ruiiz-dev/batman.git
cd batman
uv sync                 # crea .venv con dependencias + pytest/hypothesis
uv run sonar --help

Deberías ver la ayuda con los comandos devices, diagnose, calibrate, ping, live y sweep.

≈ 15 s · sin hardware

Corre los tests para confirmar que la cadena funciona

Los tests no usan tu micrófono: alimentan el código real con un simulador acústico. Si pasan, cualquier fallo posterior es del hardware o del sistema operativo, no del software.

Terminal
uv run pytest
# si pytest no puede escribir en la carpeta temporal del sistema:
uv run pytest --basetemp=.pytest_tmp
Salida esperada
........................................................................ [ 81%]
................                                                         [100%]
88 passed in 9.68s
≈ 5 min · crítico en Windows

Prepara el sistema operativo

Todo el "procesamiento inteligente" de voz del sistema está diseñado para eliminar exactamente lo que queremos medir: sonidos que salen del parlante y vuelven al micrófono.

  • Desactiva mejoras de audio: Configuración → Sistema → Sonido → (dispositivo) → Mejoras de audio: Desactivado. Haz lo mismo en el parlante.
  • Apps de "cancelación de ruido con IA" (Armoury Crate, Nahimic, NVIDIA Broadcast, Krisp…): apágalas.
  • Permiso de micrófono: Privacidad y seguridad → Micrófono → permitir apps de escritorio.
  • Cierra Zoom, Discord, Teams y pestañas del navegador que usen el micrófono.
  • Volumen del sistema moderado (~50 %).
Trampa real: Armoury Crate (ASUS)

"Cancelación de ruido con IA bidireccional" deshabilita el micrófono físico y deja sólo un micrófono virtual. Al apagarla tuvimos que rehabilitarlo a mano: Win+R → mmsys.cpl → Grabación → clic derecho → "Mostrar dispositivos deshabilitados" → Habilitar, y luego quitarle el mute.

≈ 1 min

Identifica tus dispositivos

Terminal
uv run sonar devices

Verás cada dispositivo una vez por host API (MME, DirectSound, WASAPI, WDM-KS en Windows), con canales y tasas de muestreo soportadas. Busca el micrófono integrado y los parlantes integrados bajo WASAPI y anota una parte única de su nombre.

Elige por nombre, nunca por índice

Los índices cambian cuando conectas un monitor o audífonos. --input "Varios micr" --host-api WASAPI es estable; --input 14 no.

≈ 30 s · guarda silencio

Diagnostica la respuesta de tu hardware

Graba 2 s de ruido ambiente, reproduce un barrido de 1 kHz a ~23 kHz y calcula el SNR por frecuencia. Te dice qué banda usar y te imprime el comando exacto.

Windows (WASAPI exclusivo)
uv run sonar --host-api WASAPI --exclusive --input "Varios micr" --output "Altavoces (Realtek" diagnose --plot respuesta.png
macOS / Linux
uv run sonar diagnose --plot respuesta.png
Respuesta parlante a micrófono: SNR alto bajo 9.5 kHz, cero sobre 10 kHz
Resultado real (ASUS): el hardware responde hasta ~9.5 kHz y está muerto en la banda por defecto 18–22 kHz (sombreada). Por eso esta laptop usa 2–8 kHz.
≈ 2 min

Monta la escena

mesa laptop parlante micrófono pared dura 0.5 – 2 m de espacio libre
  • Nada entre el equipo y el objetivo. Superficies a menos de 15 cm caen en la zona ciega.
  • No muevas el equipo entre calibrar y medir.
  • Tú también eres un reflector: aléjate o quédate detrás del equipo.
≈ 10 s · sin el objetivo delante

Calibra el fondo

Graba todo lo estático (el sonido que viaja por el chasis, el camino directo, la mesa, la tapa) para restarlo después. Hazlo en la posición de medida, apuntando a espacio libre.

Terminal (Windows, configuración verificada)
uv run sonar --host-api WASAPI --exclusive --input "Varios micr" --output "Altavoces (Realtek" ^
  --f-start 2000 --f-end 8000 --amplitude 0.5 --pings 64 --interval-ms 150 calibrate

Se guarda en .sonar/background.npz junto con una huella de la configuración (fs, banda, chirp, ventana, intervalo). Si cambias alguno de esos parámetros, ping rechazará el perfil y te pedirá recalibrar.

En PowerShell el salto de línea es ` (acento grave) en vez de ^; en bash es \. O escríbelo todo en una línea.

≈ 10 s

Tu primer ping

Pon el objetivo delante y mide. Las opciones globales van antes del comando.

Terminal
uv run sonar --host-api WASAPI --exclusive --input "Varios micr" --output "Altavoces (Realtek" ^
  --f-start 2000 --f-end 8000 --amplitude 0.5 --pings 64 --interval-ms 150 ping --plot ascan.png
Salida (ejemplo)
        Ecos (c = 343.2 m/s, 64 pings, resolución 2.9 cm)
┏━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━┓
┃ # ┃ distancia (m) ┃ retardo (ms) ┃ SNR (dB) ┃
┡━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━┩
│ 1 │         0.260 │        1.515 │     18.4 │
└───┴───────────────┴──────────────┴──────────┘

Útiles: --json para scripts, --show para abrir la gráfica, --save-raw rec.npy para guardar la grabación cruda y re-analizarla después, --no-background para ver la escena sin restar el fondo.

≈ 5 min

Valida con una cinta métrica y corrige el sesgo

Mide la distancia real y compárala. Si el error es constante (siempre ~4 cm de menos, por ejemplo), es la separación parlante–micrófono: el eco recorre parlante→objeto→micrófono, no 2·d exactos. Corrígelo:

Terminal
# distancia = (c·Δt + direct_path) / 2   → un sesgo de −4 cm sugiere ~0.08 m
uv run sonar ... --direct-path 0.08 ping
continuo

Modo en vivo

Terminal
uv run sonar ... --pings 4 live      # menos pings = refresca más rápido

Arriba el A-scan actual, abajo un waterfall (cada fila es una medición; el tiempo baja). Acerca y aleja una carpeta dura: verás una línea diagonal. Los errores transitorios (SNR bajo, clipping) aparecen en la ventana sin cortar el loop. Cierra la ventana o Ctrl+C para salir.

≈ 5 min

Radar polar a mano

Terminal
uv run sonar ... sweep --out sweep.png --show

Gira el equipo a un ángulo, escribe el ángulo (p. ej. -30, 0, 30) y Enter para medir. Enter vacío termina y dibuja un gráfico polar: ángulo × distancia, color = SNR, punto rojo = eco más fuerte.

🎉 Listo

Si llegaste aquí tienes un sonar funcional. Comparte tus resultados (modelo de laptop + salida de diagnose): ver cómo reportar tu hardware.

Tutorial

Cómo leer un A-scan

A-scan con eco a 0.26 m sobre el umbral de 12 dB
A-scan real: objeto a 30 cm, 64 pings, fondo restado.
ElementoQué significa
Eje XDistancia en metros desde el equipo (ya dividida entre 2: es ida, no ida y vuelta).
Eje YSNR en dB: cuánto sobresale la señal sobre el ruido estimado.
Franja grisZona ciega (--min-range): el eco se solapa con el camino directo; no se busca ahí.
Línea punteadaUmbral global --min-snr (12 dB). Debajo, nada se reporta.
Triángulos rojosEcos detectados (pasan umbral global, umbral local CFAR y rechazo de lóbulos laterales).
TítuloPings promediados, si se restó fondo, y SNR del camino directo (salud de la medición: >20 dB es bueno).

Regla práctica: un eco real se mueve cuando mueves el objeto. Si un pico aparece en todas las mediciones sin importar dónde esté el objeto, es clutter de la escena, no tu objetivo. Esta regla nos salvó de un falso positivo (ver resultados).

Entender

Física del sonar

1. Tiempo de vuelo

parlante (S) micrófono (M) reflector camino directo → t₀ ida vuelta → t_eco d
d = ( c · (t_eco − t₀) + direct_path ) / 2
c = 331.3 · √(1 + T / 273.15) m/s   → 343.2 m/s a 20 °C

La idea clave del proyecto: Windows mete entre 10 y 300 ms de latencia desconocida entre "reproduce esto" y "esto es lo que grabé". 10 ms de error serían 1.7 m. La solución: no medir tiempos absolutos. El primer sonido fuerte que llega al micrófono es el que viaja directo desde el parlante (t₀). Toda distancia se mide respecto a ese pico, así la latencia del sistema se cancela por completo. Un test de propiedades lo verifica con latencias aleatorias de 0 a 400 ms.

2. ¿Por qué un chirp y no un "beep"?

Un pulso corto tiene poca energía (ruidoso); uno largo tiene mucha energía pero se emborrona en el tiempo. El chirp (barrido lineal de f_start a f_end) resuelve el dilema: es largo (8 ms = 2.7 m de sonido en el aire) pero, tras el filtro adaptado (correlación con la señal emitida), se comprime a un pico de ~1/B segundos. Es la misma técnica de los radares (pulse compression).

resolución = c / (2·B)   → 6 kHz de banda (2–8 kHz) ≈ 2.9 cm · 4 kHz ≈ 4.3 cm · 8 kHz ≈ 2.1 cm

Resolución ≠ precisión. La resolución es qué tan cerca pueden estar dos ecos y seguir viéndose separados. La precisión de un eco aislado es mucho mejor (sub-centimétrica en simulación) gracias a la interpolación parabólica del pico.

3. Promediado coherente

Emitimos N chirps idénticos a intervalos fijos. La señal se suma en fase; el ruido (aleatorio) no. El ruido baja √N en amplitud: 16 pings ≈ +12 dB, 64 pings ≈ +18 dB. Coherente significa alineado a la muestra: a 6 kHz, un desfase de una sola muestra (21 µs) ya destruye la suma. Esto resultó ser el problema más difícil en hardware real (ver bitácora).

4. El tren de pings

silencio 50 ms → ruido de ref. warm-up ×2 no se promedian · · · N pings (por defecto 16), uno cada 60 ms periodo cola: 1 periodo + 500 ms absorbe la latencia de audio

El silencio inicial garantiza un tramo de puro ruido al principio de la grabación (la latencia sólo puede retrasar la señal, nunca adelantarla), que se usa para estimar el ruido. El periodo debe ser mayor que el chirp + el viaje de ida y vuelta a max_range + guarda; la configuración lo valida y te dice el mínimo.

Interactivo

Calculadora de parámetros

Replica las fórmulas y validaciones de SonarConfig. Úsala para elegir banda, intervalo y alcance antes de medir.

Entender

Pipeline de procesamiento de señal

De la grabación cruda a una lista de distancias. Todo vive en processing.py y detection.py, como funciones puras (arrays entran, arrays salen).

01Validar grabaciónsilencio digital → permiso; ≥0.99 FS → clipping
02Pasa-bandaButterworth orden 4, fase cero
03Filtro adaptadocorrelación FFT con el chirp
04Envolvente|señal analítica| (Hilbert)
05t₀ = camino directogruesa + envolvente plegada sobre todos los pings
06Re-alinear cada pingcorrelación sub-muestra (buffers perdidos)
07Promedio coherenteruido ↓ √N
08Alineación sub-muestradirecto exactamente en guard
09Restar fondorefinado de fase + ganancia por mínimos cuadrados
10Detecciónumbral global + OS-CFAR + lóbulos
11Distanciapico parabólico → c·Δt/2

Resaltados: pasos que no estaban en el diseño inicial y nacieron de pruebas en hardware real.

01 · Validar la grabación

check_recording: si todas las muestras son cero exacto, el sistema operativo está bloqueando el micrófono (Windows y macOS no dan error, entregan ceros) → MicrophonePermissionError con instrucciones por SO. Si alguna muestra llega a clip_level (0.99) → ClippingError: la saturación distorsiona el filtro adaptado.

02 · Pasa-banda de fase cero

processing.py
lo = max(0.9 * f_lo, 20.0); hi = min(1.1 * f_hi, 0.99 * nyq)
sos = butter(4, [lo / nyq, hi / nyq], btype="bandpass", output="sos")
return sosfiltfilt(sos, x)   # ida y vuelta → desfase cero → no sesga los tiempos de llegada

03 · Filtro adaptado

Correlación cruzada (vía FFT) con la plantilla del chirp, indexada por lag: out[k] es máximo cuando el chirp empieza en x[k]. El chirp de 8 ms se comprime a un pico de ~1/B. La ventana Tukey (α = 0.25) en el chirp reduce los lóbulos laterales en distancia y evita "clicks" en el parlante.

04 · Envolvente de Hilbert

La salida del filtro oscila a la frecuencia portadora. Su magnitud analítica |hilbert(x)| da una envolvente suave cuyo máximo es el instante de llegada.

05 · El camino directo como t₀

  1. Ruido de referencia: mediana de la potencia de envolvente en el silencio inicial, dividida por ln 2 (ruido gaussiano → envolvente Rayleigh → potencia exponencial, cuya mediana es media·ln 2). La mediana es robusta a picos esporádicos.
  2. SNR directo: si el pico más fuerte no supera 15 dB sobre el ruido, se distingue entre BandOutOfResponseError (cuarto silencioso pero el chirp no aparece → el hardware no responde en esa banda) y LowSNRError (demasiado ruido).
  3. Gruesa: primera muestra que supera el 50 % del pico (la primera llegada fuerte, no la más fuerte).
  4. Refinada sobre la envolvente plegada: se promedian las ventanas alrededor de cada ping medido. En hardware real el camino directo y una reflexión cercana (tapa, mesa) pueden intercambiar el papel de "más fuerte" de ping a ping; un solo ping no es una referencia fiable.

06 · Re-alinear cada ping (align_frames)

Descubierto en hardware: los streams dúplex de WASAPI ocasionalmente pierden o repiten un buffer completo de 32 muestras en mitad del tren, lo que desplaza todos los pings siguientes. A 6 kHz eso destruye el promedio. Solución:

  • Cada ping se recorta con max_slip_samples (128) muestras extra a cada lado.
  • Se calcula el desfase de cada ping contra el primero por correlación sensible a la fase en la región del camino directo, con precisión sub-muestra (parábola), y se normaliza a la mediana.
  • Se hace un promedio provisional y se re-estima cada desfase contra él (segunda pasada, más robusta).
  • Se aplica el desplazamiento fraccionario exacto en frecuencia: X(f)·e^(−j2πf·shift).

El test test_duplex_buffer_slips_do_not_break_averaging inyecta slips de ±32 y 64 muestras en el simulador y exige que el eco conserve su SNR (≤ 3 dB de pérdida).

07–08 · Promedio coherente y alineación final

Se promedian los pings ya alineados. Luego se busca la primera llegada fuerte en la envolvente promedio, se interpola el pico parabólicamente y se desplaza el frame para que el camino directo quede exactamente en el índice guard (= longitud del chirp).

09 · Sustracción de fondo

Con un BackgroundProfile (de calibrate), se resta la escena estática. Dos detalles hacen que funcione en la práctica:

  • Refinado de fase: alinear por pico de envolvente deja un error de fracción de muestra que, a la frecuencia portadora, basta para dejar residuos fuertes. Se hacen dos pasadas de correlación cruzada contra el frame de referencia completo.
  • Ganancia por mínimos cuadrados sobre la región del camino directo: scale = ⟨a,r⟩/⟨r,r⟩, que absorbe pequeños cambios de volumen entre calibración y medida.

10 · Detección

Un pico candidato (dentro de [min_range, max_range]) se acepta si cumple las tres:

PruebaUmbralPara qué
Globalmin_snr_db = 12 dB sobre el ruido del perfilAcota las falsas alarmas
Local OS-CFARcfar_snr_db = 8 dB sobre la mediana de su vecindarioRechaza clutter extendido. La mediana (no la media) no se deja enmascarar por un eco fuerte vecino.
Lóbulos lateralessidelobe_margin_db = 6 dB sobre la fuga esperadaUsa la autocorrelación real del chirp para descartar "fantasmas" que cada llegada fuerte (incluido el directo) proyecta. Voraz: de más fuerte a más débil.

11 · Distancia

El pico se refina con una parábola de 3 puntos (precisión sub-muestra) y se convierte: samples_to_range(lag) = (c·lag/fs + direct_path) / 2. Se devuelven los n_echoes (5) más fuertes por SNR.

Entender

Arquitectura del código

Puertos y adaptadores: el dominio (procesamiento de señal) no sabe nada de audio real. Sólo SoundDeviceAudio toca sounddevice, y lo importa de forma perezosa, así que los tests nunca necesitan PortAudio.

Interfaz
cli.pytyper · parsea opciones → SonarConfig · traduce SonarError a "Error / Cómo resolverlo" (exit 2)
Aplicación
engine.py · SonarEngineacquire · analyse · measure · calibrate · diagnose
viz.pyA-scan, LiveView, respuesta, radar polar
Dominio (puro, sin I/O)
signal.pychirp, tren, barrido
processing.pyfiltros, alineación, fondo
detection.pyCFAR, lóbulos, Echo
diagnostics.pyrespuesta, banda
config.py · SonarConfiginmutable, validada, magnitudes derivadas
errors.pySonarError(message, hint)
Puerto
audio.AudioDevice (Protocol)playrec · record · check_settings · list_devices
Adaptadores
SoundDeviceAudioPortAudio, WASAPI exclusivo, resolución por nombre + host API, tope de amplitud
FakeAudioDevice + simulation.pyescena sintética determinista para tests

Mapa de archivos

ArchivoLíneasResponsabilidad
config.py199SonarConfig inmutable (frozen dataclass), validación con mensajes accionables, conversiones distancia↔muestras, huella para el fondo.
signal.py70linear_chirp (también plantilla del filtro adaptado), ping_train, linear_sweep.
audio.py306Puerto AudioDevice, adaptador real, fake, traducción de códigos de error de PortAudio.
simulation.py90Escena: latencia, camino directo, reflectores con retardos fraccionarios exactos (FFT), banda del hardware, ruido, clipping, mic bloqueado, slips de buffer.
processing.py347Primitivas DSP, align_frames, EchoProfile, BackgroundProfile, process_recording.
detection.py95os_cfar_noise, detect_echoes → list[Echo].
diagnostics.py121Welch PSD barrido vs. ruido (promedio por mediana), recommend_band.
engine.py72Servicio que orquesta dispositivo + dominio.
viz.py164Gráficas matplotlib.
cli.py384Comandos typer; capa delgada.
errors.py78Jerarquía de errores + instrucciones de permisos por SO.

Usarlo como librería

Python
from sonar.audio import SoundDeviceAudio, FakeAudioDevice
from sonar.config import SonarConfig
from sonar.engine import SonarEngine
from sonar.simulation import Scene, Reflector

# Simulado: una pared a 1.2 m, latencia arbitraria
cfg = SonarConfig(f_start=2000, f_end=8000)
fake = FakeAudioDevice(scene=Scene(reflectors=(Reflector(1.2, 0.05),), latency_s=0.11))
m = SonarEngine(fake, cfg).measure()
print([round(e.distance_m, 3) for e in m.echoes])

# Real
dev = SoundDeviceAudio("Varios micr", "Altavoces (Realtek", cfg.max_amplitude, host_api="WASAPI", exclusive=True)
m = SonarEngine(dev, cfg).measure()
Historia

Bitácora del proyecto: de la simulación al hardware real

Cada fase registra el problema, cómo se detectó, la causa y la solución que quedó en el código. Si replicas el proyecto en otra laptop, probablemente te cruces con varios de estos.

FASE 1 · DISEÑO

Simulación primero

Antes de tocar un micrófono se construyó la cadena completa contra un simulador acústico (simulation.py) con latencia aleatoria, camino directo, reflectores con retardos fraccionarios exactos, respuesta del hardware, ruido y clipping. Banda inicial: casi-ultrasonido 18–22 kHz (casi inaudible). Meta: error < 2 cm en cualquier distancia y latencia.

Decisión
Usar el camino directo como t₀ para cancelar la latencia; arquitectura de puertos para testear sin hardware.
FASE 2 · PRIMER CONTACTO

El hardware no emite casi-ultrasonido

Síntoma
Ningún eco; el chirp ni siquiera aparecía en el camino directo.
Diagnóstico
sonar diagnose: SNR ≈ 0 dB sobre ~10 kHz; el hardware no responde sobre ~18 kHz.
Solución
Banda 2–8 kHz. De aquí nacieron diagnose, recommend_band y el error BandOutOfResponseError, que distingue "no responde" de "mucho ruido".
Respuesta en frecuencia medida
Respuesta medida parlante→micrófono. Banda por defecto sombreada: sin señal.
FASE 3 · WINDOWS

Windows borra los chirps: modo compartido vs. exclusivo

Síntoma
Los primeros pings salían bien y luego desaparecían; jitter de decenas de muestras entre pings; corte sobre ~8 kHz.
Causa
En WASAPI compartido Windows aplica procesamiento de voz al micrófono (cancelación de eco, compuerta de ruido, AGC). Para el sistema, nuestro chirp es un eco a cancelar.
Solución
--host-api WASAPI --exclusive: acceso directo al hardware. Resultado: latencia ~42 ms estable, jitter ±0.5 muestras.
FASE 4 · DISPOSITIVOS

El micrófono que desaparece

Síntomas
Grabaciones en cero absoluto; el micrófono "Varios micrófonos (Realtek)" no aparecía; los índices cambiaban entre corridas.
Causas
(a) Armoury Crate "Cancelación de ruido con IA bidireccional" deshabilita el mic físico; (b) quedó deshabilitado y en mute al apagarla; (c) con la tapa cerrada el mic se apaga; (d) al conectar el monitor externo los índices se desplazan.
Solución
Rehabilitar en mmsys.cpl; selección por nombre + host API (_resolve exige exactamente una coincidencia); error de silencio digital con instrucciones por SO.
FASE 5 · NIVEL

Amplificador dormido y nivel bajo

Síntoma
En exclusivo el nivel es bajo (pico ~0.003) y los primeros 1–2 pings difieren del resto.
Causa
El amplificador del parlante despierta del ahorro de energía durante los primeros cientos de ms.
Solución
warmup_pings = 2: se emiten pero no se promedian. Más pings (64) y amplitud 0.5 compensan el nivel.
FASE 6 · EL BUG MÁS DIFÍCIL

Buffers perdidos rompen el promedio coherente

Síntoma
El promedio de 16+ pings daba peor resultado que un ping; los pings tardíos estaban corridos.
Causa
El stream dúplex de WASAPI pierde o repite buffers de 32 muestras en mitad del tren. Todo lo grabado después se desplaza.
Solución
align_frames: re-alineación sub-muestra de cada ping sobre su propio contenido, con margen de ±128 muestras. Se añadió Scene.slips al simulador y un test de regresión.
FASE 7 · REFERENCIA

El camino directo cambia de identidad

Síntoma
t₀ saltaba entre pings.
Causa
El directo y una reflexión muy cercana (tapa, mesa) tienen amplitud similar e intercambian el papel de pico más fuerte.
Solución
t₀ = primera llegada fuerte (≥ 50 % del pico), refinada sobre la envolvente plegada de todos los pings.
FASE 8 · FONDO

La resta de fondo dejaba residuos

Síntoma
Ecos estáticos fuertes sobrevivían a la sustracción.
Causa
Alinear por pico de envolvente deja un sesgo de fracción de muestra; a la portadora eso basta para no cancelar.
Solución
Dos pasadas de correlación sensible a la fase contra el frame completo de referencia + ganancia por mínimos cuadrados. Separar pings (--interval-ms 150) para que la reverberación del ping anterior no se sume coherentemente.
FASE 9 · 28-OCT-2026 · PRUEBAS CON OBJETOS REALES

Medición de objetos a 30, 50 y 80 cm

Configuración: WASAPI exclusivo, 2–8 kHz, 64 pings, amplitud 0.5, intervalo 150 ms, fondo restado. Detalle en resultados.

Historia

Resultados reales (sin maquillaje)

Publicamos lo que funcionó y lo que no. Un proyecto open source replicable necesita expectativas honestas.

EscenarioRealMedidoVeredicto
Cuarto vacío, 64 pings, fondo restadosin objetivoningún pico sobre 12 dBsin falsas alarmas
Objeto a 30 cm0.30 m0.26 m, repetibledetectado sesgo ≈ −4 cm
Objeto a 50 cm0.50 m0.47 mno confiable
Objeto a 80 cm0.80 m—no detectado
A-scan del cuarto vacío sin picos sobre el umbral
Cuarto vacío. Todo bajo el umbral: el detector no inventa ecos.
A-scan con objeto a 30 cm: pico en 0.26 m
Objeto a 30 cm → 0.26 m. También aparecen 0.38/0.46/0.52 m.
A-scan con objeto a 50 cm: pico en 0.47 m
Objeto a 50 cm → "0.47 m". Parece correcto… pero ver abajo.
A-scan con objeto a 80 cm: sin eco cerca de 0.8 m
Objeto a 80 cm. Nada en 0.8 m; de nuevo el cúmulo 0.21–0.52 m.
Por qué "0.47 m a 50 cm" no cuenta como éxito

Un cúmulo de picos en 0.38 / 0.46 / 0.52 m aparece en todas las corridas, esté el objeto a 30, 50 u 80 cm. No se mueve con el objeto, así que no es el objeto: es algo variable en la escena que la calibración no captura (posiblemente la persona que opera el equipo). El 0.47 m coincide con ese cúmulo por casualidad.

Perfil diferencial 30 cm frente a 80 cm con pico en 0.26 m
Diferencial 30 cm vs. 80 cm. Comparar dos escenas que sólo difieren en la posición del objeto aísla lo que cambió: 0.26 m vuelve a aparecer. El sesgo de ≈ −4 cm se vio en ambos sentidos de la diferencia.

Interpretación

  • Sesgo de −4 cm. Hipótesis: es la separación parlante–micrófono. Con direct_path = 0 la fórmula mide ≈ d − s/2; un sesgo de 4 cm corresponde a s ≈ 8 cm. Prueba --direct-path 0.08 y valida con cinta. (Pendiente de confirmar.)
  • Alcance efectivo ≈ 30–50 cm con objetos pequeños en esta laptop. El nivel de salida en exclusivo es bajo y el eco cae con la distancia; paredes grandes y duras deberían llegar más lejos.
  • Clutter variable en 0.2–0.55 m: el límite práctico hoy no es el ruido sino la escena no estática.
Primeras mediciones (antes de afinar la configuración)
Primer A-scan sin sustracción de fondo
16 pings, sin fondo: lóbulos y clutter cercano compiten con el umbral.
A-scan con 16 pings y fondo, pico en 0.12 m
16 pings, con fondo: un residuo en 0.12 m, pegado a la zona ciega. Motivó pasar a 64 pings e intervalo de 150 ms.

Configuración verificada para replicar

ASUS ROG/TUF · Realtek · Windows 11
sonar --host-api WASAPI --exclusive --input "Varios micr" --output "Altavoces (Realtek" --f-start 2000 --f-end 8000 --amplitude 0.5 --pings 64 --interval-ms 150 ping
Referencia

Referencia de la CLI

Forma: sonar [OPCIONES GLOBALES] COMANDO [OPCIONES DEL COMANDO]. Las globales van antes del comando.

Opciones globales

OpciónPor defectoDescripción
--band ultrasonic|audibleultrasonicPreset 18–22 kHz u 8–16 kHz
--f-start / --f-end18000 / 22000Banda manual (Hz); gana sobre --band
--fs48000Tasa de muestreo (Hz)
--chirp-ms8Duración del chirp
--windowtukeytukey (α 0.25) o hann
--pings16Pings promediados coherentemente
--warmup2Pings de calentamiento emitidos pero no promediados
--interval-ms60Intervalo entre pings; súbelo (150–250) en cuartos reverberantes
--amplitude0.1Amplitud digital (0–1)
--max-amplitude0.5Tope de seguridad; el adaptador bloquea señales por encima
--temperature20 °CPara la velocidad del sonido
--sound-speed—Fuerza c (m/s), ignora la temperatura
--direct-path0 mSeparación parlante–micrófono, corrige el sesgo
--min-range / --max-range0.15 / 5 mVentana de búsqueda (zona ciega / alcance)
--min-snr12 dBSNR mínimo de un eco
--input / --outputpredeterminado del SOÍndice o parte del nombre del dispositivo
--host-api—Restringe la búsqueda por nombre a una host API (p. ej. WASAPI)
--exclusiveoffWASAPI exclusivo: evita el procesamiento de voz de Windows

Comandos

ComandoOpcionesQué hace
devices—Lista dispositivos, host API, canales, fs soportadas; marca los predeterminados.
diagnose--duration 2 --min-snr 15 --plot RUTARuido + barrido 1 kHz→~23 kHz, tabla de SNR por kHz, banda recomendada y comando exacto. Sale con código 2 si no hay banda usable.
calibrate--out .sonar/background.npzGraba el perfil de fondo con huella de configuración.
ping--background --no-background --plot --show --json --save-rawUna medición. Usa .sonar/background.npz automáticamente si existe.
live--background --no-background --history 120A-scan + waterfall en tiempo real.
sweep--background --no-background --out sweep.png --showRadar polar girando el equipo a mano.

Salida JSON de ping --json

JSON
{
  "speed_of_sound": 343.21,
  "direct_snr_db": 29.4,
  "pings_averaged": 64,
  "echoes": [ { "distance_m": 0.26, "snr_db": 18.4, "delay_s": 0.001515 } ]
}
Referencia

SonarConfig: todos los parámetros

Dataclass inmutable que se valida al crearse. with_overrides(**kw) devuelve una copia validada ignorando los None.

CampoDefectoSignificado
fs48000Tasa de muestreo
f_start, f_end18000, 22000Banda del chirp. f_end < 0.98·Nyquist
chirp_duration0.008 s≥ 16 muestras
window, tukey_alpha"tukey", 0.25Ventana del chirp
n_pings, warmup_pings16, 2Pings promediados / de calentamiento
ping_interval0.06 sDebe cubrir guarda + ida y vuelta a max_range + chirp
pre_silence0.05 s≥ 2 chirps; referencia de ruido
latency_margin0.5 sCola grabada para absorber latencia
max_slip_samples128Mayor desfase de buffer que busca la re-alineación
temperature_c, speed_of_sound_override20, NoneVelocidad del sonido
direct_path_m0.0Separación parlante–micrófono
amplitude, max_amplitude0.1, 0.50 < amplitude ≤ max_amplitude ≤ 1
min_range, max_range0.15, 5.0 mVentana de búsqueda
n_echoes5Máximo de ecos reportados
min_snr_db12Umbral global
cfar_snr_db8Umbral local OS-CFAR
min_direct_snr_db15SNR mínimo del camino directo para confiar en la medición
sidelobe_margin_db6Margen sobre lóbulos laterales esperados
clip_level0.99Nivel considerado saturación
input_device, output_deviceNoneÍndice o subcadena del nombre
host_api, exclusiveNone, FalseFiltro de host API / WASAPI exclusivo

Magnitudes derivadas

speed_of_sound, bandwidth, range_resolution (c/2B), chirp_samples, frame_samples, pre_silence_samples, mainlobe_samples (⌈fs/B⌉), guard_samples, range_to_samples(), samples_to_range(), fingerprint().

Referencia

Errores y cómo se reportan

Todo error hereda de SonarError(message, hint). La CLI imprime Error: y Cómo resolverlo: y sale con código 2.

ClaseCuándoQué hacer
ConfigErrorParámetro inválido o amplitud sobre el topeEl mensaje dice el valor válido (p. ej. intervalo mínimo en ms)
DeviceNotFoundErrorNombre/índice inexistente o ambiguo en la host APIsonar devices, nombre más específico
UnsupportedSampleRateErrorPortAudio rechaza fs (−9997)--fs 48000
MicrophonePermissionErrorGrabación en cero exacto o error de host (−9999)Permisos del SO, mute, tapa, Armoury Crate
ClippingErrorMuestras ≥ 0.99 FSBajar amplitud/volumen/ganancia; alejarse
LowSNRErrorSNR del directo < 15 dB con ambiente ruidosoSilencio, más pings, más volumen
BandOutOfResponseErrorAmbiente silencioso pero el chirp no aparecesonar diagnose y su banda
AudioIOErrorDispositivo ocupado, PortAudio ausente, latencia > margenCerrar apps; instalar libportaudio2
IncompatibleBackgroundErrorHuella del fondo distinta o archivo corruptoRe-ejecutar calibrate o --no-background

En live y sweep, LowSNRError y ClippingError son transitorios: se muestran y el loop continúa.

Referencia

Tests: 88 casos, cero hardware

FakeAudioDevice alimenta la cadena real con simulation.py. El simulador y el estimador comparten la misma velocidad del sonido (343 m/s), así cualquier error de distancia viene sólo del procesamiento.

ArchivoCasosQué garantiza
test_ranging.py29Error < 2 cm en 0.3–4.8 m × 3 latencias; Hypothesis (30 ejemplos: distancia, ganancia, latencia 0–400 ms, semilla); 4 ecos simultáneos; sin ecos ⇒ sin detecciones; ecos a 15 cm separados; bajo resolución se fusionan; el promediado recupera un eco débil; temperatura; orden por SNR; slips de buffer.
test_config.py13Defaults seguros, c(T), ida y vuelta distancia↔muestras, configuraciones inválidas con pista, tope de amplitud.
test_processing.py12Pico del filtro adaptado, desplazamiento fraccionario, parábola exacta, ruido ↓ √N, autocorrelación, silencio digital, clipping, fondo ida/vuelta y corrupto.
test_cli.py9devices, ping JSON, calibrate→ping con fondo y gráfica, fondo incompatible, permiso accionable, config inválida, diagnose recomienda audible, sweep polar, --save-raw.
test_engine.py9Resta de fondo (incl. cambios de latencia y volumen), mic bloqueado, clipping, SNR bajo, banda fuera de respuesta, fallback audible, fs no soportada, tope en el dispositivo.
test_diagnostics.py8Preferencia casi-ultrasonido → audible → cualquiera; sin banda usable; cobertura; hardware plano, con caída sobre 16 kHz, mic bloqueado.
test_signal.py5Longitud/pico/bordes del chirp, energía dentro de la banda, tren de pings, amplitud del barrido.
test_viz.py3A-scan, LiveView, radar polar y respuesta (backend Agg).

Escribir un test nuevo

tests/test_mi_caso.py
from conftest import make_engine, scene
from sonar.simulation import Reflector

def test_pared_a_dos_metros(cfg):
    m = make_engine(cfg, scene(Reflector(2.0, 0.05), latency_s=0.07, noise_std=1e-3)).measure()
    assert abs(m.echoes[0].distance_m - 2.0) < 0.02

Si encuentras un fallo de hardware nuevo, la práctica del proyecto es: modelarlo en el simulador (como Scene.slips), escribir el test que falla y luego arreglar el procesamiento.

Referencia

Solución de problemas

"La grabación es silencio digital absoluto"
  1. ¿Tapa abierta?
  2. ¿Micrófono en mute? (Configuración → Sonido → Entrada)
  3. ¿Armoury Crate / app de IA lo deshabilitó? → mmsys.cpl → mostrar deshabilitados → Habilitar
  4. Permisos de micrófono para apps de escritorio
  5. macOS: habilita tu terminal en Privacidad → Micrófono y reiníciala
Los primeros pings salen bien y luego desaparecen

Procesamiento de voz de Windows en modo compartido. Usa --host-api WASAPI --exclusive y desactiva las mejoras de audio.

"Dispositivo … en host API 'WASAPI': varios"

El nombre coincide con más de un dispositivo. Usa una subcadena más larga, p. ej. "Altavoces (Realtek" en vez de "Altavoces".

"El modo exclusivo sólo existe en dispositivos WASAPI"

Añade --host-api WASAPI o quita --exclusive (macOS/Linux no lo necesitan).

"El dispositivo está ocupado"

Otra app usa el micrófono o parlante (en exclusivo, Windows no lo comparte). Cierra Zoom, Discord, Teams, el navegador.

"No se escucha el chirp en 18.0–22.0 kHz"

Tu hardware no responde en casi-ultrasonido (muy común). Ejecuta diagnose y usa la banda recomendada, o --band audible.

Ecos que no se mueven cuando muevo el objeto

Es clutter: reflexiones fijas o variables de la escena. Recalibra sin moverte, aléjate del equipo, sube --interval-ms a 150–250.

Distancias con error constante

Separación parlante–micrófono: --direct-path <m>. Error proporcional a la distancia → temperatura incorrecta (--temperature).

"ping_interval … es demasiado corto para max_range"

El mensaje trae el mínimo; sube --interval-ms o baja --max-range. Usa la calculadora.

Clipping

Baja --amplitude o el volumen del sistema, o separa el equipo de superficies muy cercanas.

pytest no puede escribir en la carpeta temporal

uv run pytest --basetemp=.pytest_tmp

Comunidad

Limitaciones y roadmap

Limitaciones conocidas

  • Sin dirección: un micrófono sólo da distancia. El radar de sweep depende de girar el equipo y de la (pobre) directividad del parlante.
  • Hardware no especificado sobre 20 kHz; la respuesta varía mucho entre modelos.
  • Alcance corto con objetos pequeños (≈ 30–50 cm en la laptop de prueba); superficies blandas devuelven poco.
  • Escena estática: la resta de fondo asume que nada se mueve; personas cerca o cambios de temperatura dejan residuos.
  • Zona ciega bajo ~15 cm.

Ideas para contribuir

🎯 Dirección (TDOA)

Usar los dos canales del array de micrófonos (muchas laptops tienen 2) para estimar ángulo por diferencia de tiempo de llegada.

🔊 Hardware externo

Perfiles probados con micrófono USB y tweeter externo para casi-ultrasonido real.

📏 Confirmar --direct-path

Validar con cinta la hipótesis del sesgo de −4 cm.

🧠 Seguimiento temporal

Filtro de Kalman/tracking en live para separar objetos que se mueven del clutter.

🗂️ Base de hardware

Tabla comunitaria de modelos de laptop con banda útil y comando verificado.

⚙️ CI

GitHub Actions corriendo uv run pytest en Windows, macOS y Linux (no requiere audio).

Comunidad

Contribuir

Flujo

Terminal
git checkout -b mi-mejora
uv sync
uv run pytest            # todo verde antes de empezar
# 1) si es un fallo de hardware: modélalo en simulation.py y escribe el test que falla
# 2) arregla processing/detection
uv run pytest            # todo verde al terminar

Convenciones del código

  • El dominio (signal, processing, detection, diagnostics) es puro: sin I/O de audio.
  • Toda configuración pasa por SonarConfig y se valida ahí con un mensaje accionable.
  • Todo error es un SonarError con hint que diga cómo resolverlo.
  • Docstrings y comentarios en inglés; mensajes al usuario en español.
  • Comentarios que expliquen el porqué (sobre todo lo aprendido en hardware).

Reporta tu hardware

El aporte más valioso: saber qué funciona en otras máquinas. Abre un issue con esta plantilla:

Plantilla de issue
### Hardware
- Laptop / modelo:
- Sistema operativo:
- Micrófono / parlante (nombre en `sonar devices`):
- Host API usada (WASAPI exclusivo / CoreAudio / ALSA / PulseAudio):

### Diagnóstico
- Banda recomendada por `sonar diagnose`:
- Adjunta respuesta.png

### Medición
- Comando completo:
- Objetivo y distancia real (cinta):
- Distancia medida (3 corridas):
- Adjunta el A-scan

### Problemas encontrados y cómo los resolviste

Checklist para publicar el repositorio

TareaEstado
git init y primer commit (el proyecto aún no es repositorio git)pendiente
Archivo LICENSE (p. ej. MIT o Apache-2.0)pendiente
Declarar rich en pyproject.toml (cli.py lo importa directo; hoy llega como dependencia de typer)pendiente
README: mencionar que muchas laptops necesitan banda audible y enlazar esta documentaciónpendiente
Workflow de CI con uv run pytestpendiente
Imágenes de la documentación versionadas (!docs/img/*.png en .gitignore)hecho
GitHub Pages: Settings → Pages → rama main, carpeta /docsal publicar
Comunidad

Glosario

TérminoDefinición
A-scanPerfil de eco: intensidad (SNR) en función de la distancia para una medición.
Camino directoSonido que va del parlante al micrófono sin rebotar. Es la referencia t₀.
ChirpSeñal cuya frecuencia sube (o baja) linealmente durante el pulso.
ClutterEcos no deseados de la escena (muebles, mesa, personas).
CFAR / OS-CFARUmbral de tasa constante de falsas alarmas; la variante OS usa la mediana del vecindario.
dBFSDecibeles relativos a la escala completa digital (0 dBFS = máximo).
Envolvente de HilbertMagnitud de la señal analítica: el "contorno" de una señal oscilante.
Fase ceroFiltrado ida y vuelta que no desplaza la señal en el tiempo.
Filtro adaptadoCorrelación con la señal emitida; maximiza el SNR y comprime el chirp en un pico.
Host APICapa de audio del SO que usa PortAudio (MME, DirectSound, WASAPI, WDM-KS, CoreAudio, ALSA…).
Lóbulo lateralPicos secundarios alrededor del pico principal del filtro adaptado; pueden parecer ecos falsos.
Nyquistfs/2: frecuencia máxima representable (24 kHz a 48 kHz).
Promediado coherenteSumar repeticiones alineadas a la muestra: la señal se refuerza, el ruido baja √N.
Resolución en distanciac/2B: separación mínima entre dos ecos distinguibles.
SNRRelación señal/ruido, en dB.
WASAPI exclusivoModo de Windows en que una app usa el dispositivo sin mezclador ni efectos del sistema.
WaterfallImagen de A-scans apilados en el tiempo.