maj bluetooth compatible webapp

This commit is contained in:
thomas committed 2026-10-24 00:54:19 +02:00
1 parent 3757975e51
commit e94499ba1e
22 files changed
+68217 -67436

No files matched your search

File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
@@ -16,6 +16,8 @@
#include <touchgfx/widgets/TextAreaWithWildcard.hpp>
#include <touchgfx/widgets/ToggleButton.hpp>
#include <texts/TextKeysAndLanguages.hpp>
#include <audio_effects.h>
#include <string.h>
namespace ui
{
@@ -144,6 +146,44 @@ inline void layoutParamRow(touchgfx::TextArea& label, touchgfx::Slider& slider,
slider.setXY(184, y); /* curseur 456x52 */
value.init(touchgfx::TypedText(T_VALUE_M_LEFT), accentColor, 656, y + 10, 136, 34);
}
/* Detecte qu'un autre acteur que l'ecran (controle a distance BLE) a modifie les parametres
* d'effets. snapshot() memorise l'etat affiche ; changed() compare tous les PERIOD ticks IHM
* (~100 ms a 60 Hz) et renvoie true une fois par changement : l'ecran relit alors les parametres. */
class ParamsWatcher
{
public:
ParamsWatcher() : ticks(0)
{
memset(&last, 0, sizeof(last));
}
void snapshot()
{
last = *AudioEffects_GetParams();
ticks = 0;
}
bool changed()
{
if (++ticks < PERIOD)
{
return false;
}
ticks = 0;
if (memcmp(&last, AudioEffects_GetParams(), sizeof(last)) == 0)
{
return false;
}
snapshot();
return true;
}
private:
static const uint8_t PERIOD = 6;
AudioEffectsParams last;
uint8_t ticks;
};
} // namespace ui
#endif // UITHEME_HPP
@@ -12,12 +12,16 @@ public:
virtual ~EchoView() {}
virtual void setupScreen();
virtual void tearDownScreen();
virtual void handleTickEvent();
virtual void toggleClicked();
virtual void sliderDelaiChanged(int value);
virtual void sliderFeedbackChanged(int value);
virtual void sliderMixChanged(int value);
protected:
/* Relit les parametres d'effets dans les widgets (setupScreen + changements distants) */
void syncFromParams();
ui::ParamsWatcher watcher;
ui::Header header;
ui::ValueText values[3];
};
@@ -12,6 +12,7 @@ public:
virtual ~EqualizerView() {}
virtual void setupScreen();
virtual void tearDownScreen();
virtual void handleTickEvent();
virtual void toggleClicked();
virtual void sliderBand1Changed(int value);
@@ -25,6 +26,9 @@ public:
virtual void sliderBand9Changed(int value);
virtual void sliderBand10Changed(int value);
protected:
/* Relit les parametres d'effets dans les widgets (setupScreen + changements distants) */
void syncFromParams();
ui::ParamsWatcher watcher;
ui::Header header;
ui::ValueText values[10];
touchgfx::Box zeroDbLine;
@@ -12,6 +12,7 @@ public:
virtual ~OctaverView() {}
virtual void setupScreen();
virtual void tearDownScreen();
virtual void handleTickEvent();
virtual void toggleClicked();
virtual void slider1Clicked(int value);
@@ -20,6 +21,9 @@ public:
virtual void slider4Clicked(int value);
virtual void slider5Clicked(int value);
protected:
/* Relit les parametres d'effets dans les widgets (setupScreen + changements distants) */
void syncFromParams();
ui::ParamsWatcher watcher;
ui::Header header;
ui::ValueText values[5];
@@ -12,12 +12,16 @@ public:
virtual ~OverdriveView() {}
virtual void setupScreen();
virtual void tearDownScreen();
virtual void handleTickEvent();
virtual void toggleClicked();
virtual void sliderDriveChanged(int value);
virtual void sliderToneChanged(int value);
virtual void sliderLevelChanged(int value);
protected:
/* Relit les parametres d'effets dans les widgets (setupScreen + changements distants) */
void syncFromParams();
ui::ParamsWatcher watcher;
ui::Header header;
ui::ValueText values[3];
};
@@ -33,6 +33,9 @@ protected:
};
void onCardOpen(const EffectCard& card);
/* Relit marche/arret, volume et etat des cartes (setupScreen + changements distants) */
void syncFromParams();
ui::ParamsWatcher watcher;
void setLooperState(LooperState state);
/* Buffer du CanvasWidgetRenderer (voyant du looper), propre a cet ecran comme
@@ -12,12 +12,16 @@ public:
virtual ~ReverbView() {}
virtual void setupScreen();
virtual void tearDownScreen();
virtual void handleTickEvent();
virtual void toggleClicked();
virtual void sliderTailleChanged(int value);
virtual void sliderAmortiChanged(int value);
virtual void sliderMixChanged(int value);
protected:
/* Relit les parametres d'effets dans les widgets (setupScreen + changements distants) */
void syncFromParams();
ui::ParamsWatcher watcher;
ui::Header header;
ui::ValueText values[3];
};
@@ -12,11 +12,15 @@ public:
virtual ~SaturationView() {}
virtual void setupScreen();
virtual void tearDownScreen();
virtual void handleTickEvent();
virtual void toggleClicked();
virtual void sliderDriveClicked(int value);
virtual void sliderLevelClicked(int value);
protected:
/* Relit les parametres d'effets dans les widgets (setupScreen + changements distants) */
void syncFromParams();
ui::ParamsWatcher watcher;
ui::Header header;
ui::ValueText values[2];
};
@@ -20,14 +20,30 @@ void EchoView::setupScreen()
add(values[i]);
}
syncFromParams();
}
void EchoView::syncFromParams()
{
FxEchoParams& p = AudioEffects_GetParams()->echo;
toggleButton1.forceState(p.enabled);
toggleButton1.invalidate();
slider1.setValue((int16_t)(p.delay_ms / 10.0f + 0.5f));
slider2.setValue((int16_t)(p.feedback / 0.95f * 100.0f + 0.5f));
slider3.setValue((int16_t)(p.mix * 100.0f + 0.5f));
values[0].show("%d ms", slider1.getValue() * 10);
values[1].show("%d %%", slider2.getValue());
values[2].show("%d %%", slider3.getValue());
watcher.snapshot();
}
void EchoView::handleTickEvent()
{
EchoViewBase::handleTickEvent();
if (watcher.changed())
{
syncFromParams();
}
}
void EchoView::tearDownScreen()
@@ -42,8 +42,6 @@ void EqualizerView::setupScreen()
&bandSlider6, &bandSlider7, &bandSlider8, &bandSlider9, &bandSlider10 };
touchgfx::TextArea* labels[10] = { &textArea2, &textArea2_1, &textArea2_2, &textArea2_3, &textArea2_4,
&textArea2_5, &textArea2_6, &textArea2_7, &textArea2_8, &textArea2_9 };
const FxEqualizerParams& p = AudioEffects_GetParams()->equalizer;
toggleButton1.forceState(p.enabled);
for (int i = 0; i < 10; i++)
{
@@ -53,10 +51,34 @@ void EqualizerView::setupScreen()
labels[i]->setColor(ui::textMuted());
values[i].init(touchgfx::TypedText(T_VALUE_S_CENTER), accent, x - 10, VALUE_Y, 76, 24);
add(values[i]);
}
syncFromParams();
}
void EqualizerView::syncFromParams()
{
touchgfx::Slider* sliders[10] = { &bandSlider1, &bandSlider2, &bandSlider3, &bandSlider4, &bandSlider5,
&bandSlider6, &bandSlider7, &bandSlider8, &bandSlider9, &bandSlider10 };
const FxEqualizerParams& p = AudioEffects_GetParams()->equalizer;
toggleButton1.forceState(p.enabled);
toggleButton1.invalidate();
for (int i = 0; i < 10; i++)
{
sliders[i]->setValue((int16_t)((p.bands[i].gain_db + GAIN_RANGE_DB / 2.0f) / GAIN_RANGE_DB * 100.0f + 0.5f));
values[i].showDb(sliderToDb(sliders[i]->getValue()));
}
watcher.snapshot();
}
void EqualizerView::handleTickEvent()
{
EqualizerViewBase::handleTickEvent();
if (watcher.changed())
{
syncFromParams();
}
}
void EqualizerView::tearDownScreen()
@@ -33,17 +33,34 @@ void OctaverView::setupScreen()
add(values[i]);
}
syncFromParams();
}
void OctaverView::syncFromParams()
{
FxOctaverParams& p = AudioEffects_GetParams()->octaver;
toggleButton1.forceState(p.enabled);
toggleButton1.invalidate();
slider1.setValue((int16_t)(p.level_down2 * 100.0f + 0.5f));
slider2.setValue((int16_t)(p.level_down1 * 100.0f + 0.5f));
slider3.setValue((int16_t)(p.level_dry * 100.0f + 0.5f));
slider4.setValue((int16_t)(p.level_up1 * 100.0f + 0.5f));
slider5.setValue((int16_t)(p.level_up2 * 100.0f + 0.5f));
touchgfx::Slider* sliders[5] = { &slider1, &slider2, &slider3, &slider4, &slider5 };
for (int i = 0; i < 5; i++)
{
showLevel(i, sliders[i]->getValue());
}
watcher.snapshot();
}
void OctaverView::handleTickEvent()
{
OctaverViewBase::handleTickEvent();
if (watcher.changed())
{
syncFromParams();
}
}
void OctaverView::tearDownScreen()
@@ -20,14 +20,30 @@ void OverdriveView::setupScreen()
add(values[i]);
}
syncFromParams();
}
void OverdriveView::syncFromParams()
{
FxOverdriveParams& p = AudioEffects_GetParams()->overdrive;
toggleButton1.forceState(p.enabled);
toggleButton1.invalidate();
slider1.setValue((int16_t)(p.drive * 100.0f + 0.5f));
slider2.setValue((int16_t)(p.tone * 100.0f + 0.5f));
slider3.setValue((int16_t)(p.level * 100.0f + 0.5f));
values[0].show("%d %%", slider1.getValue());
values[1].show("%d %%", slider2.getValue());
values[2].show("%d %%", slider3.getValue());
watcher.snapshot();
}
void OverdriveView::handleTickEvent()
{
OverdriveViewBase::handleTickEvent();
if (watcher.changed())
{
syncFromParams();
}
}
void OverdriveView::tearDownScreen()
@@ -95,11 +95,23 @@ void OverviewView::setupScreen()
buttonWithLabel7.setLabelText(touchgfx::TypedText(T_SPECTRUM));
/* --- Etat initial --- */
syncFromParams();
setLooperState(LOOPER_IDLE);
}
void OverviewView::syncFromParams()
{
const AudioEffectsParams* params = AudioEffects_GetParams();
toggleButton1.forceState(params->audio_on_off);
toggleButton1.invalidate();
const int volume = (int)(params->master_volume * 100.0f + 0.5f);
slider1.setValue((int16_t)volume);
volumeValue.show("%d %%", volume);
setLooperState(LOOPER_IDLE);
for (int i = 0; i < 6; i++)
{
cards[i].refresh();
}
watcher.snapshot();
}
void OverviewView::tearDownScreen()
@@ -152,6 +164,11 @@ void OverviewView::handleTickEvent()
{
OverviewViewBase::handleTickEvent();
if (watcher.changed())
{
syncFromParams();
}
/* repeatButton1 declenche une action a l'appui puis en continu tant qu'il est
* maintenu (comportement standard TouchGFX d'un RepeatButton) : ca ne donne pas
* directement un evenement "relache". On detecte donc nous-memes le front sur son
@@ -24,14 +24,30 @@ void ReverbView::setupScreen()
add(values[i]);
}
syncFromParams();
}
void ReverbView::syncFromParams()
{
FxReverbParams& p = AudioEffects_GetParams()->reverb;
toggleButton1.forceState(p.enabled);
toggleButton1.invalidate();
slider1.setValue((int16_t)(p.room_size * 100.0f + 0.5f));
slider2.setValue((int16_t)(p.damping * 100.0f + 0.5f));
slider3.setValue((int16_t)(p.mix * 100.0f + 0.5f));
values[0].show("%d %%", slider1.getValue());
values[1].show("%d %%", slider2.getValue());
values[2].show("%d %%", slider3.getValue());
watcher.snapshot();
}
void ReverbView::handleTickEvent()
{
ReverbViewBase::handleTickEvent();
if (watcher.changed())
{
syncFromParams();
}
}
void ReverbView::tearDownScreen()
@@ -17,12 +17,28 @@ void SaturationView::setupScreen()
add(values[0]);
add(values[1]);
syncFromParams();
}
void SaturationView::syncFromParams()
{
FxSaturationParams& p = AudioEffects_GetParams()->saturation;
toggleButton1.forceState(p.enabled);
toggleButton1.invalidate();
slider1.setValue((int16_t)(p.drive * 100.0f + 0.5f));
slider2.setValue((int16_t)(p.level * 100.0f + 0.5f));
values[0].show("%d %%", slider1.getValue());
values[1].show("%d %%", slider2.getValue());
watcher.snapshot();
}
void SaturationView::handleTickEvent()
{
SaturationViewBase::handleTickEvent();
if (watcher.changed())
{
syncFromParams();
}
}
void SaturationView::tearDownScreen()
+55 -19
View File
@@ -69,7 +69,7 @@ const SECTIONS = [
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;
let link = null, pending = new Map(), timer = 0, demo = false;
const $ = s => document.querySelector(s);
const msg = t => { $("#msg").textContent = t || ""; };
@@ -124,45 +124,81 @@ function send(id, value) {
async function flush() {
timer = 0;
const items = [...pending]; pending = new Map();
if (!ctrl) return;
if (!link) 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);
await link.write(new Uint8Array(buf.buffer), false);
}
} catch (e) { msg("Envoi impossible : " + e.message); }
}
function onState(ev) {
const dv = ev.target.value;
function onState(dv) {
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 ---- */
/* ---- connexion ----
* Deux transports, meme interface {write(bytes, avecReponse), close()} :
* - app WebApp : pont natif Android.ble (Web Bluetooth n'existe pas dans sa WebView)
* - Chrome : Web Bluetooth (page en contexte securise) */
function setConnected(on) { $("#dot").classList.toggle("on", on); $("#btn").textContent = on ? "Déconnecter" : "Connecter"; }
let device = null;
function lost() { link = null; setConnected(false); msg("Déconnecté."); }
async function connectNative() {
const B = window.Android.ble;
const dev = await B.connect({name: "CelloFX", service: SERVICE, timeout: 10000});
Android.on("ble.disconnect", e => { if (e.id === dev.id) lost(); });
await B.subscribe(dev.id, SERVICE, CHAR_STATE, bytes => onState(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)));
link = {
write: (bytes, resp) => B.write(dev.id, SERVICE, CHAR_CTRL, bytes, {response: resp}),
close: () => B.disconnect(dev.id),
};
await link.write(new Uint8Array([SNAPSHOT, 0, 0]), true);
}
async function connectWeb() {
const device = await navigator.bluetooth.requestDevice({filters: [{name: "CelloFX"}], optionalServices: [SERVICE]});
device.addEventListener("gattserverdisconnected", lost);
const server = await device.gatt.connect();
const svc = await server.getPrimaryService(SERVICE);
const ctrl = await svc.getCharacteristic(CHAR_CTRL);
const st = await svc.getCharacteristic(CHAR_STATE);
st.addEventListener("characteristicvaluechanged", ev => onState(ev.target.value));
await st.startNotifications(); /* le module envoie alors un instantane */
link = {
write: (bytes, resp) => resp ? ctrl.writeValueWithResponse(bytes) : ctrl.writeValueWithoutResponse(bytes),
close: () => device.gatt.connected && device.gatt.disconnect(),
};
await link.write(new Uint8Array([SNAPSHOT, 0, 0]), true); /* au cas ou */
}
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; }
const native = window.Android?.isNative && window.Android.ble;
if (!native && !navigator.bluetooth) {
demo = true; setConnected(true);
msg(window.isSecureContext
? "Web Bluetooth indisponible dans ce navigateur : mode démo (rien n'est envoyé). Utilise l'app WebApp ou Chrome Android."
: "Page non sécurisée : Web Bluetooth est bloqué (mode démo). Sers la page en HTTPS ou autorise l'origine dans chrome://flags (voir remote/serve.sh).");
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 */
await (native ? connectNative() : connectWeb());
setConnected(true); msg("Connecté.");
} catch (e) { setConnected(false); msg(e.name === "NotFoundError" ? "Aucun appareil choisi." : "Connexion échouée : " + e.message); }
} catch (e) {
link = null; setConnected(false);
msg(e.name === "NotFoundError" ? "Aucun appareil choisi."
: e.code === "not_found" ? "CelloFX introuvable : la carte est-elle allumée et à portée ?"
: e.code === "permission_denied" ? "Autorisation Bluetooth refusée (menu WebApp → Autorisations)."
: "Connexion échouée : " + e.message);
}
}
function disconnect() { device?.gatt?.connected && device.gatt.disconnect(); ctrl = null; demo = false; setConnected(false); msg(""); }
function disconnect() { link?.close(); link = null; demo = false; setConnected(false); msg(""); }
$("#btn").addEventListener("click", () => ($("#dot").classList.contains("on") ? disconnect() : connect()));
build();
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Sert la telecommande CelloFX sur le reseau local (telephone et PC sur le meme Wi-Fi).
# Web Bluetooth exige un contexte securise : voir le message affiche ci-dessous.
# Usage: remote/serve.sh [port]
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
PORT="${1:-8000}"
echo "Page : cellofx-remote.html (port $PORT)"
for ip in $(hostname -I 2>/dev/null); do echo " http://$ip:$PORT/cellofx-remote.html"; done
cat <<MSG
Sur le telephone (Chrome Android, une seule fois) :
1. chrome://flags/#unsafely-treat-insecure-origin-as-secure
2. y ajouter l'adresse http://<ip>:$PORT ci-dessus, passer le drapeau sur Enabled, relancer Chrome
3. Bluetooth + localisation actives, ouvrir l'adresse, "Connecter"
Ensuite : menu Chrome > "Ajouter a l'ecran d'accueil" (la page est alors lancee comme une app).
MSG
exec python3 -m http.server "$PORT" --bind 0.0.0.0