Files
CelloEffects/remote/cellofx-remote.html
T
2026-10-24 00:14:53 +02:00

172 lines
8.7 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>CelloFX Remote</title>
<meta name="description" content="Télécommande BLE des effets CelloFX (STM32H7S78-DK)">
<meta name="theme-color" content="#14161c">
<meta name="webapp:icon" content="🎻">
<meta name="webapp:orientation" content="portrait">
<style>
:root{--bg:#14161c;--card:#1e222b;--fg:#e8eaf0;--mut:#8b92a3;--acc:#ff9f43;--ok:#3ddc97;--ko:#ff5d5d;--r:14px}
@media (prefers-color-scheme:light){:root{--bg:#f2f3f7;--card:#fff;--fg:#1b1e26;--mut:#6b7285}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{margin:0;background:var(--bg);color:var(--fg);font:16px system-ui,sans-serif;
padding:env(safe-area-inset-top) 16px calc(env(safe-area-inset-bottom) + 24px)}
header{position:sticky;top:0;z-index:5;background:var(--bg);padding:12px 0;display:flex;gap:10px;align-items:center}
header h1{font-size:20px;margin:0;flex:1}
.dot{width:10px;height:10px;border-radius:50%;background:var(--ko);display:inline-block;margin-right:6px}
.dot.on{background:var(--ok)}
button.main{min-height:44px;padding:0 16px;border:0;border-radius:22px;background:var(--acc);color:#1b1200;font-weight:700;font-size:15px}
.card{background:var(--card);border-radius:var(--r);padding:14px 16px;margin:12px 0}
.card h2{font-size:17px;margin:0;display:flex;align-items:center;justify-content:space-between;min-height:44px}
.row{margin:10px 0}
.row label{display:flex;justify-content:space-between;color:var(--mut);font-size:14px}
.row b{color:var(--fg);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;height:36px;accent-color:var(--acc)}
.sw{position:relative;width:52px;height:30px;flex:none}
.sw input{opacity:0;position:absolute;inset:0;width:100%;height:100%;margin:0;z-index:2}
.sw i{position:absolute;inset:0;border-radius:15px;background:#555;transition:.15s}
.sw i::after{content:"";position:absolute;top:3px;left:3px;width:24px;height:24px;border-radius:50%;background:#fff;transition:.15s}
.sw input:checked+i{background:var(--acc)}
.sw input:checked+i::after{transform:translateX(22px)}
.eq{display:grid;grid-template-columns:repeat(10,1fr);gap:2px;height:210px;margin-top:8px}
.eq div{display:flex;flex-direction:column;align-items:center;font-size:11px;color:var(--mut)}
.eq input{writing-mode:vertical-lr;direction:rtl;flex:1;width:100%;min-width:0;margin:0}
.eq span{margin-top:4px}
#msg{color:var(--mut);font-size:13px;margin:4px 0 0;min-height:1.2em}
</style>
</head>
<body>
<header>
<h1><span class="dot" id="dot"></span>CelloFX</h1>
<button class="main" id="btn">Connecter</button>
</header>
<p id="msg"></p>
<main id="app"></main>
<script>
"use strict";
const SERVICE = "c3110000-7e8a-4b1c-9d52-f0a1e4b7c600";
const CHAR_CTRL = "c3110001-7e8a-4b1c-9d52-f0a1e4b7c600";
const CHAR_STATE = "c3110002-7e8a-4b1c-9d52-f0a1e4b7c600";
const SNAPSHOT = 0xFF, MAX_RECORDS = 6; /* 18 octets : tient dans un MTU par defaut de 23 */
/* kind: b = interrupteur, u = 0..1 (fil 0..1000), g = dB (fil x10), ms = millisecondes */
const U = (id, name) => ({id, name, kind: "u", min: 0, max: 1000});
const B = id => ({id, kind: "b"});
const EQ = ["31","62","125","250","500","1k","2k","4k","8k","16k"];
const SECTIONS = [
{title: "Général", on: B(0x00), params: [U(0x01, "Volume")]},
{title: "Octaver", on: B(0x10), params: [U(0x11,"−2 oct"),U(0x12,"−1 oct"),U(0x13,"Sec"),U(0x14,"+1 oct"),U(0x15,"+2 oct")]},
{title: "Égaliseur", on: B(0x20), eq: true},
{title: "Overdrive", on: B(0x50), params: [U(0x51,"Drive"),U(0x52,"Tone"),U(0x53,"Niveau")]},
{title: "Saturation", on: B(0x60), params: [U(0x61,"Drive"),U(0x62,"Niveau")]},
{title: "Echo", on: B(0x70), params: [{id:0x71,name:"Délai",kind:"ms",min:0,max:1000},{id:0x72,name:"Feedback",kind:"u",min:0,max:950},U(0x73,"Mix")]},
{title: "Reverb", on: B(0x80), params: [U(0x81,"Taille"),U(0x82,"Amortissement"),U(0x83,"Mix")]},
];
const state = new Map(); /* id -> valeur fil */
const widgets = new Map(); /* id -> fonction de mise a jour de l'UI */
let ctrl = null, pending = new Map(), timer = 0, demo = false;
const $ = s => document.querySelector(s);
const msg = t => { $("#msg").textContent = t || ""; };
function fmt(p, v) {
if (p.kind === "g") return (v / 10).toFixed(1) + " dB";
if (p.kind === "ms") return v + " ms";
return Math.round(v / 10) + " %";
}
function slider(p, label, extraClass) {
const wrap = document.createElement("div");
wrap.className = extraClass || "row";
const input = document.createElement("input");
input.type = "range"; input.min = p.min; input.max = p.max; input.step = p.kind === "g" ? 5 : (p.kind === "ms" ? 5 : 5);
input.value = p.min;
const val = document.createElement("b");
const upd = v => { input.value = v; val.textContent = fmt(p, v); };
input.addEventListener("input", () => { val.textContent = fmt(p, +input.value); send(p.id, +input.value); });
widgets.set(p.id, upd);
if (extraClass) { wrap.append(input); wrap.dataset.val = ""; const s = document.createElement("span"); s.textContent = label; wrap.append(s); }
else { const l = document.createElement("label"); l.append(label, val); wrap.append(l, input); }
upd(p.min);
return wrap;
}
function build() {
const app = $("#app");
for (const s of SECTIONS) {
const card = document.createElement("section"); card.className = "card";
const h = document.createElement("h2"); h.append(s.title);
const sw = document.createElement("label"); sw.className = "sw";
const cb = document.createElement("input"); cb.type = "checkbox";
cb.addEventListener("change", () => { navigator.vibrate?.(15); send(s.on.id, cb.checked ? 1 : 0); });
sw.append(cb, document.createElement("i")); h.append(sw); card.append(h);
widgets.set(s.on.id, v => { cb.checked = !!v; });
if (s.eq) {
const eq = document.createElement("div"); eq.className = "eq";
EQ.forEach((f, i) => eq.append(slider({id: 0x30 + i, kind: "g", min: -240, max: 240}, f, "col")));
card.append(eq);
eq.querySelectorAll(".col").forEach(c => c.style.cssText = "display:flex;flex-direction:column;align-items:center;font-size:11px;color:var(--mut)");
} else for (const p of s.params) card.append(slider(p, p.name));
app.append(card);
}
}
/* ---- envoi : on garde la derniere valeur par id et on groupe ~40 ms ---- */
function send(id, value) {
state.set(id, value);
pending.set(id, value);
if (!timer) timer = setTimeout(flush, 40);
}
async function flush() {
timer = 0;
const items = [...pending]; pending = new Map();
if (!ctrl) return;
try {
for (let i = 0; i < items.length; i += MAX_RECORDS) {
const chunk = items.slice(i, i + MAX_RECORDS);
const buf = new DataView(new ArrayBuffer(chunk.length * 3));
chunk.forEach(([id, v], k) => { buf.setUint8(k * 3, id); buf.setInt16(k * 3 + 1, v, true); });
await ctrl.writeValueWithoutResponse(buf);
}
} catch (e) { msg("Envoi impossible : " + e.message); }
}
function onState(ev) {
const dv = ev.target.value;
for (let o = 0; o + 3 <= dv.byteLength; o += 3) {
const id = dv.getUint8(o), v = dv.getInt16(o + 1, true);
state.set(id, v); widgets.get(id)?.(v);
}
}
/* ---- connexion ---- */
function setConnected(on) { $("#dot").classList.toggle("on", on); $("#btn").textContent = on ? "Déconnecter" : "Connecter"; }
let device = null;
async function connect() {
if (!navigator.bluetooth) { demo = true; setConnected(true); msg("Web Bluetooth indisponible ici : mode démo (rien n'est envoyé). Ouvre la page dans Chrome Android en HTTPS."); return; }
try {
msg("Recherche de CelloFX…");
device = await navigator.bluetooth.requestDevice({filters: [{name: "CelloFX"}], optionalServices: [SERVICE]});
device.addEventListener("gattserverdisconnected", () => { ctrl = null; setConnected(false); msg("Déconnecté."); });
const server = await device.gatt.connect();
const svc = await server.getPrimaryService(SERVICE);
ctrl = await svc.getCharacteristic(CHAR_CTRL);
const st = await svc.getCharacteristic(CHAR_STATE);
st.addEventListener("characteristicvaluechanged", onState);
await st.startNotifications(); /* le module envoie alors un instantane */
await ctrl.writeValueWithResponse(new Uint8Array([SNAPSHOT, 0, 0])); /* au cas ou */
setConnected(true); msg("Connecté.");
} catch (e) { setConnected(false); msg(e.name === "NotFoundError" ? "Aucun appareil choisi." : "Connexion échouée : " + e.message); }
}
function disconnect() { device?.gatt?.connected && device.gatt.disconnect(); ctrl = null; demo = false; setConnected(false); msg(""); }
$("#btn").addEventListener("click", () => ($("#dot").classList.contains("on") ? disconnect() : connect()));
build();
</script>
</body>
</html>