Ambient-Adaptive Noise Bar with BleuIO & HibouAir
August 15, 2025
This tutorial shows how to build a small, browser-based monitor that reads only the advertised noise level from a nearby HibouAir sensor using a BleuIO USB BLE dongle. There is no pairing, no audio recording, and no microphone access. The page simply listens for Bluetooth Low Energy (BLE) advertisements, decodes a numeric noise value emitted by the sensor, and renders it as a color bar between 40–80 dBSPL. When the value exceeds a threshold you choose, a plain “shhhh” banner appears as a gentle cue to keep things quiet.
Why a Noise-Only, Privacy-Centric Monitor?
The goal is awareness, not surveillance. Many environments benefit from real-time feedback about loudness—libraries, classrooms, shared offices, and homes—yet microphones introduce privacy concerns and data-handling obligations. This project avoids all of that by reading a single numeric value that the HibouAir computes internally and broadcasts publicly. Because BLE advertisements are brief, connectionless, and contain no personally identifiable information or audio, the solution is both practical and privacy-preserving. It gives occupants a clear indication of ambient noise without storing, transmitting, or analyzing conversations.
What You’ll Build
You’ll create a single HTML file that talks to the BleuIO dongle through the Web Serial API. The page switches BleuIO to a central role, periodically runs a targeted scan for your HibouAir device ID, and parses the manufacturer-specific bytes in each advertisement to extract the noise reading. The value is then mapped to a 40–80 dB display range and presented as a horizontal color bar. If the measured level crosses your threshold, the banner appears. Everything runs locally in your browser; there is no backend server and nothing leaves your machine.
Hardware
Software
- Google Chrome or Microsoft Edge on desktop (Web Serial enabled)
- Any text editor to save and edit
index.html
How It Works
BLE devices periodically broadcast short advertisement packets. These packets can include a manufacturer-specific data (MSD) field where vendors store compact sensor values. Because advertisements are public and unidirectional, you can read them without pairing or maintaining a connection, which makes them ideal for low-overhead telemetry and privacy-first designs.
What We Read
HibouAir encodes a noise metric inside its MSD block. This project looks only for that metric. The page filters scan results to a specific board ID so you capture advertisements from your own sensor. Each time the dongle reports an advertisement line, the page extracts the longest hex payload, finds the MSD anchor, and reads two bytes that represent the noise value.
The BLE Flow
When you click Connect, the browser opens a serial session to BleuIO. The page sends AT+CENTRAL
once to set the dongle into scanning mode. Every few seconds, it issues AT+FINDSCANDATA=<BOARD_ID>=3
to perform a three-second targeted scan and then reads the output until the dongle prints “SCAN COMPLETE.” This cadence repeats continuously so your display stays current without spamming the serial interface.
Source code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>BleuIO + HibouAir — Simple Noise Bar</title>
<style>
:root {
--val: 0;
} /* percent of the 40–80 dB range */
body {
font-family: system-ui, Arial, sans-serif;
margin: 24px;
}
button {
padding: 10px 14px;
margin-right: 8px;
cursor: pointer;
}
.row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.bar-wrap {
position: relative;
width: 100%;
max-width: 760px;
height: 56px;
border-radius: 12px;
overflow: hidden;
border: 1px solid #eee;
margin-top: 12px;
background: linear-gradient(
90deg,
#3aa0ff 0%,
#41d17d 20%,
#b5e04a 40%,
#ffd54d 60%,
#ff8a33 80%,
#f44336 100%
);
}
.bar-mask {
position: absolute;
inset: 0;
background: #fff;
transform-origin: left center;
transform: translateX(
calc(var(--val) * 1%)
); /* reveals gradient as value grows */
mix-blend-mode: lighten;
}
.value {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 22px;
color: #111;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
pointer-events: none;
}
.shhh {
display: none;
margin-top: 10px;
font-weight: 700;
color: #f44336;
font-size: 20px;
}
.shhh.show {
display: block;
}
#log {
height: 160px;
overflow: auto;
background: #0b0b0b;
color: #9f9;
padding: 8px;
margin-top: 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
border-radius: 8px;
}
</style>
</head>
<body>
<h2>Ambient-Adaptive Noise Bar</h2>
<div class="row">
<button id="btnConnect">Connect BleuIO</button>
<button id="btnStart" disabled>Start</button>
<button id="btnStop" disabled>Stop</button>
<label
>Board ID: <input id="board" value="473DA5" style="width: 90px"
/></label>
<label
>“Shhh” threshold (dBSPL):
<input id="thresh" type="number" value="60" style="width: 70px" />
</label>
</div>
<div class="bar-wrap" aria-label="Noise level">
<div class="bar-mask" id="mask"></div>
<div class="value" id="valLabel">dBSPL +40</div>
</div>
<div id="shhh" class="shhh">🤫 shhhh… too loud</div>
<pre id="log"></pre>
<script>
let port,
writer,
running = false,
timer = null;
const logEl = document.getElementById('log');
const btnConnect = document.getElementById('btnConnect');
const btnStart = document.getElementById('btnStart');
const btnStop = document.getElementById('btnStop');
const valLabel = document.getElementById('valLabel');
const shhhEl = document.getElementById('shhh');
const threshInput = document.getElementById('thresh');
const boardInput = document.getElementById('board');
function log(s) {
logEl.textContent += s + '\n';
logEl.scrollTop = logEl.scrollHeight;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function clamp(x, min, max) {
return Math.max(min, Math.min(max, x));
}
// Update UI from **dB** value: fill 40..80 => 0..100%, label as dBSPL +<db>
function setUI(db, thresholdDb) {
const dbClamped = clamp(db, 40, 80); // display window
const pct = Math.round(((dbClamped - 40) / 40) * 100);
document.documentElement.style.setProperty('--val', pct);
valLabel.textContent = `dBSPL +${dbClamped}`;
if (dbClamped >= thresholdDb) shhhEl.classList.add('show');
else shhhEl.classList.remove('show');
}
// Parse hex string → byte array
function hexToBytes(hex) {
hex = (hex || '').replace(/[^0-9A-F]/gi, '');
if (hex.length % 2) hex = hex.slice(0, -1);
const arr = new Uint8Array(hex.length / 2);
for (let i = 0; i < arr.length; i++)
arr[i] = parseInt(hex.substr(i * 2, 2), 16);
return arr;
}
// Robust decoder: find 5B 07 05 and read **noise bytes at offset +7 (big-endian)**.
// dB = 120 - noiseAdv ; then clamp to 0..120 and show within 40..80 range.
function decodeNoiseDbFromAdv(hex) {
const bytes = hexToBytes(hex);
// find sequence 5B 07 05
for (let i = 0; i <= bytes.length - 3; i++) {
if (
bytes[i] === 0x5b &&
bytes[i + 1] === 0x07 &&
bytes[i + 2] === 0x05
) {
const noiseIdx = i + 7; // your generator: i+7 = MSB, i+8 = LSB
if (noiseIdx + 1 < bytes.length) {
const noiseAdv = (bytes[noiseIdx] << 8) | bytes[noiseIdx + 1]; // big-endian
const db = 120 - noiseAdv; // inverse of your generator
return clamp(Math.round(db), 0, 120);
}
}
}
return null;
}
async function connectPort() {
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
writer = port.writable.getWriter();
await send('AT'); // wake
btnStart.disabled = false;
log('Connected. BleuIO ready.');
} catch (e) {
alert('Serial open failed: ' + e);
}
}
// robust sender: flush + tiny delay to avoid glued commands (e.g., "CENTRALA")
async function send(cmd) {
const enc = new TextEncoder();
try {
await writer.write(enc.encode('\r\n'));
await sleep(10);
await writer.write(
enc.encode(cmd.endsWith('\r\n') ? cmd : cmd + '\r\n')
);
log('>> ' + cmd.trim());
await sleep(30);
} catch (e) {
log('WRITE ERROR: ' + e);
}
}
// Fresh reader per scan; stop on SCAN COMPLETE or timeout
async function readFor(ms) {
const reader = port.readable.getReader();
const td = new TextDecoder();
let buf = '',
doneEarly = false;
const killer = setTimeout(async () => {
try {
await reader.cancel();
} catch {}
}, ms);
try {
while (running) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
buf += td.decode(value);
const lines = buf.split(/\r?\n/);
buf = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
log(line);
if (line.includes('SCAN COMPLETE')) {
doneEarly = true;
break;
}
const matches = line.match(/[0-9A-F]{16,}/gi);
const hex = matches
? matches.sort((a, b) => b.length - a.length)[0]
: null;
if (hex) {
const db = decodeNoiseDbFromAdv(hex.toUpperCase());
if (db !== null) setUI(db, Number(threshInput.value || 60));
}
}
if (doneEarly) break;
}
} catch (e) {
/* expected on cancel */
} finally {
clearTimeout(killer);
reader.releaseLock();
}
}
async function startLoop() {
if (!port || !writer) return;
running = true;
btnStart.disabled = true;
btnStop.disabled = false;
await send('AT+CENTRAL'); // set once
const scanSec = 3; // command duration
const periodMs = 10000; // keep your current cadence (adjust if needed)
const cycle = async () => {
if (!running) return;
const id = (boardInput.value || '473DA5').trim();
const t0 = performance.now();
try {
await send(`AT+FINDSCANDATA=${id}=${scanSec}`);
log('SCANNING...');
await readFor(scanSec * 1000 + 500); // read until SCAN COMPLETE or timeout (~3.5s)
} catch (e) {
log('LOOP ERROR: ' + e);
} finally {
log('SCAN COMPLETE');
if (running) {
const elapsed = performance.now() - t0;
const wait = Math.max(0, periodMs - elapsed);
timer = setTimeout(cycle, wait);
}
}
};
cycle();
}
function stopLoop() {
running = false;
btnStart.disabled = false;
btnStop.disabled = true;
if (timer) clearTimeout(timer);
}
btnConnect.addEventListener('click', connectPort);
btnStart.addEventListener('click', startLoop);
btnStop.addEventListener('click', stopLoop);
// init: show floor of the display window (40 dB)
setUI(40, Number(threshInput.value || 60));
</script>
</body>
</html>
Running It
Save the page as index.html
and open it with Chrome or Edge on your desktop. Click Connect BleuIO and select the dongle’s serial port. Verify the Board ID matches your HibouAir unit, then click Start. You should see the serial log print “SCANNING…” followed by advertisement lines and “SCAN COMPLETE.” As new packets arrive, the color bar moves within the 40–80 dB range and the banner appears when the measured level meets or exceeds your threshold. The app continues scanning at a steady cadence without manual intervention.

Output
Use Cases
In a library or reading room, the bar provides a quiet, visual nudge to maintain a calm atmosphere without recording anyone. Classrooms can use it to keep group work from spilling into disruption while reassuring students and parents that no audio is captured. Open offices gain a neutral reference during focus periods, with the banner serving as a gentle reminder rather than an alarm. At home, the display helps keep late-night activities considerate of sleeping children or neighbors. Across all of these contexts, the design sidesteps privacy concerns by never collecting voice or content—only a simple loudness number the sensor already computes.
Accuracy and Limitations
The 40–80 dB window is a display choice that covers common indoor scenarios; this is not a calibrated sound level meter. BLE advertisements arrive periodically, so the value updates in small steps rather than continuously. Placement matters: keep the HibouAir and BleuIO within reasonable proximity to reduce missed packets. If the environment is unusually quiet or loud, you can shift the threshold or adjust the visual window to suit your space.