feat: add realart web application with Hydra, WebGL, and OSC data feeds
- Implemented Hydra visualizations in `web_realart/public/hydra/app.js` and `index.html`. - Created WebGL visualizer using Three.js and WebGPU in `web_realart/public/webgl/app.js` and `index.html`. - Developed main landing page in `web_realart/public/index.html` to showcase different visualizations. - Set up WebSocket server in `web_realart/server.js` to relay OSC messages to web clients. - Added health check API endpoint for monitoring server status.
This commit is contained in:
@@ -2,7 +2,11 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(awk 'BEGIN{p=0;b=0;tlb=0;depth=0} /^\\\\\\(/{if\\(depth==0\\)tlb++; depth++} {for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b \" TLB:\" tlb}' sound_algo/control/data_feeds.scd)",
|
||||
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/examples/16_data_feeds.scd sound_algo/control/data_feeds.scd)"
|
||||
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/examples/16_data_feeds.scd sound_algo/control/data_feeds.scd)",
|
||||
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/control/data_feeds.scd)",
|
||||
"Bash(find . -name \"*.yml\" -path \"*workflows*\" 2>/dev/null *)",
|
||||
"Read(//Applications/**)",
|
||||
"Bash(swift build *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,16 @@ Each act lasts 12-55 s, transitions between acts trigger flash + glitch postfx +
|
||||
- `Espace` — manual glitch pulse
|
||||
- `F1`–`F5` — fullscreen / GUI / postFx / autoglitch / reload shaders
|
||||
|
||||
### 🌐 Real-world data feeds — `data_feeds/` + `web_realart/`
|
||||
- **9 live sources** ingested by `data_feeds/bridge.py` and broadcast as OSC `/data/<source>/<sub>` to SC (`:57121`), oF (`:57123`) and the web bridge (`:57124`) :
|
||||
USGS quakes · NOAA SWPC (solar wind, Bz IMF, Kp, X-ray flares) · Mainsfrequenz.de · RTE eCO2mix · Blitzortung lightning · OpenSky ADS-B · Bluesky firehose · Bitcoin mempool · GitHub events · GCN astrophysics · YOLOv8 webcam pose
|
||||
- **SC presets** `sound_algo/examples/16_data_feeds.scd` and `17_data_feeds_more.scd` map each source to synthesis : Schumann cavity drone (foudre), aurora additive pad (Bz/wind/Kp), Netzfrequenz pulse kick, RTE 8-op carbon FM, OpenSky granular swarm
|
||||
- **Web standalone** `web_realart/` ports the visualizers and synths to the browser for `real.art.saillant.cc` :
|
||||
- **WebGPU + three.js TSL** globe with quake/strike/flight particles (auto-fallback WebGL2)
|
||||
- **Web Audio** ports of 5 SC SynthDef presets (cavity, mix, geo, aurora, pulse)
|
||||
- **Hydra** with 7 data-driven patches (aurora, quake, lightning, flightmap, gridpulse, solarwind, bskyrain)
|
||||
- All three layers share `feeds_client.js` (window.feeds) over one WebSocket
|
||||
|
||||
### 📡 Audio reactivity pipeline
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.uv-cache/
|
||||
uv.lock
|
||||
@@ -8,7 +8,7 @@ et les rebalance en OSC vers SuperCollider (`:57121`) et openFrameworks
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
┌────────────────────────────────┐
|
||||
│ data_feeds/bridge.py │
|
||||
│ ├─ usgs (HTTP 60 s) │
|
||||
@@ -75,6 +75,7 @@ complet.
|
||||
| `rte_eco2mix` | `mix` | 15 min |
|
||||
| `github` | `event` | 30 s |
|
||||
| `gcn` | `alert` | rare |
|
||||
| `pose` | `count`, `person`, `skel`, `bone` | ~20 fps |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -92,6 +93,10 @@ Flux nécessitant des identifiants (désactivés par défaut) :
|
||||
<https://data.rte-france.com/> puis renseigner `client_id` /
|
||||
`client_secret`.
|
||||
- `gcn` : <https://gcn.nasa.gov/quickstart> + `uv add gcn-kafka`.
|
||||
- `pose` : install les deps optionnelles avec `uv sync --extra pose`
|
||||
(opencv-python + ultralytics). Sur Mac M5 utiliser `device = "mps"`.
|
||||
Une seule app peut grabber la webcam : si oF tourne `WebcamVis` en
|
||||
capture locale, mettre `feeds.pose.enabled = false` (et inversement).
|
||||
|
||||
## Diagnostic
|
||||
|
||||
|
||||
@@ -11,10 +11,35 @@
|
||||
targets = [
|
||||
{ host = "127.0.0.1", port = 57121 }, # SuperCollider
|
||||
{ host = "127.0.0.1", port = 57123 }, # openFrameworks
|
||||
{ host = "127.0.0.1", port = 57124 }, # web bridge (sound_algo/web + web_realart)
|
||||
]
|
||||
# Préfixe commun. Toutes les routes sont /data/<feed>/...
|
||||
prefix = "/data"
|
||||
|
||||
# -- Pose / webcam --------------------------------------------------------
|
||||
|
||||
[feeds.pose]
|
||||
enabled = true
|
||||
# YOLOv8/v11-pose via ultralytics. Auto-download du .pt au premier run.
|
||||
# Modeles : yolov8n-pose (fast), yolov8s-pose, yolov8m-pose, yolov8l-pose.
|
||||
# Sur Mac M5 prefere `n` ou `s`, device="mps" (Metal).
|
||||
model = "yolov8n-pose.pt"
|
||||
device = "mps" # "cpu", "mps" (Apple Silicon), "cuda:0"
|
||||
camera = 0 # index ofVideoGrabber-style (0 = camera par defaut)
|
||||
width = 640
|
||||
height = 480
|
||||
target_fps = 20 # plafond (le serveur peut faire moins)
|
||||
conf_thresh = 0.35
|
||||
max_persons = 4
|
||||
# Si false, n'emit que `/data/pose/count` et les bbox (pas les 17 kp).
|
||||
emit_keypoints = true
|
||||
|
||||
# Routes :
|
||||
# /data/pose/count <n>
|
||||
# /data/pose/person <idx> <cx> <cy> <w> <h> <conf> (normalises 0..1)
|
||||
# /data/pose/skel <idx> <conf_avg> <x0 y0 c0 ... x16 y16 c16> (17 kp COCO)
|
||||
# /data/pose/bone <idx> <kp_a> <kp_b> (segments du skeleton)
|
||||
|
||||
# -- Sismique / géophysique ------------------------------------------------
|
||||
|
||||
[feeds.usgs]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Webcam → OpenCV → pose detection (YOLOv8-pose) → OSC.
|
||||
|
||||
Pourquoi YOLOv8-pose plutot qu'OpenPose proper ?
|
||||
- OpenPose officiel = build CUDA, douloureux sur Mac ARM.
|
||||
- YOLOv8-pose : pip install, MPS/Metal accelere, 17 keypoints COCO
|
||||
(proche d'OpenPose BODY_25, suffisant pour de l'AV-live).
|
||||
- Pour un vrai OpenPose, swap simple : remplacer `Detector` par un
|
||||
wrapper autour de pyopenpose ou cmu-openpose et conserver le format
|
||||
keypoints (x_norm, y_norm, conf) emis sur OSC.
|
||||
|
||||
Sortie OSC :
|
||||
/data/pose/count <n>
|
||||
/data/pose/person <idx> <cx> <cy> <w> <h> <conf>
|
||||
/data/pose/skel <idx> <conf_avg> <x0 y0 c0 ... x16 y16 c16>
|
||||
/data/pose/bone <idx> <kp_a> <kp_b> (a la connexion, statique)
|
||||
|
||||
Toutes les coordonnees sont normalisees 0..1 (origine top-left).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
LOG = logging.getLogger("feed.pose")
|
||||
|
||||
# Squelette COCO 17 keypoints (paires d'os).
|
||||
COCO_BONES: list[tuple[int, int]] = [
|
||||
(0, 1), (0, 2), (1, 3), (2, 4), # tete
|
||||
(5, 6), (5, 7), (7, 9), (6, 8), (8, 10),# bras
|
||||
(5, 11), (6, 12), (11, 12), # torse
|
||||
(11, 13), (13, 15), (12, 14), (14, 16), # jambes
|
||||
]
|
||||
|
||||
|
||||
class _Lazy:
|
||||
"""Imports lourds differes pour ne pas casser le pont entier si pose
|
||||
n'est pas demande."""
|
||||
def __init__(self) -> None:
|
||||
self.cv2 = None
|
||||
self.YOLO = None
|
||||
self.np = None
|
||||
|
||||
def load(self) -> None:
|
||||
if self.cv2 is not None:
|
||||
return
|
||||
import cv2 # type: ignore
|
||||
import numpy as np # type: ignore
|
||||
from ultralytics import YOLO # type: ignore
|
||||
self.cv2 = cv2
|
||||
self.np = np
|
||||
self.YOLO = YOLO
|
||||
|
||||
|
||||
_LAZY = _Lazy()
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
try:
|
||||
_LAZY.load()
|
||||
except ModuleNotFoundError as e:
|
||||
LOG.error("dependances manquantes : %s — uv sync --extra pose", e)
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
|
||||
cv2, np, YOLO = _LAZY.cv2, _LAZY.np, _LAZY.YOLO
|
||||
|
||||
cam_idx = int(cfg.get("camera", 0))
|
||||
width = int(cfg.get("width", 640))
|
||||
height = int(cfg.get("height", 480))
|
||||
target_fps = float(cfg.get("target_fps", 20))
|
||||
conf_thresh = float(cfg.get("conf_thresh", 0.35))
|
||||
max_persons = int(cfg.get("max_persons", 4))
|
||||
emit_kp = bool(cfg.get("emit_keypoints", True))
|
||||
model_name = cfg.get("model", "yolov8n-pose.pt")
|
||||
device = cfg.get("device", "mps")
|
||||
|
||||
LOG.info("loading %s on %s", model_name, device)
|
||||
model = YOLO(model_name)
|
||||
|
||||
cap = cv2.VideoCapture(cam_idx)
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
||||
if not cap.isOpened():
|
||||
LOG.error("camera index %d indisponible", cam_idx)
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
|
||||
# Annonce du squelette (statique, une fois)
|
||||
for a, b in COCO_BONES:
|
||||
ctx.send("bone", float(a), float(b))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
period = 1.0 / max(1.0, target_fps)
|
||||
LOG.info("pose stream up: %dx%d @ %.1f fps target", width, height, target_fps)
|
||||
|
||||
def _grab():
|
||||
ok, frame = cap.read()
|
||||
return frame if ok else None
|
||||
|
||||
try:
|
||||
while True:
|
||||
t0 = time.monotonic()
|
||||
frame = await loop.run_in_executor(None, _grab)
|
||||
if frame is None:
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
h, w = frame.shape[:2]
|
||||
try:
|
||||
results = model.predict(
|
||||
frame, device=device, conf=conf_thresh,
|
||||
verbose=False, max_det=max_persons,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("inference failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
if not results:
|
||||
ctx.send("count", 0.0)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
|
||||
res = results[0]
|
||||
kp_xy = getattr(res.keypoints, "xy", None)
|
||||
kp_conf = getattr(res.keypoints, "conf", None)
|
||||
boxes = getattr(res, "boxes", None)
|
||||
n = 0 if kp_xy is None else int(len(kp_xy))
|
||||
ctx.send("count", float(n))
|
||||
|
||||
for i in range(n):
|
||||
# bbox normalisee
|
||||
if boxes is not None and i < len(boxes):
|
||||
b = boxes.xywhn[i].cpu().numpy().tolist() # cx, cy, w, h
|
||||
conf_b = float(boxes.conf[i].item())
|
||||
ctx.send("person", float(i), *b, conf_b)
|
||||
if not emit_kp or kp_xy is None:
|
||||
continue
|
||||
pts = kp_xy[i].cpu().numpy() # (17, 2) px
|
||||
cfs = kp_conf[i].cpu().numpy() if kp_conf is not None \
|
||||
else np.ones(len(pts), dtype=float)
|
||||
flat: list[float] = []
|
||||
conf_sum = 0.0
|
||||
for (x, y), c in zip(pts, cfs):
|
||||
xn = float(x) / max(1.0, w)
|
||||
yn = float(y) / max(1.0, h)
|
||||
cc = float(c)
|
||||
flat.extend([xn, yn, cc])
|
||||
conf_sum += cc
|
||||
avg = conf_sum / max(1, len(pts))
|
||||
ctx.send("skel", float(i), avg, *flat)
|
||||
|
||||
# cadence
|
||||
dt = time.monotonic() - t0
|
||||
if dt < period:
|
||||
await asyncio.sleep(period - dt)
|
||||
finally:
|
||||
cap.release()
|
||||
@@ -12,5 +12,12 @@ dependencies = [
|
||||
"skyfield>=1.49",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
pose = [
|
||||
"opencv-python>=4.10",
|
||||
"ultralytics>=8.3", # YOLOv8/v11-pose (CoreML/MPS sur Apple Silicon)
|
||||
"numpy>=1.26",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Schéma OSC — data_feeds
|
||||
|
||||
Diffusion : UDP `127.0.0.1:57121` (SuperCollider) **et** `127.0.0.1:57123`
|
||||
(openFrameworks). Tous les arguments sont des floats sauf mention contraire.
|
||||
|
||||
## Méta
|
||||
|
||||
| Route | Args | Description |
|
||||
|-------------------|---------------------|--------------------------------------|
|
||||
| `/data/heartbeat` | `uptime_sec` | Émis toutes les 5 s par le pont |
|
||||
|
||||
## Sismique — `usgs`
|
||||
|
||||
Source : <https://earthquake.usgs.gov/>
|
||||
|
||||
| Route | Args | Notes |
|
||||
|---------------------|-------------------------------------|--------------------------------|
|
||||
| `/data/usgs/event` | `mag lon lat depth_km age_sec` | Un par séisme nouveau |
|
||||
| `/data/usgs/rate` | `events_per_hour` | Fenêtre glissante 1 h |
|
||||
|
||||
## Météo spatiale — `swpc`
|
||||
|
||||
Source : NOAA SWPC (DSCOVR, GOES, planetary index).
|
||||
|
||||
| Route | Args | Notes |
|
||||
|-------------------|-----------------------------------|----------------------------------------|
|
||||
| `/data/swpc/wind` | `speed_kms density_pcm3 temp_K` | typique 300–800 km/s |
|
||||
| `/data/swpc/bz` | `Bz_nT Bt_nT` | Bz < 0 = orage géomag possible |
|
||||
| `/data/swpc/kp` | `Kp a_index` | Kp ∈ [0..9] |
|
||||
| `/data/swpc/xray` | `short_Wm2 long_Wm2 flare_norm` | `flare_norm` 0..1 sur classes A→X |
|
||||
|
||||
## Réseau électrique — `netzfrequenz`
|
||||
|
||||
Source : Mainsfrequenz.de WebSocket (mesure ~200 ms à Karlsruhe).
|
||||
|
||||
| Route | Args | Notes |
|
||||
|--------------------------------|-------------|------------------------------------|
|
||||
| `/data/netzfrequenz/freq` | `hz` | typiquement 49.95 .. 50.05 |
|
||||
| `/data/netzfrequenz/dev` | `delta_hz` | `hz - 50.0` |
|
||||
| `/data/netzfrequenz/time_dev` | `sec` | Dérive intégrée (horloge synchrone)|
|
||||
|
||||
## Foudre — `blitzortung`
|
||||
|
||||
Source : LightningMaps WebSocket relay (réseau Blitzortung).
|
||||
|
||||
| Route | Args | Notes |
|
||||
|-----------------------------|-------------------------------|----------------------|
|
||||
| `/data/blitzortung/strike` | `lat lon age_sec multiplicity`| Un par impact |
|
||||
| `/data/blitzortung/rate` | `strikes_per_min` | Fenêtre 60 s |
|
||||
|
||||
## Aviation — `opensky`
|
||||
|
||||
Source : OpenSky Network REST (anonyme : 15 s max).
|
||||
|
||||
| Route | Args | Notes |
|
||||
|------------------------|-----------------------------------------------------|------------------------|
|
||||
| `/data/opensky/count` | `n` | Aéronefs dans la bbox |
|
||||
| `/data/opensky/plane` | `"icao24" lon lat alt_m vel_ms heading_deg` | Un par avion par poll |
|
||||
|
||||
Note : `icao24` est une string ; côté oF elle est hashée djb2 16 bits en float.
|
||||
|
||||
## Mix électrique France — `rte_eco2mix`
|
||||
|
||||
Source : RTE Open API (OAuth2 client_credentials, gratuit).
|
||||
|
||||
| Route | Args |
|
||||
|-------------------|-------------------------------------------------------------------|
|
||||
| `/data/rte_eco2mix/mix` | `nuclear gas coal oil hydro wind solar bio` (MW) |
|
||||
|
||||
Côté SC, `~feeds[\rte_renew_pct]` calcule `(hydro+wind+solar+bio)/total`.
|
||||
|
||||
## Social — `bluesky`
|
||||
|
||||
Source : Jetstream WebSocket (firehose posts publics).
|
||||
|
||||
| Route | Args | Notes |
|
||||
|----------------------|----------------------------|--------------------------------------|
|
||||
| `/data/bluesky/post` | `text_len lang_hash` | Echantillonné selon `sample_rate` |
|
||||
| `/data/bluesky/rate` | `posts_per_sec` | Fenêtre 10 s |
|
||||
|
||||
## Bitcoin — `mempool`
|
||||
|
||||
Source : mempool.space WebSocket.
|
||||
|
||||
| Route | Args |
|
||||
|----------------------|---------------------------------------|
|
||||
| `/data/mempool/tx` | `value_btc fee_sat_vb` |
|
||||
| `/data/mempool/block`| `height tx_count reward_btc` |
|
||||
|
||||
## GitHub — `github`
|
||||
|
||||
Source : `/events` API publique (60 req/h sans token).
|
||||
|
||||
| Route | Args |
|
||||
|----------------------|-------------------------------|
|
||||
| `/data/github/event` | `type_hash repo_hash` |
|
||||
|
||||
## Pose / webcam — `pose`
|
||||
|
||||
Source : webcam locale (cv2.VideoCapture) + détection YOLOv8-pose
|
||||
(17 keypoints COCO). Tourne dans `data_feeds/feeds/pose.py`.
|
||||
|
||||
**Attention** : sur macOS, **une seule application** peut ouvrir la
|
||||
webcam à la fois. Si le worker pose tourne, désactiver
|
||||
`localCapture` dans `WebcamVis` côté oF (et inversement).
|
||||
|
||||
| Route | Args |
|
||||
|----------------------|---------------------------------------------------------------|
|
||||
| `/data/pose/count` | `n` (nombre de personnes) |
|
||||
| `/data/pose/person` | `idx cx cy w h conf` (bbox normalisée 0..1) |
|
||||
| `/data/pose/skel` | `idx avg_conf x0 y0 c0 ... x16 y16 c16` (53 args si emit_kp) |
|
||||
| `/data/pose/bone` | `kp_a kp_b` (annoncé statiquement à la connexion, 16 paires) |
|
||||
|
||||
Layout COCO (17 keypoints) :
|
||||
|
||||
```text
|
||||
0 nose 5 sho_l 6 sho_r
|
||||
1 eye_l 2 eye_r 7 elb_l 8 elb_r
|
||||
3 ear_l 4 ear_r 9 wri_l 10 wri_r
|
||||
11 hip_l 12 hip_r
|
||||
13 kne_l 14 kne_r
|
||||
15 ank_l 16 ank_r
|
||||
```
|
||||
|
||||
Côté SC, helper de lecture :
|
||||
|
||||
```supercollider
|
||||
~poseKp.(\wri_r); // → (x:, y:, c:) pour le sujet 0
|
||||
~feeds[\pose_count]; // nombre de sujets
|
||||
~feeds[\pose_persons][0]; // (cx:, cy:, w:, h:, conf:)
|
||||
```
|
||||
|
||||
## GCN — `gcn`
|
||||
|
||||
Source : NASA GCN Classic over Kafka (auth).
|
||||
|
||||
| Route | Args |
|
||||
|----------------------|-------------------------------------------|
|
||||
| `/data/gcn/alert` | `mission_hash ra_deg dec_deg err_arcmin` |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Strings → hash** : tout argument string non-essentiel est encodé en
|
||||
hash djb2 16 bits (`0..65535`) pour rester compatible numérique côté
|
||||
SynthDef et shaders. Le pont Python et `OscClient` utilisent la même
|
||||
fonction.
|
||||
- **Age** : pour les événements horodatés (USGS, Blitzortung), un
|
||||
`age_sec` est inclus pour permettre de filtrer les vieux événements
|
||||
qui arriveraient en burst après une coupure réseau.
|
||||
- **Idempotence** : tous les `OSCdef` côté SC sont libérés et recréés
|
||||
à chaque chargement de `control/data_feeds.scd`.
|
||||
+10
-4
@@ -1,9 +1,10 @@
|
||||
# AVLiveLauncher
|
||||
|
||||
macOS menubar launcher for AV-Live. Starts and stops `sclang` (which
|
||||
auto-loads `sound_algo/00_load.scd` and serves the web UI) and the
|
||||
`oscope-of` visualizer with one click each, and aggregates their logs in
|
||||
one window.
|
||||
auto-loads `sound_algo/00_load.scd` and serves the web UI), the
|
||||
`oscope-of` visualizer, and the `data_feeds` Python bridge (USGS,
|
||||
SWPC, grid frequency, lightning, pose, etc. → OSC) with one click
|
||||
each, and aggregates their logs in one window.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -27,6 +28,8 @@ Three paths are stored in `UserDefaults` (`~/Library/Preferences/cc.saillant.AVL
|
||||
| `/Applications/SuperCollider.app/Contents/MacOS/sclang` | Paths… → sclang binary |
|
||||
| `~/Documents/Projets/AV-Live/sound_algo/00_load.scd` | Paths… → load file |
|
||||
| `~/Documents/Projets/AV-Live/oscope-of/bin/oscope-of` | Paths… → oscope binary |
|
||||
| `/opt/homebrew/bin/uv` (or `~/.local/bin/uv`) | Paths… → uv binary |
|
||||
| `~/Documents/Projets/AV-Live/data_feeds` | Paths… → data_feeds directory |
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -35,4 +38,7 @@ Three paths are stored in `UserDefaults` (`~/Library/Preferences/cc.saillant.AVL
|
||||
palette, and serves the web bridge on `:8080`.
|
||||
- Spawns the `oscope-of` binary with cwd set to its parent directory
|
||||
(so oF can find `bin/data/`).
|
||||
- Quitting the menubar app sends `SIGTERM` to both children.
|
||||
- Spawns `uv run python bridge.py -v` in `data_feeds/` for the real-world
|
||||
flux → OSC bridge. Off by default (Auto-start data_feeds toggle in
|
||||
Settings to enable). uv auto-syncs the venv on first run.
|
||||
- Quitting the menubar app sends `SIGTERM` to all children.
|
||||
|
||||
@@ -36,6 +36,14 @@ struct MenuBarContent: View {
|
||||
stop: processManager.stopWeb
|
||||
)
|
||||
|
||||
ProcessRow(
|
||||
title: "Data Feeds",
|
||||
subtitle: "USGS · SWPC · Grid · Lightning · Pose · …",
|
||||
isRunning: processManager.dataFeedsRunning,
|
||||
start: processManager.startDataFeeds,
|
||||
stop: processManager.stopDataFeeds
|
||||
)
|
||||
|
||||
HStack {
|
||||
Button(action: processManager.openBrowser) {
|
||||
Label("Control", systemImage: "slider.horizontal.3")
|
||||
@@ -171,6 +179,11 @@ private struct SettingsView: View {
|
||||
get: { processManager.autoOpenBrowser },
|
||||
set: { processManager.autoOpenBrowser = $0 }
|
||||
))
|
||||
Toggle("Auto-start data_feeds bridge (USGS, SWPC, grid, pose…)",
|
||||
isOn: Binding(
|
||||
get: { processManager.autoStartDataFeeds },
|
||||
set: { processManager.autoStartDataFeeds = $0 }
|
||||
))
|
||||
Divider()
|
||||
Text("Paths").font(.headline)
|
||||
PathField(
|
||||
@@ -213,6 +226,22 @@ private struct SettingsView: View {
|
||||
),
|
||||
isDirectory: false
|
||||
)
|
||||
PathField(
|
||||
label: "uv binary",
|
||||
path: Binding(
|
||||
get: { processManager.uvPath },
|
||||
set: { processManager.uvPath = $0 }
|
||||
),
|
||||
isDirectory: false
|
||||
)
|
||||
PathField(
|
||||
label: "data_feeds directory",
|
||||
path: Binding(
|
||||
get: { processManager.dataFeedsDir },
|
||||
set: { processManager.dataFeedsDir = $0 }
|
||||
),
|
||||
isDirectory: true
|
||||
)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done", action: dismiss).keyboardShortcut(.defaultAction)
|
||||
|
||||
@@ -14,6 +14,7 @@ final class ProcessManager: ObservableObject {
|
||||
@Published var sclangRunning = false
|
||||
@Published var oscopeRunning = false
|
||||
@Published var webRunning = false
|
||||
@Published var dataFeedsRunning = false
|
||||
@Published private(set) var logs: [LogLine] = []
|
||||
|
||||
// Persisted paths (UserDefaults, key/value)
|
||||
@@ -23,13 +24,18 @@ final class ProcessManager: ObservableObject {
|
||||
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
|
||||
@Published var webServerScript: String { didSet { defaults.set(webServerScript, forKey: "webServerScript") } }
|
||||
@Published var webPort: Int { didSet { defaults.set(webPort, forKey: "webPort") } }
|
||||
@Published var uvPath: String { didSet { defaults.set(uvPath, forKey: "uvPath") } }
|
||||
@Published var dataFeedsDir: String { didSet { defaults.set(dataFeedsDir, forKey: "dataFeedsDir") } }
|
||||
@Published var autoStart: Bool { didSet { defaults.set(autoStart, forKey: "autoStart") } }
|
||||
@Published var autoOpenBrowser: Bool { didSet { defaults.set(autoOpenBrowser, forKey: "autoOpenBrowser") } }
|
||||
@Published var autoStartDataFeeds: Bool { didSet { defaults.set(autoStartDataFeeds, forKey: "autoStartDataFeeds") } }
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private var sclangProc: Process?
|
||||
private var oscopeProc: Process?
|
||||
private var webProc: Process?
|
||||
private var dataFeedsProc: Process?
|
||||
private var dataFeedsWantsRestart = false
|
||||
private var sclangWantsRestart = false
|
||||
let osc = OSCSender(host: "127.0.0.1", port: 57121)
|
||||
private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log")
|
||||
@@ -101,6 +107,17 @@ final class ProcessManager: ObservableObject {
|
||||
webPort = (defaults.object(forKey: "webPort") as? Int) ?? 3000
|
||||
autoStart = (defaults.object(forKey: "autoStart") as? Bool) ?? true
|
||||
autoOpenBrowser = (defaults.object(forKey: "autoOpenBrowser") as? Bool) ?? true
|
||||
|
||||
// uv (Astral) — usually installed via curl|sh or brew. Use ~/.local/bin
|
||||
// for the curl installer, /opt/homebrew/bin for Apple Silicon brew.
|
||||
let uvCandidates = ["/opt/homebrew/bin/uv", "/usr/local/bin/uv",
|
||||
"\(home)/.local/bin/uv", "/usr/bin/uv"]
|
||||
uvPath = defaults.string(forKey: "uvPath")
|
||||
?? (uvCandidates.first(where: { fm.isExecutableFile(atPath: $0) })
|
||||
?? "/opt/homebrew/bin/uv")
|
||||
dataFeedsDir = defaults.string(forKey: "dataFeedsDir")
|
||||
?? "\(avLive)/data_feeds"
|
||||
autoStartDataFeeds = (defaults.object(forKey: "autoStartDataFeeds") as? Bool) ?? false
|
||||
}
|
||||
|
||||
/// Start everything that's currently stopped. Used by AppDelegate on
|
||||
@@ -117,6 +134,12 @@ final class ProcessManager: ObservableObject {
|
||||
guard let self = self else { return }
|
||||
if !self.webRunning { self.startWeb() }
|
||||
}
|
||||
if autoStartDataFeeds {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !self.dataFeedsRunning { self.startDataFeeds() }
|
||||
}
|
||||
}
|
||||
if autoOpenBrowser {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -394,12 +417,77 @@ final class ProcessManager: ObservableObject {
|
||||
return URL(fileURLWithPath: binaryPath).deletingLastPathComponent()
|
||||
}
|
||||
|
||||
// MARK: - data_feeds bridge (Python → OSC)
|
||||
|
||||
/// Lance `uv run python bridge.py -v` depuis `dataFeedsDir`. uv s'occupe
|
||||
/// de creer/sync le venv au premier run. Le pont diffuse sur :57121
|
||||
/// (sclang) et :57123 (oF) selon config.toml.
|
||||
func startDataFeeds() {
|
||||
guard dataFeedsProc == nil else { return }
|
||||
guard FileManager.default.isExecutableFile(atPath: uvPath) else {
|
||||
append(source: "launcher", text: "uv not found at \(uvPath) — install with: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
return
|
||||
}
|
||||
let bridgePy = dataFeedsDir + "/bridge.py"
|
||||
guard FileManager.default.fileExists(atPath: bridgePy) else {
|
||||
append(source: "launcher", text: "bridge.py not found at \(bridgePy)")
|
||||
return
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: uvPath)
|
||||
p.arguments = ["run", "python", "bridge.py", "-v"]
|
||||
p.currentDirectoryURL = URL(fileURLWithPath: dataFeedsDir)
|
||||
// uv resout les binaries depuis ~/.local/bin et /opt/homebrew/bin
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
|
||||
+ ":/usr/local/bin:/opt/homebrew/bin:\(env["HOME"] ?? "")/.local/bin"
|
||||
// Force la couleur off pour des logs propres dans la TextView
|
||||
env["NO_COLOR"] = "1"
|
||||
p.environment = env
|
||||
attach(process: p, label: "feeds")
|
||||
do {
|
||||
try p.run()
|
||||
dataFeedsProc = p
|
||||
DispatchQueue.main.async { self.dataFeedsRunning = true }
|
||||
p.terminationHandler = { [weak self] proc in
|
||||
self?.append(source: "feeds", text: "exited with status \(proc.terminationStatus)")
|
||||
DispatchQueue.main.async {
|
||||
self?.dataFeedsProc = nil
|
||||
self?.dataFeedsRunning = false
|
||||
if self?.dataFeedsWantsRestart == true {
|
||||
self?.dataFeedsWantsRestart = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
||||
self?.startDataFeeds()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
append(source: "launcher", text: "started data_feeds bridge (\(dataFeedsDir))")
|
||||
} catch {
|
||||
append(source: "launcher", text: "failed to start data_feeds: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stopDataFeeds() {
|
||||
dataFeedsWantsRestart = false
|
||||
dataFeedsProc?.terminate()
|
||||
}
|
||||
|
||||
func restartDataFeeds() {
|
||||
guard dataFeedsProc != nil else { startDataFeeds(); return }
|
||||
append(source: "launcher", text: "restarting data_feeds…")
|
||||
dataFeedsWantsRestart = true
|
||||
dataFeedsProc?.terminate()
|
||||
dataFeedsWantsRestart = true
|
||||
}
|
||||
|
||||
// MARK: - utilities
|
||||
|
||||
func stopAll() {
|
||||
sclangProc?.terminate()
|
||||
oscopeProc?.terminate()
|
||||
webProc?.terminate()
|
||||
dataFeedsProc?.terminate()
|
||||
}
|
||||
|
||||
func clearLogs() {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
ofxOsc
|
||||
ofxGui
|
||||
ofxOpenCv
|
||||
|
||||
@@ -74,6 +74,7 @@ void ofApp::setup() {
|
||||
spectro_ = std::make_unique<oscope::SpectrogramVis>();
|
||||
reactive_ = std::make_unique<oscope::ReactiveVis>();
|
||||
waveform_ = std::make_unique<oscope::WaveformVis>();
|
||||
webcam_ = std::make_unique<oscope::WebcamVis>();
|
||||
polar_ = std::make_unique<oscope::PolarVis>();
|
||||
plasma_ = std::make_unique<oscope::PlasmaVis>();
|
||||
particles_ = std::make_unique<oscope::ParticleVis>();
|
||||
@@ -157,6 +158,7 @@ void ofApp::setup() {
|
||||
spectro_->setup(W, H / 4);
|
||||
reactive_->setup(W, H);
|
||||
waveform_->setup(W, H);
|
||||
webcam_->setup(W, H);
|
||||
polar_->setup(W, H);
|
||||
plasma_->setup(W, H);
|
||||
particles_->setup(W, H);
|
||||
@@ -385,6 +387,7 @@ void ofApp::update() {
|
||||
spectro_->update(frame);
|
||||
reactive_->update(frame);
|
||||
waveform_->update(frame);
|
||||
webcam_->update(frame);
|
||||
polar_->update(frame);
|
||||
plasma_->update(frame);
|
||||
particles_->update(frame);
|
||||
@@ -1354,6 +1357,7 @@ void ofApp::drawScope4(int W, int H) {
|
||||
case BgKind::ImgIrix: imgIrix_->draw(0, 0, W, H); break;
|
||||
case BgKind::ImgZX: imgZX_->draw(0, 0, W, H); break;
|
||||
case BgKind::ImgC64: imgC64_->draw(0, 0, W, H); break;
|
||||
case BgKind::Webcam: webcam_->draw(0, 0, W, H); break;
|
||||
}
|
||||
|
||||
// 1bis) HUD pseudo-aléatoire de valeurs sub-10 Hz.
|
||||
@@ -1663,6 +1667,8 @@ void ofApp::keyPressed(int key) {
|
||||
case 'i': liveBg_ = BgKind::Mode7; liveBgOverride_=true; break;
|
||||
case 'o': liveBg_ = BgKind::Octahedron; liveBgOverride_=true; break;
|
||||
case 'p': liveBg_ = BgKind::PlasmaC64; liveBgOverride_=true; break;
|
||||
// Webcam + ofxOpenCv + overlay pose (touche '&' = AZERTY shift+1).
|
||||
case '&': liveBg_ = BgKind::Webcam; liveBgOverride_=true; break;
|
||||
|
||||
// --- 17 paramètres FX (middle + bottom row) ---
|
||||
// Tunnel mults (multiplicatifs ×0.83/×1.20, clamp 0.05..12)
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "visualizers/ModelVis.h"
|
||||
#include "visualizers/SphereWaveVis.h"
|
||||
#include "visualizers/ImageVis.h"
|
||||
#include "visualizers/WebcamVis.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
@@ -108,7 +109,8 @@ private:
|
||||
SphereWave,
|
||||
ImgWin1, ImgWin311, ImgWin95, ImgLotus, ImgDos,
|
||||
ImgAtari, ImgApple, ImgWB, ImgNeXT, ImgBeOS,
|
||||
ImgOS2, ImgIrix, ImgZX, ImgC64 };
|
||||
ImgOS2, ImgIrix, ImgZX, ImgC64,
|
||||
Webcam };
|
||||
struct DemoScene {
|
||||
const char* name;
|
||||
float durSec;
|
||||
@@ -227,6 +229,8 @@ private:
|
||||
std::unique_ptr<oscope::ImageVis> imgLotus_, imgDos_, imgAtari_, imgApple_;
|
||||
std::unique_ptr<oscope::ImageVis> imgWB_, imgNeXT_, imgBeOS_, imgOS2_;
|
||||
std::unique_ptr<oscope::ImageVis> imgIrix_, imgZX_, imgC64_;
|
||||
// Webcam + ofxOpenCv + overlay du skeleton OSC (/data/pose/*).
|
||||
std::unique_ptr<oscope::WebcamVis> webcam_;
|
||||
|
||||
std::vector<float> ch1_, ch2_;
|
||||
Mode mode_ = Mode::Scope4;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "WebcamVis.h"
|
||||
|
||||
namespace oscope {
|
||||
|
||||
namespace {
|
||||
// Squelette COCO (17 kp) — paires d'os.
|
||||
constexpr std::array<std::pair<int, int>, 16> kBones = {{
|
||||
{0, 1}, {0, 2}, {1, 3}, {2, 4},
|
||||
{5, 6}, {5, 7}, {7, 9}, {6, 8}, {8, 10},
|
||||
{5, 11}, {6, 12}, {11, 12},
|
||||
{11, 13}, {13, 15}, {12, 14}, {14, 16},
|
||||
}};
|
||||
}
|
||||
|
||||
void WebcamVis::setup(int w, int h) {
|
||||
w_ = w; h_ = h;
|
||||
if (localCapture_) {
|
||||
grabber_.setDeviceID(0);
|
||||
grabber_.setDesiredFrameRate(30);
|
||||
cameraOk_ = grabber_.setup(camW_, camH_);
|
||||
if (!cameraOk_) {
|
||||
ofLogWarning("WebcamVis") << "camera unavailable, fallback to skeleton-only";
|
||||
} else {
|
||||
colorImg_.allocate(camW_, camH_);
|
||||
grayImg_.allocate(camW_, camH_);
|
||||
prevGray_.allocate(camW_, camH_);
|
||||
diffImg_.allocate(camW_, camH_);
|
||||
edgesImg_.allocate(camW_, camH_);
|
||||
threshImg_.allocate(camW_, camH_);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WebcamVis::update(const VisFrame& frame) {
|
||||
// -- Capture + pipeline OpenCV (mode A) ---------------------------
|
||||
if (cameraOk_) {
|
||||
grabber_.update();
|
||||
if (grabber_.isFrameNew()) {
|
||||
colorImg_.setFromPixels(grabber_.getPixels());
|
||||
if (mirror_) colorImg_.mirror(false, true);
|
||||
grayImg_ = colorImg_;
|
||||
// edges via Canny-like (Sobel + threshold)
|
||||
edgesImg_ = grayImg_;
|
||||
edgesImg_.blurGaussian(3);
|
||||
// ofxOpenCv ne wrappe pas Canny directement → on fait
|
||||
// une approx : abs(diff) avec un soft-threshold.
|
||||
threshImg_ = grayImg_;
|
||||
threshImg_.threshold((int)threshold_);
|
||||
if (haveFrame_) {
|
||||
diffImg_.absDiff(prevGray_, grayImg_);
|
||||
diffImg_.threshold(15);
|
||||
}
|
||||
prevGray_ = grayImg_;
|
||||
haveFrame_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Lecture du skeleton OSC --------------------------------------
|
||||
personCount_ = (int)frame.osc.dataf("pose", "count", 0.0f);
|
||||
std::vector<float> skel;
|
||||
if (frame.osc.consumeDataPulse("pose", "skel", skel)) {
|
||||
// skel = [idx, avg, x0,y0,c0, x1,y1,c1, ...] (53 floats si idx=0)
|
||||
if (skel.size() >= 2 + 17 * 3) {
|
||||
skelAvgConf_ = skel[1];
|
||||
for (int i = 0; i < 17; ++i) {
|
||||
int o = 2 + i * 3;
|
||||
skel_[i] = {skel[o], skel[o + 1], skel[o + 2]};
|
||||
}
|
||||
lastSkelT_ = ofGetElapsedTimef();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WebcamVis::draw(int x, int y, int w, int h) {
|
||||
ofPushStyle();
|
||||
ofSetColor(255);
|
||||
|
||||
// -- Couche image -------------------------------------------------
|
||||
if (cameraOk_ && haveFrame_) {
|
||||
switch (mode_) {
|
||||
case 1: edgesImg_.draw(x, y, w, h); break;
|
||||
case 2: threshImg_.draw(x, y, w, h); break;
|
||||
case 3: diffImg_.draw(x, y, w, h); break;
|
||||
case 0:
|
||||
default: colorImg_.draw(x, y, w, h); break;
|
||||
}
|
||||
} else {
|
||||
ofSetColor(20);
|
||||
ofDrawRectangle(x, y, w, h);
|
||||
ofSetColor(120);
|
||||
ofDrawBitmapString("no camera — skeleton only", x + 16, y + 24);
|
||||
}
|
||||
|
||||
// -- Overlay skeleton --------------------------------------------
|
||||
const double now = ofGetElapsedTimef();
|
||||
const bool fresh = lastSkelT_ >= 0.0 && (now - lastSkelT_) < 1.0;
|
||||
if (!fresh || personCount_ == 0) { ofPopStyle(); return; }
|
||||
|
||||
auto mapX = [&](float xn) { return x + (mirror_ ? (1.f - xn) : xn) * w; };
|
||||
auto mapY = [&](float yn) { return y + yn * h; };
|
||||
|
||||
// Bones
|
||||
ofSetLineWidth(2.5f);
|
||||
for (auto [a, b] : kBones) {
|
||||
const auto& A = skel_[a];
|
||||
const auto& B = skel_[b];
|
||||
if (A.c < 0.2f || B.c < 0.2f) continue;
|
||||
float cv = std::min(A.c, B.c);
|
||||
ofSetColor(0, 255 * cv, 200 * cv, 255 * skelAlpha_);
|
||||
ofDrawLine(mapX(A.x), mapY(A.y), mapX(B.x), mapY(B.y));
|
||||
}
|
||||
// Joints
|
||||
for (int i = 0; i < 17; ++i) {
|
||||
const auto& K = skel_[i];
|
||||
if (K.c < 0.2f) continue;
|
||||
ofSetColor(255, 80, 80, 255 * skelAlpha_);
|
||||
ofDrawCircle(mapX(K.x), mapY(K.y), 4.0f + 4.0f * K.c);
|
||||
}
|
||||
|
||||
// HUD
|
||||
ofSetColor(180, 220);
|
||||
ofDrawBitmapString(
|
||||
"pose: " + ofToString(personCount_) + "p conf=" +
|
||||
ofToString(skelAvgConf_, 2),
|
||||
x + 12, y + h - 12);
|
||||
|
||||
ofPopStyle();
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
// Webcam + ofxOpenCv pipeline + overlay du skeleton recu via OSC
|
||||
// (/data/pose/skel <idx> <avg> <x0 y0 c0 ... x16 y16 c16>).
|
||||
//
|
||||
// La capture webcam est faite EN LOCAL par oF (ofVideoGrabber). La
|
||||
// detection de pose tourne dans le pont Python (data_feeds/feeds/pose.py)
|
||||
// sur la MEME webcam que celle que oF ouvre ? Non : sur Mac, une seule
|
||||
// app peut grabber la camera. On a 2 strategies :
|
||||
//
|
||||
// A) oF capture localement et applique du fx OpenCV (contours, frame
|
||||
// diff, threshold, optical flow). Pas de detection de pose oF-side.
|
||||
// Le pont Python NE tourne PAS le worker pose.
|
||||
//
|
||||
// B) Le pont Python grabbe la webcam, fait la detection, et expose un
|
||||
// stream MJPEG/UDP en plus de l'OSC. oF lit ce stream comme
|
||||
// ofVideoGrabber. (TODO si necessaire — non implemente ici.)
|
||||
//
|
||||
// Choix par defaut : A. setEnableLocalCapture(false) pour mode B.
|
||||
|
||||
#include "Visualizer.h"
|
||||
#include "ofMain.h"
|
||||
#include "ofxOpenCv.h"
|
||||
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
class WebcamVis : public Visualizer {
|
||||
public:
|
||||
void setup(int w, int h) override;
|
||||
void update(const VisFrame& frame) override;
|
||||
void draw(int x, int y, int w, int h) override;
|
||||
|
||||
// Live tweaks.
|
||||
void setEnableLocalCapture(bool b) { localCapture_ = b; }
|
||||
void setThreshold(float v) { threshold_ = ofClamp(v, 0.f, 255.f); }
|
||||
void setEdgeAmount(float v) { edgeAmount_ = ofClamp(v, 0.f, 1.f); }
|
||||
void setSkeletonAlpha(float v) { skelAlpha_ = ofClamp(v, 0.f, 1.f); }
|
||||
void setMode(int m) { mode_ = m; } // 0 raw 1 edges 2 thresh 3 diff
|
||||
void setMirror(bool b) { mirror_ = b; }
|
||||
|
||||
private:
|
||||
int w_ = 0, h_ = 0;
|
||||
int camW_ = 640, camH_ = 480;
|
||||
bool localCapture_ = true;
|
||||
bool cameraOk_ = false;
|
||||
bool mirror_ = true;
|
||||
|
||||
ofVideoGrabber grabber_;
|
||||
ofxCvColorImage colorImg_;
|
||||
ofxCvGrayscaleImage grayImg_;
|
||||
ofxCvGrayscaleImage prevGray_;
|
||||
ofxCvGrayscaleImage diffImg_;
|
||||
ofxCvGrayscaleImage edgesImg_;
|
||||
ofxCvGrayscaleImage threshImg_;
|
||||
bool haveFrame_ = false;
|
||||
|
||||
int mode_ = 0;
|
||||
float threshold_ = 80.0f;
|
||||
float edgeAmount_ = 0.5f;
|
||||
float skelAlpha_ = 1.0f;
|
||||
|
||||
// Cache du skeleton OSC : 17 keypoints x (x,y,conf), pour le sujet 0.
|
||||
struct Kp { float x = 0, y = 0, c = 0; };
|
||||
std::array<Kp, 17> skel_{};
|
||||
float skelAvgConf_ = 0.0f;
|
||||
int personCount_ = 0;
|
||||
double lastSkelT_ = -1.0;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -174,6 +174,63 @@
|
||||
"*** GCN alert ***".postln; msg.postln;
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// POSE -- webcam + YOLOv8-pose (17 keypoints COCO)
|
||||
// =====================================================================
|
||||
// Layout COCO :
|
||||
// 0 nose, 1/2 eyes L/R, 3/4 ears L/R,
|
||||
// 5/6 shoulders L/R, 7/8 elbows L/R, 9/10 wrists L/R,
|
||||
// 11/12 hips L/R, 13/14 knees L/R, 15/16 ankles L/R
|
||||
~poseKpNames = [\nose, \eye_l, \eye_r, \ear_l, \ear_r,
|
||||
\sho_l, \sho_r, \elb_l, \elb_r, \wri_l, \wri_r,
|
||||
\hip_l, \hip_r, \kne_l, \kne_r, \ank_l, \ank_r];
|
||||
|
||||
~_feedDef.(\d_pose_count, '/data/pose/count', { |msg|
|
||||
~feedSet.(\pose_count, msg[1]);
|
||||
});
|
||||
~_feedDef.(\d_pose_person, '/data/pose/person', { |msg|
|
||||
var idx = msg[1].asInteger;
|
||||
var persons = ~feeds[\pose_persons] ? IdentityDictionary.new;
|
||||
persons[idx] = (cx: msg[2], cy: msg[3], w: msg[4], h: msg[5], conf: msg[6]);
|
||||
~feedSet.(\pose_persons, persons);
|
||||
});
|
||||
~_feedDef.(\d_pose_skel, '/data/pose/skel', { |msg|
|
||||
var idx = msg[1].asInteger;
|
||||
var avg = msg[2];
|
||||
// msg[3..] = [x0,y0,c0, x1,y1,c1, ..., x16,y16,c16] (51 floats)
|
||||
var flat = msg.copyRange(3, msg.size - 1);
|
||||
var skel = Array.new(17);
|
||||
17.do { |k|
|
||||
var off = k * 3;
|
||||
skel = skel.add((
|
||||
x: flat[off],
|
||||
y: flat[off + 1],
|
||||
c: flat[off + 2],
|
||||
name: ~poseKpNames[k]
|
||||
));
|
||||
};
|
||||
var all = ~feeds[\pose_skels] ? IdentityDictionary.new;
|
||||
all[idx] = (avg: avg, kp: skel);
|
||||
~feedSet.(\pose_skels, all);
|
||||
// raccourci : premier sujet
|
||||
if(idx == 0) { ~feedSet.(\pose_skel0, skel) };
|
||||
});
|
||||
~_feedDef.(\d_pose_bone, '/data/pose/bone', { |msg|
|
||||
// Annonce du squelette a la connexion. On stocke la liste des paires.
|
||||
var bones = ~feeds[\pose_bones] ? Array.new;
|
||||
var pair = [msg[1].asInteger, msg[2].asInteger];
|
||||
if(bones.indexOfEqual(pair).isNil) {
|
||||
~feedSet.(\pose_bones, bones.add(pair));
|
||||
};
|
||||
});
|
||||
|
||||
// Helper : lit le keypoint `name` du sujet 0. Renvoie (x:, y:, c:).
|
||||
~poseKp = { |name|
|
||||
var skel = ~feeds[\pose_skel0];
|
||||
var i = ~poseKpNames.indexOf(name);
|
||||
if(skel.notNil and: { i.notNil }) { skel[i] } { nil }
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// Heartbeat -- detection de pont down
|
||||
// =====================================================================
|
||||
|
||||
@@ -208,6 +208,77 @@ s.sync;
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [4] PRESET D -- "Body"
|
||||
// Corps detecte (YOLOv8-pose) pilote 4 synths :
|
||||
// - poignet droit -> filtre passe-bas (X hor) + amp (Y vert)
|
||||
// - poignet gauche -> pitch d'un drone
|
||||
// - hauteur des epaules -> seuil de granulateur
|
||||
// - confiance moyenne -> reverb wet
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\body_drone, { |out=0, freq=80, amp=0.25, cutoff=800, q=0.4, pan=0|
|
||||
var sig = Saw.ar(freq * [1, 1.005, 0.503]);
|
||||
sig = RLPF.ar(sig, cutoff.lag(0.05).clip(80, 8000), q);
|
||||
sig = sig.sum * amp;
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
SynthDef(\body_gran, { |out=0, dens=10, rate=1.0, amp=0.3|
|
||||
var trig = Impulse.kr(dens);
|
||||
var grain = SinOsc.ar(
|
||||
TRand.kr(200, 1200, trig) * rate.lag(0.1),
|
||||
0,
|
||||
EnvGen.kr(Env.perc(0.005, 0.05), trig)
|
||||
);
|
||||
Out.ar(out, Pan2.ar(grain * amp, TRand.kr(-1, 1, trig)));
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
~bodyDrone = Synth(\body_drone, [\amp, 0.2]);
|
||||
~bodyGran = Synth(\body_gran, [\amp, 0.15]);
|
||||
|
||||
~bodyR = Routine({
|
||||
inf.do {
|
||||
var wr = ~poseKp.(\wri_r);
|
||||
var wl = ~poseKp.(\wri_l);
|
||||
var sl = ~poseKp.(\sho_l), sR = ~poseKp.(\sho_r);
|
||||
var avg, shoY;
|
||||
if(wr.notNil and: { wr.c > 0.3 }) {
|
||||
~bodyDrone.set(\cutoff, wr.x.linexp(0, 1, 200, 6000));
|
||||
~bodyDrone.set(\amp, (1 - wr.y).linlin(0, 1, 0.05, 0.5));
|
||||
};
|
||||
if(wl.notNil and: { wl.c > 0.3 }) {
|
||||
~bodyDrone.set(\freq, wl.y.linexp(0, 1, 220, 55));
|
||||
};
|
||||
if(sl.notNil and: { sR.notNil }) {
|
||||
shoY = (sl.y + sR.y) * 0.5;
|
||||
~bodyGran.set(\dens, (1 - shoY).linexp(0, 1, 2, 80));
|
||||
~bodyGran.set(\rate, (1 - shoY).linlin(0, 1, 0.5, 2.5));
|
||||
};
|
||||
avg = ~feedGet.(\pose_count, 0);
|
||||
if(avg < 1) {
|
||||
// personne -> on baisse tout
|
||||
~bodyDrone.set(\amp, 0.0);
|
||||
~bodyGran.set(\amp, 0.0);
|
||||
} {
|
||||
~bodyGran.set(\amp, 0.15);
|
||||
};
|
||||
0.05.wait;
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
"[body] up. arret : ~bodyStop.value".postln;
|
||||
~bodyStop = {
|
||||
~bodyDrone.release(2);
|
||||
~bodyGran.release(1);
|
||||
~bodyR.stop;
|
||||
"[body] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [9] ARRET TOTAL
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -215,4 +286,5 @@ s.sync;
|
||||
~cavityStop !? { ~cavityStop.value };
|
||||
~mixStop !? { ~mixStop.value };
|
||||
~geoStop !? { ~geoStop.value };
|
||||
~bodyStop !? { ~bodyStop.value };
|
||||
)
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// =====================================================================
|
||||
// 17_data_feeds_more.scd -- Presets supplementaires data_feeds.
|
||||
//
|
||||
// Suite de 16_data_feeds.scd. Quatre nouveaux mappings, autonomes,
|
||||
// a executer un par un dans l'IDE.
|
||||
//
|
||||
// Prerequis :
|
||||
// [0] 01_live.scd bloc init
|
||||
// [1] cd data_feeds && uv run python bridge.py
|
||||
// [2] "sound_algo/control/data_feeds.scd".loadRelative
|
||||
// =====================================================================
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [1] PRESET D -- "Aurora"
|
||||
// Vent solaire + Bz IMF + Kp pilotent un drone spectral evolutif.
|
||||
// Bz negatif (couplage geomagnetique) -> pad descend en frequence.
|
||||
// Vitesse du vent -> decalage Doppler (legere desaccord chorus).
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\aurora_pad, { |out=0, freq=110, amp=0.3, detune=0.005, brillance=2000, ratio=1|
|
||||
var harm = freq * [1, ratio, 2, 2.01 * ratio, 3, 3.02 * ratio, 5];
|
||||
var amps = [1, 0.7, 0.5, 0.4, 0.3, 0.25, 0.15];
|
||||
var sig = SinOsc.ar(harm * (1 + (LFNoise2.kr(0.2 ! 7) * detune)), 0, amps).sum;
|
||||
sig = sig + (Saw.ar(freq * 0.5) * 0.1);
|
||||
sig = LPF.ar(sig, brillance.lag(0.5).clip(120, 8000));
|
||||
sig = LeakDC.ar(sig).tanh * amp;
|
||||
Out.ar(out, Splay.ar([sig, DelayN.ar(sig, 0.04, 0.025)]));
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
~aurora = Synth(\aurora_pad, [\freq, 55, \amp, 0.22]);
|
||||
|
||||
// Bz IMF : -20..+20 nT typique. Tres negatif = drone descend, brillance baisse.
|
||||
~auroraBz = Routine({
|
||||
inf.do {
|
||||
var bz = ~feedGet.(\swpc_bz, 0);
|
||||
var wind = ~feedGet.(\swpc_wind_speed, 400);
|
||||
var kp = ~feedGet.(\swpc_kp, 2);
|
||||
// Bz tres negatif -> shift de freq vers le bas
|
||||
~aurora.set(\freq, 55 * bz.linexp(-20, 20, 0.5, 1.4));
|
||||
// Vent solaire 250..900 km/s -> detune (Doppler)
|
||||
~aurora.set(\detune, wind.linlin(250, 900, 0.002, 0.025));
|
||||
// Kp 0..9 -> brillance / ouverture LPF
|
||||
~aurora.set(\brillance, kp.linexp(0, 9, 600, 8000));
|
||||
// amp legerement modulee par a-index
|
||||
~aurora.set(\amp, ~feedGet.(\swpc_a, 5).linlin(0, 50, 0.18, 0.32));
|
||||
2.0.wait;
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
"[aurora] up. arret : ~auroraStop.value".postln;
|
||||
~auroraStop = {
|
||||
~aurora.release(4);
|
||||
~auroraBz.stop;
|
||||
"[aurora] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [2] PRESET E -- "Pulse"
|
||||
// Battement principal genere par la frequence du reseau europeen.
|
||||
// 50.0 Hz pile = pulse stable. Les ecarts pilotent un layer hat.
|
||||
// Bluesky firehose -> nuage de clicks granulaires (densite = post/s).
|
||||
// USGS -> sub-boom proportionnel au log(magnitude).
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\pulse_kick, { |out=0, freq=55, amp=0.7, dur=0.4, sweep=0.4|
|
||||
var env = EnvGen.kr(Env.perc(0.005, dur, 1, -4), doneAction: 2);
|
||||
var fenv = EnvGen.kr(Env([freq * (1 + sweep * 4), freq], [dur * 0.3], -8));
|
||||
var sig = SinOsc.ar(fenv) * env;
|
||||
sig = (sig * 1.4).tanh;
|
||||
Out.ar(out, Pan2.ar(sig * amp, 0));
|
||||
}).add;
|
||||
|
||||
SynthDef(\pulse_hat, { |out=0, freq=8000, amp=0.3, dur=0.04, pan=0|
|
||||
var env = EnvGen.kr(Env.perc(0.001, dur), doneAction: 2);
|
||||
var sig = HPF.ar(WhiteNoise.ar, freq) * env * amp;
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
SynthDef(\pulse_click, { |out=0, freq=2000, amp=0.2, pan=0|
|
||||
var env = EnvGen.kr(Env.perc(0.0005, 0.02), doneAction: 2);
|
||||
var sig = (SinOsc.ar(freq) + WhiteNoise.ar(0.3)) * env * amp;
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
SynthDef(\pulse_sub, { |out=0, freq=35, amp=0.6, dur=2|
|
||||
var env = EnvGen.kr(Env([0, 1, 0.4, 0], [0.05, dur * 0.3, dur * 0.65], [-2, -2, -3]),
|
||||
doneAction: 2);
|
||||
var sig = SinOsc.ar(freq) * env;
|
||||
sig = sig + (LFTri.ar(freq * 0.5) * env * 0.2);
|
||||
Out.ar(out, Pan2.ar(sig.tanh * amp, 0));
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
// Routine principale : kick au tempo dicte par la frequence reseau (50 Hz +- dev)
|
||||
~pulseClock = Routine({
|
||||
inf.do {
|
||||
var hz = ~feedGet.(\netz_freq, 50.0);
|
||||
var dev = ~feedGet.(\netz_dev, 0);
|
||||
// tempo musical : 50 Hz -> 125 BPM ; +0.05 Hz -> +1.25 BPM
|
||||
var bpm = hz * 2.5;
|
||||
var beat = 60 / bpm;
|
||||
Synth(\pulse_kick, [\freq, 50 + (dev * 30), \amp, 0.7]);
|
||||
// double les hats si la grille est tres stable
|
||||
if(dev.abs < 0.02) {
|
||||
(beat / 2).wait;
|
||||
Synth(\pulse_hat, [\freq, 9000, \amp, 0.18, \pan, 0.3]);
|
||||
(beat / 2).wait;
|
||||
} {
|
||||
beat.wait;
|
||||
};
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
// Bluesky : chaque post devient un click discret. On limite via rate.
|
||||
~pulseBskySub = ~feedSub.(\social_rate_s, { |rate|
|
||||
// ~rate posts/sec : on echantillonne pour generer ~rate clicks/sec
|
||||
var n = rate.clip(0, 30).asInteger;
|
||||
n.do { |i|
|
||||
AppClock.sched(i * 0.03 + 1.0.rand, {
|
||||
Synth(\pulse_click, [
|
||||
\freq, exprand(800, 8000),
|
||||
\pan, 1.0.rand2,
|
||||
\amp, 0.06 + 0.1.rand
|
||||
]); nil;
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// USGS : sub-boom log-magnitude
|
||||
~pulseQuakeSub = ~feedSub.(\usgs_last_mag, { |mag|
|
||||
Synth(\pulse_sub, [
|
||||
\freq, mag.linexp(2, 7.5, 30, 90),
|
||||
\dur, mag.linlin(2, 7.5, 1.5, 5),
|
||||
\amp, mag.linlin(2, 7.5, 0.4, 0.85),
|
||||
]);
|
||||
});
|
||||
|
||||
"[pulse] up. arret : ~pulseStop.value".postln;
|
||||
~pulseStop = {
|
||||
~pulseClock.stop;
|
||||
~feedUnsub.(\social_rate_s, ~pulseBskySub);
|
||||
~feedUnsub.(\usgs_last_mag, ~pulseQuakeSub);
|
||||
"[pulse] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [3] PRESET F -- "Carbone"
|
||||
// eCO2mix RTE 8 filieres -> 8-operateur FM additif.
|
||||
// Chaque filiere pilote un partial : amplitude = part dans le mix.
|
||||
// Renouvelables (hydro, eolien, solaire, bio) -> partials harmoniques.
|
||||
// Fossiles (gaz, charbon, fioul) -> partials inharmoniques (bruit).
|
||||
// Nucleaire -> drone continu.
|
||||
//
|
||||
// ATTENTION : eCO2mix necessite token RTE (cf. config.toml).
|
||||
// En l'absence de donnees, le synth tourne sur valeurs par defaut.
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\carbone_op, { |out=0, freq=110, amp=0.0, harm=1, fm=0, inharm=0|
|
||||
var f = freq * harm + (LFNoise2.kr(8) * inharm * freq);
|
||||
var car = SinOsc.ar(f, SinOsc.ar(f * 0.5, 0, fm * f * 0.3));
|
||||
car = car * amp.lag(1.0);
|
||||
Out.ar(out, Pan2.ar(car, harm.linlin(1, 8, -0.8, 0.8)));
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
// 8 operateurs : nuc, gas, coal, oil, hydro, wind, solar, bio
|
||||
~carboneOps = ();
|
||||
~carboneOps[\nuclear] = Synth(\carbone_op, [\harm, 1, \inharm, 0, \amp, 0.3]);
|
||||
~carboneOps[\gas] = Synth(\carbone_op, [\harm, 7, \inharm, 0.04, \amp, 0]);
|
||||
~carboneOps[\coal] = Synth(\carbone_op, [\harm, 9, \inharm, 0.06, \amp, 0]);
|
||||
~carboneOps[\oil] = Synth(\carbone_op, [\harm, 11, \inharm, 0.08, \amp, 0]);
|
||||
~carboneOps[\hydro] = Synth(\carbone_op, [\harm, 2, \inharm, 0, \amp, 0]);
|
||||
~carboneOps[\wind] = Synth(\carbone_op, [\harm, 3, \inharm, 0, \amp, 0]);
|
||||
~carboneOps[\solar] = Synth(\carbone_op, [\harm, 5, \inharm, 0, \amp, 0]);
|
||||
~carboneOps[\bio] = Synth(\carbone_op, [\harm, 4, \inharm, 0, \amp, 0]);
|
||||
|
||||
// FM globale : intensite carbone -> brillance FM
|
||||
~carboneR = Routine({
|
||||
inf.do {
|
||||
var total = ~feedGet.(\rte_total, 50000);
|
||||
var renew = ~feedGet.(\rte_renew_pct, 0.25);
|
||||
var gPart = if(total > 0) { ~feedGet.(\rte_gas, 0) / total } { 0 };
|
||||
var cPart = if(total > 0) { ~feedGet.(\rte_coal, 0) / total } { 0 };
|
||||
var oPart = if(total > 0) { ~feedGet.(\rte_oil, 0) / total } { 0 };
|
||||
var hPart = if(total > 0) { ~feedGet.(\rte_hydro, 0)/ total } { 0 };
|
||||
var wPart = if(total > 0) { ~feedGet.(\rte_wind, 0) / total } { 0 };
|
||||
var sPart = if(total > 0) { ~feedGet.(\rte_solar, 0)/ total } { 0 };
|
||||
var bPart = if(total > 0) { ~feedGet.(\rte_bio, 0) / total } { 0 };
|
||||
~carboneOps[\gas] .set(\amp, gPart * 0.6, \fm, gPart * 1.5);
|
||||
~carboneOps[\coal] .set(\amp, cPart * 0.6, \fm, cPart * 2.0);
|
||||
~carboneOps[\oil] .set(\amp, oPart * 0.7, \fm, oPart * 2.5);
|
||||
~carboneOps[\hydro] .set(\amp, hPart * 0.4);
|
||||
~carboneOps[\wind] .set(\amp, wPart * 0.4);
|
||||
~carboneOps[\solar] .set(\amp, sPart * 0.5);
|
||||
~carboneOps[\bio] .set(\amp, bPart * 0.3);
|
||||
~carboneOps[\nuclear].set(\amp, 0.3 * (1 - renew));
|
||||
5.0.wait;
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
"[carbone] up. arret : ~carboneStop.value".postln;
|
||||
~carboneStop = {
|
||||
~carboneR.stop;
|
||||
~carboneOps.do { |s| s.set(\amp, 0); AppClock.sched(2.0, { s.free; nil }); };
|
||||
~carboneOps = ();
|
||||
"[carbone] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [4] PRESET G -- "Skylane"
|
||||
// OpenSky ADS-B -> nuage granular continu.
|
||||
// Chaque avion = grain perpetuel : altitude = pitch, vitesse = densite,
|
||||
// cap = pan stereo. Le nombre d'avions visibles (count) controle un
|
||||
// filtre passe-bande global.
|
||||
// Les flares X-ray SWPC interrompent tout (silence dramatique 2 s).
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\sky_grain, { |out=0, freq=440, amp=0.0, density=10, pan=0, bright=2|
|
||||
var trig = Dust.kr(density.lag(0.5).clip(0.5, 50));
|
||||
var env = EnvGen.kr(Env.perc(0.002, 0.08), trig);
|
||||
var sig = (SinOsc.ar(freq) + (Saw.ar(freq * 1.005) * 0.3)) * env * amp;
|
||||
sig = LPF.ar(sig, freq * bright);
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
SynthDef(\sky_bus, { |out=0, in=0, freq=1500, q=0.3, amp=1|
|
||||
var sig = In.ar(in, 2);
|
||||
sig = BPF.ar(sig, freq.lag(0.5).clip(200, 8000), q);
|
||||
Out.ar(out, sig * amp * 1.4);
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
// pool de grains. on en cree 6, et on les recycle au gre des donnees.
|
||||
~skyGrains = 6.collect { |i| Synth(\sky_grain, [\freq, 440 + i * 73, \amp, 0]) };
|
||||
~skyHead = 0;
|
||||
|
||||
~planeSub2 = ~feedSub.(\aviation_last, { |val|
|
||||
var lon = val[1], lat = val[2], alt = val[3], vel = val[4], head = val[5];
|
||||
var grain = ~skyGrains.wrapAt(~skyHead);
|
||||
var midi = alt.linlin(0, 12000, 36, 84);
|
||||
grain.set(
|
||||
\freq, midi.midicps,
|
||||
\amp, vel.linlin(50, 300, 0.05, 0.18),
|
||||
\density, vel.linlin(50, 300, 1.5, 12),
|
||||
\pan, head.linlin(0, 360, -1, 1),
|
||||
\bright, alt.linlin(0, 12000, 1.5, 6),
|
||||
);
|
||||
~skyHead = ~skyHead + 1;
|
||||
});
|
||||
|
||||
// Filtre BPF global pilote par count (densite trafic)
|
||||
~skyFilterR = Routine({
|
||||
inf.do {
|
||||
var n = ~feedGet.(\aviation_count, 5);
|
||||
// peu d'avions -> filtre serre haut. trafic -> ouvert grave.
|
||||
var f = n.linexp(0, 50, 4500, 350);
|
||||
var q = n.linlin(0, 50, 0.6, 0.15);
|
||||
~skyGrains.do { |g| g.set(\bright, n.linlin(0, 50, 1.2, 5)) };
|
||||
2.0.wait;
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
// Flare = silence dramatique 2 s
|
||||
~flareSub2 = ~feedSub.(\swpc_flare_norm, { |val|
|
||||
if(val > 0.5) {
|
||||
~skyGrains.do { |g| g.set(\amp, 0) };
|
||||
AppClock.sched(2.0, {
|
||||
~skyGrains.do { |g| g.set(\amp, exprand(0.05, 0.18)) };
|
||||
nil
|
||||
});
|
||||
"*** sky flare blackout ***".postln;
|
||||
};
|
||||
});
|
||||
|
||||
"[sky] up. arret : ~skyStop.value".postln;
|
||||
~skyStop = {
|
||||
~skyFilterR.stop;
|
||||
~feedUnsub.(\aviation_last, ~planeSub2);
|
||||
~feedUnsub.(\swpc_flare_norm, ~flareSub2);
|
||||
~skyGrains.do { |g| g.set(\amp, 0); AppClock.sched(0.5, { g.free; nil }) };
|
||||
"[sky] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [9] ARRET TOTAL
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
~auroraStop !? { ~auroraStop.value };
|
||||
~pulseStop !? { ~pulseStop.value };
|
||||
~carboneStop !? { ~carboneStop.value };
|
||||
~skyStop !? { ~skyStop.value };
|
||||
)
|
||||
@@ -27,6 +27,34 @@ window.amp = { kick: 0, hat: 0, snare: 0, clap: 0, perc: 0,
|
||||
window.rms = { master: 0 };
|
||||
window.hydraParams = { intensity: 1, hueShift: 0, speed: 1, density: 1, feedback: 0.5 };
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Flux temps reel externes -- alimente par /data/<source>/<sub> via WS
|
||||
// (server.js DATA_PORT_IN <- bridge.py)
|
||||
//
|
||||
// Cles agregees pour faciliter l'usage Hydra :
|
||||
// feeds.netz = { freq, dev, time_dev }
|
||||
// feeds.swpc = { wind_speed, wind_dens, bz, bt, kp, a, flare_norm }
|
||||
// feeds.usgs = { last_mag, last_age, rate_h }
|
||||
// feeds.light = { rate_min, last_lat, last_lon, last_age }
|
||||
// feeds.sky = { count, last_alt, last_vel, last_pan }
|
||||
// feeds.bsky = { rate_s }
|
||||
// feeds.rte = { renew_pct, total }
|
||||
// feeds.tick = compteur incremente a chaque /data/<...>
|
||||
// feeds.alive = bool (heartbeat < 15s)
|
||||
// ---------------------------------------------------------------------
|
||||
window.feeds = {
|
||||
netz: { freq: 50, dev: 0, time_dev: 0 },
|
||||
swpc: { wind_speed: 400, wind_dens: 5, bz: 0, bt: 5, kp: 2, a: 5, flare_norm: 0 },
|
||||
usgs: { last_mag: 0, last_age: 9999, rate_h: 0 },
|
||||
light: { rate_min: 0, last_lat: 0, last_lon: 0, last_age: 9999 },
|
||||
sky: { count: 0, last_alt: 0, last_vel: 0, last_pan: 0 },
|
||||
bsky: { rate_s: 0 },
|
||||
rte: { renew_pct: 0.25, total: 50000 },
|
||||
tick: 0,
|
||||
alive: false,
|
||||
_lastHb: 0,
|
||||
};
|
||||
|
||||
const wsState = document.getElementById("ws-state");
|
||||
const bpmDisplay = document.getElementById("bpm");
|
||||
const beatDisplay = document.getElementById("beat");
|
||||
@@ -85,6 +113,8 @@ function connect() {
|
||||
codeArea.value = code;
|
||||
run();
|
||||
}
|
||||
} else if (addr.startsWith("/data/")) {
|
||||
ingestDataFeed(addr, msg.args);
|
||||
} else if (addr === "/hydra/param") {
|
||||
const [name, val] = msg.args;
|
||||
if (name in window.hydraParams) {
|
||||
@@ -95,6 +125,105 @@ function connect() {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- ingestion data_feeds -------------------------------------
|
||||
function ingestDataFeed(addr, args) {
|
||||
window.feeds.tick++;
|
||||
const a = args || [];
|
||||
switch (addr) {
|
||||
case "/data/heartbeat":
|
||||
window.feeds._lastHb = Date.now();
|
||||
window.feeds.alive = true;
|
||||
return;
|
||||
case "/data/netzfrequenz/freq": window.feeds.netz.freq = a[0]; break;
|
||||
case "/data/netzfrequenz/dev": window.feeds.netz.dev = a[0]; break;
|
||||
case "/data/netzfrequenz/time_dev": window.feeds.netz.time_dev = a[0]; break;
|
||||
case "/data/swpc/wind":
|
||||
window.feeds.swpc.wind_speed = a[0];
|
||||
window.feeds.swpc.wind_dens = a[1];
|
||||
break;
|
||||
case "/data/swpc/bz":
|
||||
window.feeds.swpc.bz = a[0];
|
||||
window.feeds.swpc.bt = a[1];
|
||||
break;
|
||||
case "/data/swpc/kp":
|
||||
window.feeds.swpc.kp = a[0];
|
||||
window.feeds.swpc.a = a[1];
|
||||
break;
|
||||
case "/data/swpc/xray":
|
||||
window.feeds.swpc.flare_norm = a[2];
|
||||
break;
|
||||
case "/data/usgs/event":
|
||||
window.feeds.usgs.last_mag = a[0];
|
||||
window.feeds.usgs.last_age = a[4];
|
||||
// marqueur visuel : pulse pendant 2 s via window.feeds._quakeHit
|
||||
window.feeds._quakeHit = Date.now();
|
||||
break;
|
||||
case "/data/usgs/rate":
|
||||
window.feeds.usgs.rate_h = a[0];
|
||||
break;
|
||||
case "/data/blitzortung/strike":
|
||||
window.feeds.light.last_lat = a[0];
|
||||
window.feeds.light.last_lon = a[1];
|
||||
window.feeds.light.last_age = a[2];
|
||||
window.feeds._strikeHit = Date.now();
|
||||
break;
|
||||
case "/data/blitzortung/rate":
|
||||
window.feeds.light.rate_min = a[0];
|
||||
break;
|
||||
case "/data/opensky/count":
|
||||
window.feeds.sky.count = a[0];
|
||||
break;
|
||||
case "/data/opensky/plane":
|
||||
window.feeds.sky.last_alt = a[3];
|
||||
window.feeds.sky.last_vel = a[4];
|
||||
window.feeds.sky.last_pan = (a[1] - 4.9) / 0.6; // bbox-relative
|
||||
break;
|
||||
case "/data/bluesky/rate":
|
||||
window.feeds.bsky.rate_s = a[0];
|
||||
break;
|
||||
case "/data/rte_eco2mix/mix": {
|
||||
const total = (a[0]||0)+(a[1]||0)+(a[2]||0)+(a[3]||0)+
|
||||
(a[4]||0)+(a[5]||0)+(a[6]||0)+(a[7]||0);
|
||||
const renew = (a[4]||0)+(a[5]||0)+(a[6]||0)+(a[7]||0);
|
||||
window.feeds.rte.total = total;
|
||||
window.feeds.rte.renew_pct = total > 0 ? renew / total : 0.25;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helpers exposes pour les patches Hydra (acces concis)
|
||||
window.f = {
|
||||
quakePulse: () => {
|
||||
const t = window.feeds._quakeHit;
|
||||
if (!t) return 0;
|
||||
const age = (Date.now() - t) / 1000;
|
||||
return Math.max(0, 1 - age / 2);
|
||||
},
|
||||
strikePulse: () => {
|
||||
const t = window.feeds._strikeHit;
|
||||
if (!t) return 0;
|
||||
const age = (Date.now() - t) / 1000;
|
||||
return Math.max(0, 1 - age / 1.2);
|
||||
},
|
||||
flarePulse: () => Math.min(1, (window.feeds.swpc.flare_norm||0) * 2),
|
||||
netzDev: () => window.feeds.netz.dev || 0,
|
||||
bz: () => window.feeds.swpc.bz || 0,
|
||||
kp01: () => Math.min(1, (window.feeds.swpc.kp||0) / 9),
|
||||
wind01: () => Math.min(1, ((window.feeds.swpc.wind_speed||400) - 250) / 650),
|
||||
renew: () => window.feeds.rte.renew_pct || 0.25,
|
||||
skyCount01: () => Math.min(1, (window.feeds.sky.count||0) / 50),
|
||||
bskyDensity: () => Math.min(1, (window.feeds.bsky.rate_s||0) / 30),
|
||||
lightRate01: () => Math.min(1, (window.feeds.light.rate_min||0) / 60),
|
||||
};
|
||||
|
||||
// heartbeat watchdog
|
||||
setInterval(() => {
|
||||
if (window.feeds._lastHb && Date.now() - window.feeds._lastHb > 15000) {
|
||||
window.feeds.alive = false;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// ---------- Hydra setup ----------------------------------------------
|
||||
const canvas = document.getElementById("hydra-canvas");
|
||||
canvas.width = window.innerWidth;
|
||||
@@ -304,6 +433,96 @@ voronoi(20, 0.1, 0.5)
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
// ============ DATA FEEDS PRESETS ============
|
||||
// Tous utilisent window.feeds (alimente par /data/* via WS).
|
||||
// Lance d'abord : cd data_feeds && uv run python bridge.py
|
||||
|
||||
feeds_aurora: `
|
||||
// Aurora : Bz IMF + vent solaire + Kp -> drape de lumiere
|
||||
osc(() => 6 + window.f.wind01() * 18, 0.05, () => 1 + window.f.kp01())
|
||||
.modulate(noise(() => 2 + window.f.kp01() * 6, 0.1).scrollY(() => window.beat * 0.002))
|
||||
.rotate(() => window.f.bz() * 0.03)
|
||||
.color(() => 0.2 + window.f.kp01() * 0.4,
|
||||
() => 0.6 + window.f.wind01() * 0.4,
|
||||
() => 0.4 + window.f.flarePulse())
|
||||
.scale(() => 1 + window.f.flarePulse() * 0.6)
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
feeds_quake: `
|
||||
// Quake : USGS magnitude -> onde de choc radiale
|
||||
shape(() => 4 + Math.floor(window.feeds.usgs.last_mag), 0.05, 0.02)
|
||||
.repeat(8, 8)
|
||||
.scale(() => 0.5 + window.f.quakePulse() * 2.5)
|
||||
.modulateRotate(noise(2, 0.2), () => window.f.quakePulse() * 3)
|
||||
.color(() => 0.8 + window.f.quakePulse(),
|
||||
() => 0.2 - window.f.quakePulse() * 0.2,
|
||||
0.1)
|
||||
.add(osc(20, 0.05, 1).pixelate(8, 8), () => window.f.quakePulse() * 0.4)
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
feeds_lightning: `
|
||||
// Lightning : foudre Blitzortung -> flash blanc + voronoi rapide
|
||||
voronoi(() => 8 + window.f.lightRate01() * 30, 0.3, 0.1)
|
||||
.modulate(noise(6, 0.3))
|
||||
.invert(() => window.f.strikePulse() > 0.6 ? 1 : 0)
|
||||
.add(solid(1, 1, 1, () => window.f.strikePulse() * 0.6))
|
||||
.color(0.7, 0.7, () => 0.9 + window.f.strikePulse())
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
feeds_flightmap: `
|
||||
// Flightmap : OpenSky count + altitude + cap
|
||||
osc(() => 10 + window.feeds.sky.count, 0.05, 1)
|
||||
.kaleid(() => 4 + Math.floor(window.f.skyCount01() * 8))
|
||||
.modulate(osc(2).scrollX(() => window.feeds.sky.last_pan * 0.1)
|
||||
.scrollY(() => window.feeds.sky.last_alt / 50000))
|
||||
.rotate(() => window.feeds.sky.last_pan * 0.5)
|
||||
.color(() => 0.3 + window.f.skyCount01() * 0.5,
|
||||
() => 0.5 + window.feeds.sky.last_vel / 500,
|
||||
() => 0.6 + window.feeds.sky.last_alt / 20000)
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
feeds_gridpulse: `
|
||||
// Gridpulse : derivation Netzfrequenz -> tremblement micro
|
||||
osc(() => 30 + window.feeds.netz.freq * 0.5, 0.05, 1.5)
|
||||
.modulateScale(osc(8).rotate(() => window.beat * 0.01),
|
||||
() => 0.05 + Math.abs(window.f.netzDev()) * 4)
|
||||
.scale(() => 1 + window.f.netzDev() * 8)
|
||||
.color(() => 0.5 - window.f.netzDev() * 5,
|
||||
() => 0.5 + window.f.netzDev() * 5,
|
||||
() => 0.5 + window.f.renew() * 0.5)
|
||||
.contrast(() => 1.2 + Math.abs(window.f.netzDev()) * 8)
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
feeds_solarwind: `
|
||||
// Solar wind : vent + densite + flare X-ray
|
||||
noise(() => 2 + window.f.wind01() * 8, () => 0.05 + window.f.flarePulse() * 0.3)
|
||||
.modulate(osc(() => 5 + window.f.wind01() * 10, 0.05).rotate(() => window.beat * 0.02))
|
||||
.colorama(() => 0.05 + window.f.kp01() * 0.4)
|
||||
.add(solid(1, 0.6, 0.2, () => window.f.flarePulse() * 0.5))
|
||||
.scale(() => 0.8 + window.f.wind01() * 0.5)
|
||||
.saturate(() => 1.3 + window.f.flarePulse() * 2)
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
feeds_bskyrain: `
|
||||
// Bskyrain : pluie de pixels proportionnelle au firehose Bluesky
|
||||
shape(4, 0.005, 0.001)
|
||||
.repeat(() => 40 + window.f.bskyDensity() * 80,
|
||||
() => 40 + window.f.bskyDensity() * 80)
|
||||
.scrollY(() => time * (0.05 + window.f.bskyDensity() * 0.3))
|
||||
.scrollX(() => Math.sin(time * 0.3) * 0.02)
|
||||
.color(() => 0.4 + window.f.bskyDensity() * 0.4,
|
||||
() => 0.7 + window.f.kp01() * 0.3,
|
||||
1)
|
||||
.modulate(noise(2, 0.2), 0.05)
|
||||
.out()
|
||||
`.trim(),
|
||||
|
||||
cosmos: `
|
||||
// Cosmos : multi-osc layered + rotate
|
||||
osc(5, 0.05, 1)
|
||||
|
||||
@@ -131,6 +131,13 @@
|
||||
<button data-preset="grid">grid</button>
|
||||
<button data-preset="warp">warp</button>
|
||||
<button data-preset="pixel">pixel</button>
|
||||
<button data-preset="feeds_aurora">aurora*</button>
|
||||
<button data-preset="feeds_quake">quake*</button>
|
||||
<button data-preset="feeds_lightning">lightning*</button>
|
||||
<button data-preset="feeds_flightmap">flights*</button>
|
||||
<button data-preset="feeds_gridpulse">grid*</button>
|
||||
<button data-preset="feeds_solarwind">solar*</button>
|
||||
<button data-preset="feeds_bskyrain">bsky*</button>
|
||||
<button id="run">run [Ctrl+Enter]</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
|
||||
@@ -33,6 +33,7 @@ const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3000", 10);
|
||||
const SC_HOST = process.env.SC_HOST ?? "127.0.0.1";
|
||||
const SC_PORT_OUT = parseInt(process.env.SC_PORT_OUT ?? "57121", 10); // -> SC
|
||||
const SC_PORT_IN = parseInt(process.env.SC_PORT_IN ?? "57122", 10); // <- SC
|
||||
const DATA_PORT_IN = parseInt(process.env.DATA_PORT_IN ?? "57124", 10); // <- data_feeds bridge.py
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HTTP : Express sert public/
|
||||
@@ -130,6 +131,32 @@ oscPort.on("error", (err) => {
|
||||
|
||||
oscPort.open();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OSC : server.js <- data_feeds/bridge.py (UDP, lecture seule)
|
||||
// Le pont Python emet /data/<source>/<sub> et /data/heartbeat sur ce
|
||||
// port. On les reflechit en WS sous forme JSON pour les clients web.
|
||||
// ---------------------------------------------------------------------
|
||||
const dataPort = new osc.UDPPort({
|
||||
localAddress: "0.0.0.0",
|
||||
localPort: DATA_PORT_IN,
|
||||
metadata: false,
|
||||
});
|
||||
|
||||
dataPort.on("ready", () => {
|
||||
console.log(`[data] ready -- ecoute :${DATA_PORT_IN} <- bridge.py`);
|
||||
});
|
||||
|
||||
dataPort.on("message", (oscMsg) => {
|
||||
// broadcast direct, meme format que /sync/* : { address, args }
|
||||
broadcast({ address: oscMsg.address, args: oscMsg.args });
|
||||
});
|
||||
|
||||
dataPort.on("error", (err) => {
|
||||
console.error("[data] erreur:", err.message);
|
||||
});
|
||||
|
||||
dataPort.open();
|
||||
|
||||
function sendToSC(address, args) {
|
||||
const payload = args.map((v) => {
|
||||
if (typeof v === "number") {
|
||||
@@ -165,6 +192,7 @@ function shutdown() {
|
||||
console.log("\n[server] shutdown...");
|
||||
wss.close();
|
||||
oscPort.close();
|
||||
dataPort.close();
|
||||
httpServer.close(() => process.exit(0));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY server.js ./
|
||||
COPY public ./public
|
||||
|
||||
EXPOSE 4400
|
||||
ENV HTTP_PORT=4400
|
||||
ENV DATA_PORT_IN=57124
|
||||
|
||||
# UDP 57124 doit etre exposable (data_feeds bridge.py emet ici)
|
||||
# docker run -p 4400:4400 -p 57124:57124/udp av-live-realart
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,142 @@
|
||||
# web_realart -- AV-Live data_feeds (deploiement public)
|
||||
|
||||
Standalone deployable sur `real.art.saillant.cc` (cloudflared).
|
||||
|
||||
Ne depend ni de SuperCollider ni d'openFrameworks : **portage web pur**
|
||||
des visualizers oF (WebGL via three.js + WebGPU TSL) et des SynthDefs SC
|
||||
(Web Audio API), tous deux pilotes par les memes flux que les presets
|
||||
locaux `sound_algo/examples/16_data_feeds.scd` et
|
||||
`sound_algo/examples/17_data_feeds_more.scd`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
data_feeds/bridge.py --OSC UDP :57124--> web_realart/server.js
|
||||
|
|
||||
+-- HTTP :4400 (express)
|
||||
+-- WS :/ws (snapshot replay)
|
||||
|
|
||||
+---------------------------+---------------------------+
|
||||
v v v
|
||||
public/webgl/ (3D) public/audio/ (synth) public/hydra/ (DSL)
|
||||
three.js + WebGPU TSL Web Audio API portage SC hydra-synth
|
||||
globe + particules data 5 presets SC portes 7 patches feeds
|
||||
\ | /
|
||||
+----------- /feeds_client.js (window.feeds) --------+
|
||||
```
|
||||
|
||||
## Lancer en local
|
||||
|
||||
```bash
|
||||
# 1. data_feeds bridge -- ajoute deja la cible 127.0.0.1:57124 (cf data_feeds/config.toml)
|
||||
cd ../data_feeds && uv run python bridge.py &
|
||||
|
||||
# 2. server web
|
||||
cd web_realart
|
||||
npm install
|
||||
npm start
|
||||
|
||||
# Ouvrir
|
||||
open http://localhost:4400/
|
||||
```
|
||||
|
||||
Routes :
|
||||
- `/` -- landing
|
||||
- `/webgl/` -- globe three.js + WebGPU TSL (fallback WebGL2)
|
||||
- `/audio/` -- 5 presets Web Audio (cavity, mix, geo, aurora, pulse)
|
||||
- `/hydra/` -- 7 patches Hydra (aurora, quake, lightning, flightmap, gridpulse, solarwind, bskyrain)
|
||||
- `/api/health` -- statut JSON
|
||||
|
||||
## Deploiement `real.art.saillant.cc`
|
||||
|
||||
### Option A -- Docker + cloudflared (recommandee)
|
||||
|
||||
```bash
|
||||
docker build -t av-live-realart .
|
||||
docker run -d --name realart \
|
||||
--restart unless-stopped \
|
||||
-p 4400:4400 \
|
||||
-p 57124:57124/udp \
|
||||
av-live-realart
|
||||
```
|
||||
|
||||
Sur la machine cible (electron-server ou VM), ajouter une ingress
|
||||
au tunnel cloudflared existant (cf
|
||||
`~/.claude/projects/.../reference_cloudflared_tunnel_saillant.md`) :
|
||||
|
||||
```yaml
|
||||
# /etc/cloudflared/config.yml
|
||||
ingress:
|
||||
- hostname: real.art.saillant.cc
|
||||
service: http://localhost:4400
|
||||
# ... existing ingresses ...
|
||||
```
|
||||
|
||||
Puis route DNS via API Cloudflare :
|
||||
|
||||
```bash
|
||||
cloudflared tunnel route dns 2c6b04a3-... real.art.saillant.cc
|
||||
systemctl restart cloudflared
|
||||
```
|
||||
|
||||
### Option B -- Sans Docker (node direct)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
HTTP_PORT=4400 DATA_PORT_IN=57124 node server.js
|
||||
```
|
||||
|
||||
Mettre derriere systemd :
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/av-live-realart.service
|
||||
[Unit]
|
||||
Description=AV-Live realart web bridge
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/av-live-realart
|
||||
ExecStart=/usr/bin/node server.js
|
||||
Environment=HTTP_PORT=4400 DATA_PORT_IN=57124
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Bridge data_feeds distant
|
||||
|
||||
Si `bridge.py` tourne sur une autre machine que celle qui sert
|
||||
`real.art`, ajouter dans `data_feeds/config.toml` :
|
||||
|
||||
```toml
|
||||
[osc]
|
||||
targets = [
|
||||
{ host = "100.x.y.z", port = 57124 }, # IP Tailscale du serveur web
|
||||
# ... les autres targets locaux ...
|
||||
]
|
||||
```
|
||||
|
||||
ATTENTION : OSC est UDP, sans auth ni chiffrement. **Toujours passer
|
||||
par Tailscale ou WireGuard** entre le bridge et le serveur web public.
|
||||
Ne jamais exposer le port 57124 sur Internet.
|
||||
|
||||
## Ce qui est porte
|
||||
|
||||
| oF / SC source | Web | Note |
|
||||
|---|---|---|
|
||||
| `oscope-of/.../ParticleVis` (positions) | `webgl/app.js` Points TSL | Geometrie + materiel TSL |
|
||||
| `oscope-of/.../ShaderVis` (aurore-like) | `webgl/app.js` BG node | TSL `mx_fractal_noise_float` |
|
||||
| `oscope-of/.../MeshVis` (sphere wireframe) | `webgl/app.js` LineSegments | three.js standard |
|
||||
| `cavity_drone` SC | `audio/app.js` startCavity | additif sin sur 7.83x8 Hz |
|
||||
| `aurora_pad` SC | `audio/app.js` startAurora | 7 partials + LPF Kp |
|
||||
| `pulse_kick/sub/click` SC | `audio/app.js` startPulse | tempo netz + bsky/usgs |
|
||||
| `mix_pad/plane_blip` SC | `audio/app.js` startMix | RLPF par renew_pct |
|
||||
| `geo_bus/quake_sub` SC | `audio/app.js` startGeo | BPF par Kp + flare boost |
|
||||
|
||||
Limitations connues :
|
||||
- Web Audio n'a pas d'equivalent direct de `Splay.ar`, on simplifie en stereo.
|
||||
- Les SC `tanh` sont rendus via `WaveShaperNode` (curve precalculee).
|
||||
- `Routine` SC -> `setInterval` JS (drift faible mais acceptable a 1-5 s).
|
||||
- Pas de FFT analyse cote synth (Hydra : `detectAudio: false` deliberement).
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Pas de pose (YOLO) en prod headless : on n'installe pas opencv/ultralytics.
|
||||
RUN pip install --no-cache-dir \
|
||||
"python-osc>=1.8.3" \
|
||||
"httpx>=0.27" \
|
||||
"websockets>=12.0" \
|
||||
"aiomqtt>=2.3" \
|
||||
"skyfield>=1.49"
|
||||
|
||||
# Copie le code source du bridge depuis le repo (build context = ./bridge,
|
||||
# mais on a besoin du code de ../../data_feeds : on copie via build.sh
|
||||
# OU on monte en volume. Plus simple : sync via build.sh avant docker compose).
|
||||
# Code source du bridge (synchronise depuis ../../data_feeds via build.sh)
|
||||
COPY src/bridge.py /app/bridge.py
|
||||
COPY src/feeds /app/feeds
|
||||
|
||||
# config.toml fourni en volume read-only par compose
|
||||
CMD ["python", "bridge.py", "--config", "/app/config.toml"]
|
||||
@@ -0,0 +1,71 @@
|
||||
# Config bridge data_feeds pour le deploiement real.art.saillant.cc.
|
||||
#
|
||||
# Cible OSC unique : container web sur le network "data" (docker compose).
|
||||
# Le hostname "web" est resolu par le DNS Docker interne.
|
||||
|
||||
[osc]
|
||||
targets = [
|
||||
{ host = "web", port = 57124 },
|
||||
]
|
||||
prefix = "/data"
|
||||
|
||||
# -- Pose : DESACTIVE en prod (besoin d'une webcam, pas pertinent serveur)
|
||||
[feeds.pose]
|
||||
enabled = false
|
||||
|
||||
# -- Sismique / geophysique
|
||||
[feeds.usgs]
|
||||
enabled = true
|
||||
url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
|
||||
poll_seconds = 60
|
||||
|
||||
[feeds.swpc]
|
||||
enabled = true
|
||||
url_plasma = "https://services.swpc.noaa.gov/products/solar-wind/plasma-1-day.json"
|
||||
url_mag = "https://services.swpc.noaa.gov/products/solar-wind/mag-1-day.json"
|
||||
url_kp = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
|
||||
url_xray = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
|
||||
poll_seconds = 60
|
||||
|
||||
# -- Reseau electrique
|
||||
[feeds.netzfrequenz]
|
||||
enabled = true
|
||||
ws_url = "wss://www.mainsfrequenz.de/frequenz.socket"
|
||||
|
||||
[feeds.rte_eco2mix]
|
||||
enabled = false # token RTE requis -- a remplir si besoin
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
poll_seconds = 900
|
||||
|
||||
# -- Foudre
|
||||
[feeds.blitzortung]
|
||||
enabled = true
|
||||
ws_url = "wss://ws1.blitzortung.org:443/"
|
||||
|
||||
# -- Aviation
|
||||
[feeds.opensky]
|
||||
enabled = true
|
||||
url = "https://opensky-network.org/api/states/all"
|
||||
poll_seconds = 30
|
||||
# bbox Europe occidentale plus large pour avoir du trafic visible
|
||||
bbox = [42.0, -5.0, 51.0, 10.0]
|
||||
|
||||
# -- Pouls numerique
|
||||
[feeds.bluesky]
|
||||
enabled = true
|
||||
ws_url = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post"
|
||||
sample_rate = 0.02
|
||||
|
||||
[feeds.mempool]
|
||||
enabled = false
|
||||
ws_url = "wss://mempool.space/api/v1/ws"
|
||||
|
||||
[feeds.github]
|
||||
enabled = false
|
||||
url = "https://api.github.com/events"
|
||||
|
||||
[feeds.gcn]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Orchestrateur du pont data_feeds → OSC.
|
||||
|
||||
Charge `config.toml`, lance un worker async par flux activé, et diffuse
|
||||
en broadcast vers tous les `osc.targets`. Chaque worker doit exposer
|
||||
une coroutine `run(ctx)` qui prend un `Context` et émet via `ctx.send(...)`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import tomllib # py311+
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
LOG = logging.getLogger("bridge")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
cfg: dict[str, Any]
|
||||
prefix: str
|
||||
clients: list[SimpleUDPClient]
|
||||
feed_name: str
|
||||
|
||||
def send(self, sub: str, *args: Any) -> None:
|
||||
path = f"{self.prefix}/{self.feed_name}/{sub}"
|
||||
for c in self.clients:
|
||||
try:
|
||||
c.send_message(path, list(args))
|
||||
except OSError as e:
|
||||
LOG.warning("OSC send failed %s: %s", path, e)
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
with path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
async def run_feed(name: str, cfg: dict[str, Any], ctx: Context) -> None:
|
||||
"""Charge `data_feeds.feeds.<name>` et appelle `run(ctx)`."""
|
||||
try:
|
||||
mod = importlib.import_module(f"data_feeds.feeds.{name}")
|
||||
except ModuleNotFoundError:
|
||||
# Fallback : exécution depuis le dossier data_feeds/
|
||||
mod = importlib.import_module(f"feeds.{name}")
|
||||
LOG.info("starting feed: %s", name)
|
||||
backoff = 1.0
|
||||
while True:
|
||||
try:
|
||||
await mod.run(ctx)
|
||||
# Si run() retourne sans exception, on redémarre poliment
|
||||
await asyncio.sleep(2.0)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.error("feed %s crashed: %s — retry in %.1fs", name, e, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60.0)
|
||||
|
||||
|
||||
async def heartbeat(clients: list[SimpleUDPClient], prefix: str) -> None:
|
||||
t0 = time.monotonic()
|
||||
while True:
|
||||
for c in clients:
|
||||
c.send_message(f"{prefix}/heartbeat", [time.monotonic() - t0])
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
|
||||
async def main_async(cfg: dict[str, Any]) -> None:
|
||||
osc_cfg = cfg.get("osc", {})
|
||||
prefix = osc_cfg.get("prefix", "/data")
|
||||
clients = [
|
||||
SimpleUDPClient(t["host"], t["port"])
|
||||
for t in osc_cfg.get("targets", [{"host": "127.0.0.1", "port": 57121}])
|
||||
]
|
||||
LOG.info(
|
||||
"OSC targets: %s",
|
||||
", ".join(f"{c._address}:{c._port}" for c in clients), # noqa: SLF001
|
||||
)
|
||||
|
||||
tasks: list[asyncio.Task[None]] = []
|
||||
for name, fcfg in cfg.get("feeds", {}).items():
|
||||
if not fcfg.get("enabled", False):
|
||||
LOG.info("feed disabled: %s", name)
|
||||
continue
|
||||
ctx = Context(cfg=fcfg, prefix=prefix, clients=clients, feed_name=name)
|
||||
tasks.append(asyncio.create_task(run_feed(name, fcfg, ctx), name=name))
|
||||
|
||||
if not tasks:
|
||||
LOG.warning("no feed enabled — exiting")
|
||||
return
|
||||
|
||||
tasks.append(asyncio.create_task(heartbeat(clients, prefix), name="heartbeat"))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = loop.create_future()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, lambda: stop.cancel() if not stop.done() else None)
|
||||
try:
|
||||
await stop
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
LOG.info("bridge stopped")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("-c", "--config", type=Path, default=Path(__file__).parent / "config.toml")
|
||||
p.add_argument("-v", "--verbose", action="store_true")
|
||||
args = p.parse_args()
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s — %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
cfg = load_config(args.config)
|
||||
try:
|
||||
asyncio.run(main_async(cfg))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Helpers communs aux feeds."""
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class RateMeter:
|
||||
"""Compte les événements sur une fenêtre glissante (en secondes)."""
|
||||
|
||||
def __init__(self, window: float = 60.0) -> None:
|
||||
self.window = window
|
||||
self._events: collections.deque[float] = collections.deque()
|
||||
|
||||
def tick(self) -> int:
|
||||
now = time.monotonic()
|
||||
self._events.append(now)
|
||||
while self._events and now - self._events[0] > self.window:
|
||||
self._events.popleft()
|
||||
return len(self._events)
|
||||
|
||||
@property
|
||||
def rate(self) -> float:
|
||||
return len(self._events) / max(self.window, 1e-6)
|
||||
|
||||
|
||||
def djb2(s: str) -> int:
|
||||
"""Hash stable 0..65535 pour transformer une string en float OSC."""
|
||||
h = 5381
|
||||
for c in s.encode("utf-8", errors="ignore"):
|
||||
h = ((h << 5) + h + c) & 0xFFFF
|
||||
return h
|
||||
|
||||
|
||||
def fnorm(x: float, lo: float, hi: float) -> float:
|
||||
if hi <= lo:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, (x - lo) / (hi - lo)))
|
||||
|
||||
|
||||
def safe_get(d: dict, path: Iterable[str], default=None):
|
||||
cur = d
|
||||
for k in path:
|
||||
if isinstance(cur, dict) and k in cur:
|
||||
cur = cur[k]
|
||||
else:
|
||||
return default
|
||||
return cur
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Blitzortung / LightningMaps — impacts de foudre temps réel.
|
||||
|
||||
Protocole : à la connexion, envoyer `{"a":111}` (handshake LightningMaps).
|
||||
Chaque message JSON contient { time, lat, lon, mds (multiplicity)... }.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
from ._util import RateMeter
|
||||
|
||||
LOG = logging.getLogger("feed.blitzortung")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
rate = RateMeter(window=60.0)
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
|
||||
await ws.send(json.dumps({"a": 111}))
|
||||
LOG.info("connected %s", url)
|
||||
async for raw in ws:
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
lat = float(d.get("lat", 0.0))
|
||||
lon = float(d.get("lon", 0.0))
|
||||
# time est en ns Unix ; on calcule un age en secondes
|
||||
t_ns = d.get("time", 0)
|
||||
age = 0.0
|
||||
if isinstance(t_ns, (int, float)) and t_ns > 0:
|
||||
age = max(0.0, time.time() - t_ns / 1e9)
|
||||
mult = int(d.get("mds") or 1)
|
||||
ctx.send("strike", lat, lon, age, mult)
|
||||
rate.tick()
|
||||
if rate._events: # noqa: SLF001
|
||||
ctx.send("rate", rate.rate * 60.0)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(5.0)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Bluesky Jetstream — firehose des posts publics (WebSocket JSON)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
from ._util import RateMeter, djb2
|
||||
|
||||
LOG = logging.getLogger("feed.bluesky")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
sample = float(cfg.get("sample_rate", 0.02))
|
||||
rate = RateMeter(window=10.0)
|
||||
last_rate_emit = 0.0
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
|
||||
LOG.info("connected jetstream (sample %.0f%%)", sample * 100)
|
||||
async for raw in ws:
|
||||
rate.tick()
|
||||
now = time.monotonic()
|
||||
if now - last_rate_emit > 1.0:
|
||||
ctx.send("rate", rate.rate)
|
||||
last_rate_emit = now
|
||||
if random.random() > sample:
|
||||
continue
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
commit = d.get("commit") or {}
|
||||
rec = commit.get("record") or {}
|
||||
text = rec.get("text") or ""
|
||||
lang = (rec.get("langs") or ["?"])[0]
|
||||
ctx.send("post", float(len(text)), float(djb2(lang)))
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(3.0)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""GCN Classic over Kafka — alertes astrophysiques (GRB, GW, neutrinos).
|
||||
|
||||
Nécessite des credentials Kafka. Voir https://gcn.nasa.gov/quickstart.
|
||||
Cette implémentation est volontairement minimale : on extrait ra/dec/error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
LOG = logging.getLogger("feed.gcn")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
|
||||
if not (cid and csec):
|
||||
LOG.warning("GCN credentials manquants — feed inactif (voir gcn.nasa.gov/quickstart)")
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
try:
|
||||
from gcn_kafka import Consumer # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
LOG.error("`gcn-kafka` non installé. uv add gcn-kafka")
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
|
||||
from ._util import djb2
|
||||
|
||||
cons = Consumer(client_id=cid, client_secret=csec)
|
||||
cons.subscribe([
|
||||
"gcn.classic.text.SWIFT_BAT_GRB_POS_ACK",
|
||||
"gcn.classic.text.FERMI_GBM_FLT_POS",
|
||||
"gcn.classic.text.LVC_INITIAL",
|
||||
"gcn.classic.text.ICECUBE_ASTROTRACK_GOLD",
|
||||
])
|
||||
LOG.info("subscribed GCN classic streams")
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _poll():
|
||||
return cons.consume(num_messages=10, timeout=1.0)
|
||||
|
||||
while True:
|
||||
msgs = await loop.run_in_executor(None, _poll)
|
||||
for m in msgs or []:
|
||||
if m.error():
|
||||
continue
|
||||
txt = m.value().decode("utf-8", "ignore")
|
||||
ra, dec, err = _parse(txt)
|
||||
ctx.send("alert", float(djb2(m.topic())), ra, dec, err)
|
||||
|
||||
|
||||
def _parse(txt: str) -> tuple[float, float, float]:
|
||||
ra = dec = err = 0.0
|
||||
for line in txt.splitlines():
|
||||
l = line.lower()
|
||||
try:
|
||||
if "ra:" in l and ra == 0.0:
|
||||
ra = float(line.split(":", 1)[1].split()[0])
|
||||
elif "dec:" in l and dec == 0.0:
|
||||
dec = float(line.split(":", 1)[1].split()[0])
|
||||
elif "error" in l and "arcmin" in l and err == 0.0:
|
||||
err = float(line.split(":", 1)[1].split()[0])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return ra, dec, err
|
||||
@@ -0,0 +1,37 @@
|
||||
"""GitHub public events — firehose dev mondial (polling REST anonyme)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from ._util import djb2
|
||||
|
||||
LOG = logging.getLogger("feed.github")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["url"]
|
||||
period = float(cfg.get("poll_seconds", 30))
|
||||
last_id = ""
|
||||
async with httpx.AsyncClient(timeout=20.0,
|
||||
headers={"Accept": "application/vnd.github+json"}) as cli:
|
||||
while True:
|
||||
try:
|
||||
r = await cli.get(url)
|
||||
r.raise_for_status()
|
||||
for ev in reversed(r.json()):
|
||||
eid = ev.get("id", "")
|
||||
if eid <= last_id:
|
||||
continue
|
||||
ctx.send(
|
||||
"event",
|
||||
float(djb2(ev.get("type", "?"))),
|
||||
float(djb2(((ev.get("repo") or {}).get("name") or "?"))),
|
||||
)
|
||||
last_id = eid
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""mempool.space — Bitcoin txs and blocks (WebSocket)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import websockets
|
||||
|
||||
LOG = logging.getLogger("feed.mempool")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20, max_size=2**21) as ws:
|
||||
await ws.send(json.dumps({"action": "want", "data": ["mempool-blocks", "blocks", "live-2h-chart"]}))
|
||||
LOG.info("connected mempool.space")
|
||||
async for raw in ws:
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if "block" in d:
|
||||
b = d["block"] or {}
|
||||
ctx.send(
|
||||
"block",
|
||||
float(b.get("height", 0)),
|
||||
float(b.get("tx_count", 0)),
|
||||
float(b.get("extras", {}).get("reward", 0) or 0) / 1e8,
|
||||
)
|
||||
if "transactions" in d:
|
||||
for tx in (d["transactions"] or [])[:5]:
|
||||
ctx.send(
|
||||
"tx",
|
||||
float(tx.get("value", 0)) / 1e8,
|
||||
float(tx.get("fee", 0)) / max(1.0, float(tx.get("vsize", 1))),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(5.0)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Fréquence du réseau électrique européen — WebSocket Mainsfrequenz.de.
|
||||
|
||||
Format payload (texte) : "f=49.987 t=2026-05-11T06:42:00Z" environ.
|
||||
Le serveur peut changer ; on parse defensively et on extrait `f`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
LOG = logging.getLogger("feed.netzfrequenz")
|
||||
|
||||
_RE_F = re.compile(r"f\s*=\s*([0-9]+\.[0-9]+)")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
time_dev = 0.0 # dérive intégrée (secondes)
|
||||
last_t = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20) as ws:
|
||||
LOG.info("connected %s", url)
|
||||
async for msg in ws:
|
||||
text = msg if isinstance(msg, str) else msg.decode("utf-8", "ignore")
|
||||
m = _RE_F.search(text)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
f = float(m.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
now = time.monotonic()
|
||||
dt = now - last_t
|
||||
last_t = now
|
||||
delta = f - 50.0
|
||||
# Intégration : 1 s réelle à 49.5 Hz → -0.01 s d'horloge
|
||||
time_dev += (delta / 50.0) * dt
|
||||
ctx.send("freq", f)
|
||||
ctx.send("dev", delta)
|
||||
ctx.send("time_dev", time_dev)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(3.0)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""OpenSky Network — ADS-B aircraft states (REST polling, anon ≤ 15 s)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
LOG = logging.getLogger("feed.opensky")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
base = cfg["url"]
|
||||
period = float(cfg.get("poll_seconds", 15))
|
||||
bbox = cfg.get("bbox") # [lamin, lomin, lamax, lomax]
|
||||
params = None
|
||||
if bbox and len(bbox) == 4:
|
||||
params = {
|
||||
"lamin": bbox[0], "lomin": bbox[1],
|
||||
"lamax": bbox[2], "lomax": bbox[3],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
r = await cli.get(base, params=params)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
states = data.get("states") or []
|
||||
ctx.send("count", float(len(states)))
|
||||
for s in states:
|
||||
# index: 0=icao24, 5=lon, 6=lat, 7=baro_alt, 9=velocity, 10=heading
|
||||
try:
|
||||
icao = (s[0] or "?")[:8]
|
||||
lon = float(s[5]) if s[5] is not None else 0.0
|
||||
lat = float(s[6]) if s[6] is not None else 0.0
|
||||
alt = float(s[7]) if s[7] is not None else 0.0
|
||||
vel = float(s[9]) if s[9] is not None else 0.0
|
||||
head = float(s[10]) if s[10] is not None else 0.0
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
ctx.send("plane", icao, lon, lat, alt, vel, head)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Webcam → OpenCV → pose detection (YOLOv8-pose) → OSC.
|
||||
|
||||
Pourquoi YOLOv8-pose plutot qu'OpenPose proper ?
|
||||
- OpenPose officiel = build CUDA, douloureux sur Mac ARM.
|
||||
- YOLOv8-pose : pip install, MPS/Metal accelere, 17 keypoints COCO
|
||||
(proche d'OpenPose BODY_25, suffisant pour de l'AV-live).
|
||||
- Pour un vrai OpenPose, swap simple : remplacer `Detector` par un
|
||||
wrapper autour de pyopenpose ou cmu-openpose et conserver le format
|
||||
keypoints (x_norm, y_norm, conf) emis sur OSC.
|
||||
|
||||
Sortie OSC :
|
||||
/data/pose/count <n>
|
||||
/data/pose/person <idx> <cx> <cy> <w> <h> <conf>
|
||||
/data/pose/skel <idx> <conf_avg> <x0 y0 c0 ... x16 y16 c16>
|
||||
/data/pose/bone <idx> <kp_a> <kp_b> (a la connexion, statique)
|
||||
|
||||
Toutes les coordonnees sont normalisees 0..1 (origine top-left).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
LOG = logging.getLogger("feed.pose")
|
||||
|
||||
# Squelette COCO 17 keypoints (paires d'os).
|
||||
COCO_BONES: list[tuple[int, int]] = [
|
||||
(0, 1), (0, 2), (1, 3), (2, 4), # tete
|
||||
(5, 6), (5, 7), (7, 9), (6, 8), (8, 10),# bras
|
||||
(5, 11), (6, 12), (11, 12), # torse
|
||||
(11, 13), (13, 15), (12, 14), (14, 16), # jambes
|
||||
]
|
||||
|
||||
|
||||
class _Lazy:
|
||||
"""Imports lourds differes pour ne pas casser le pont entier si pose
|
||||
n'est pas demande."""
|
||||
def __init__(self) -> None:
|
||||
self.cv2 = None
|
||||
self.YOLO = None
|
||||
self.np = None
|
||||
|
||||
def load(self) -> None:
|
||||
if self.cv2 is not None:
|
||||
return
|
||||
import cv2 # type: ignore
|
||||
import numpy as np # type: ignore
|
||||
from ultralytics import YOLO # type: ignore
|
||||
self.cv2 = cv2
|
||||
self.np = np
|
||||
self.YOLO = YOLO
|
||||
|
||||
|
||||
_LAZY = _Lazy()
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
try:
|
||||
_LAZY.load()
|
||||
except ModuleNotFoundError as e:
|
||||
LOG.error("dependances manquantes : %s — uv sync --extra pose", e)
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
|
||||
cv2, np, YOLO = _LAZY.cv2, _LAZY.np, _LAZY.YOLO
|
||||
|
||||
cam_idx = int(cfg.get("camera", 0))
|
||||
width = int(cfg.get("width", 640))
|
||||
height = int(cfg.get("height", 480))
|
||||
target_fps = float(cfg.get("target_fps", 20))
|
||||
conf_thresh = float(cfg.get("conf_thresh", 0.35))
|
||||
max_persons = int(cfg.get("max_persons", 4))
|
||||
emit_kp = bool(cfg.get("emit_keypoints", True))
|
||||
model_name = cfg.get("model", "yolov8n-pose.pt")
|
||||
device = cfg.get("device", "mps")
|
||||
|
||||
LOG.info("loading %s on %s", model_name, device)
|
||||
model = YOLO(model_name)
|
||||
|
||||
cap = cv2.VideoCapture(cam_idx)
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
||||
if not cap.isOpened():
|
||||
LOG.error("camera index %d indisponible", cam_idx)
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
|
||||
# Annonce du squelette (statique, une fois)
|
||||
for a, b in COCO_BONES:
|
||||
ctx.send("bone", float(a), float(b))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
period = 1.0 / max(1.0, target_fps)
|
||||
LOG.info("pose stream up: %dx%d @ %.1f fps target", width, height, target_fps)
|
||||
|
||||
def _grab():
|
||||
ok, frame = cap.read()
|
||||
return frame if ok else None
|
||||
|
||||
try:
|
||||
while True:
|
||||
t0 = time.monotonic()
|
||||
frame = await loop.run_in_executor(None, _grab)
|
||||
if frame is None:
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
h, w = frame.shape[:2]
|
||||
try:
|
||||
results = model.predict(
|
||||
frame, device=device, conf=conf_thresh,
|
||||
verbose=False, max_det=max_persons,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("inference failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
if not results:
|
||||
ctx.send("count", 0.0)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
|
||||
res = results[0]
|
||||
kp_xy = getattr(res.keypoints, "xy", None)
|
||||
kp_conf = getattr(res.keypoints, "conf", None)
|
||||
boxes = getattr(res, "boxes", None)
|
||||
n = 0 if kp_xy is None else int(len(kp_xy))
|
||||
ctx.send("count", float(n))
|
||||
|
||||
for i in range(n):
|
||||
# bbox normalisee
|
||||
if boxes is not None and i < len(boxes):
|
||||
b = boxes.xywhn[i].cpu().numpy().tolist() # cx, cy, w, h
|
||||
conf_b = float(boxes.conf[i].item())
|
||||
ctx.send("person", float(i), *b, conf_b)
|
||||
if not emit_kp or kp_xy is None:
|
||||
continue
|
||||
pts = kp_xy[i].cpu().numpy() # (17, 2) px
|
||||
cfs = kp_conf[i].cpu().numpy() if kp_conf is not None \
|
||||
else np.ones(len(pts), dtype=float)
|
||||
flat: list[float] = []
|
||||
conf_sum = 0.0
|
||||
for (x, y), c in zip(pts, cfs):
|
||||
xn = float(x) / max(1.0, w)
|
||||
yn = float(y) / max(1.0, h)
|
||||
cc = float(c)
|
||||
flat.extend([xn, yn, cc])
|
||||
conf_sum += cc
|
||||
avg = conf_sum / max(1, len(pts))
|
||||
ctx.send("skel", float(i), avg, *flat)
|
||||
|
||||
# cadence
|
||||
dt = time.monotonic() - t0
|
||||
if dt < period:
|
||||
await asyncio.sleep(period - dt)
|
||||
finally:
|
||||
cap.release()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""RTE éCO2mix — mix électrique France (OAuth2 client_credentials)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
LOG = logging.getLogger("feed.rte_eco2mix")
|
||||
|
||||
TOKEN_URL = "https://digital.iservices.rte-france.com/token/oauth/"
|
||||
API_URL = "https://digital.iservices.rte-france.com/open_api/actual_generation/v1/actual_generations_per_production_type"
|
||||
|
||||
|
||||
async def _get_token(cli: httpx.AsyncClient, cid: str, csec: str) -> tuple[str, float]:
|
||||
r = await cli.post(TOKEN_URL, auth=(cid, csec),
|
||||
data={"grant_type": "client_credentials"})
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
return j["access_token"], time.monotonic() + float(j.get("expires_in", 7200)) - 60
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
|
||||
period = float(cfg.get("poll_seconds", 900))
|
||||
if not (cid and csec):
|
||||
LOG.warning("client_id/client_secret manquants — feed inactif")
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
token, exp = "", 0.0
|
||||
async with httpx.AsyncClient(timeout=30.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
if time.monotonic() > exp:
|
||||
token, exp = await _get_token(cli, cid, csec)
|
||||
r = await cli.get(API_URL, headers={"Authorization": f"Bearer {token}"})
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
latest = {}
|
||||
for series in (j.get("actual_generations_per_production_type") or []):
|
||||
typ = series.get("production_type", "?")
|
||||
vals = series.get("values") or []
|
||||
if vals:
|
||||
latest[typ] = float(vals[-1].get("value", 0.0))
|
||||
# mapping standard RTE → ordre des args
|
||||
ctx.send(
|
||||
"mix",
|
||||
latest.get("NUCLEAR", 0.0),
|
||||
latest.get("GAS", 0.0),
|
||||
latest.get("COAL", 0.0),
|
||||
latest.get("OIL", 0.0),
|
||||
latest.get("HYDRO", 0.0),
|
||||
latest.get("WIND", 0.0),
|
||||
latest.get("SOLAR", 0.0),
|
||||
latest.get("BIOENERGY", 0.0),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""NOAA SWPC — vent solaire, IMF Bz, indice Kp, X-ray flux GOES."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
|
||||
import httpx
|
||||
|
||||
LOG = logging.getLogger("feed.swpc")
|
||||
|
||||
|
||||
def _last_row(data) -> list | None:
|
||||
if not data or len(data) < 2:
|
||||
return None
|
||||
return data[-1]
|
||||
|
||||
|
||||
def _flare_class_norm(long_wm2: float) -> float:
|
||||
"""Mappe le X-ray long band en classe normalisée 0..1.
|
||||
A=1e-8, B=1e-7, C=1e-6, M=1e-5, X=1e-4. log10 → [0..1] sur A→X.
|
||||
"""
|
||||
if long_wm2 <= 0:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, (math.log10(long_wm2) + 8.0) / 4.0))
|
||||
|
||||
|
||||
async def _fetch_json(cli: httpx.AsyncClient, url: str):
|
||||
r = await cli.get(url)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
period = float(cfg.get("poll_seconds", 60))
|
||||
urls = {
|
||||
"plasma": cfg.get("url_plasma"),
|
||||
"mag": cfg.get("url_mag"),
|
||||
"kp": cfg.get("url_kp"),
|
||||
"xray": cfg.get("url_xray"),
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
if urls["plasma"]:
|
||||
j = await _fetch_json(cli, urls["plasma"])
|
||||
row = _last_row(j)
|
||||
if row:
|
||||
# ["time_tag","density","speed","temperature"]
|
||||
try:
|
||||
density = float(row[1])
|
||||
speed = float(row[2])
|
||||
temp = float(row[3])
|
||||
ctx.send("wind", speed, density, temp)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if urls["mag"]:
|
||||
j = await _fetch_json(cli, urls["mag"])
|
||||
row = _last_row(j)
|
||||
if row:
|
||||
# ["time_tag","bx_gsm","by_gsm","bz_gsm","lon_gsm","lat_gsm","bt"]
|
||||
try:
|
||||
bz = float(row[3])
|
||||
bt = float(row[6])
|
||||
ctx.send("bz", bz, bt)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if urls["kp"]:
|
||||
j = await _fetch_json(cli, urls["kp"])
|
||||
row = _last_row(j)
|
||||
if row:
|
||||
# ["time_tag","Kp","a_running","station_count"]
|
||||
try:
|
||||
kp = float(row[1])
|
||||
a = float(row[2])
|
||||
ctx.send("kp", kp, a)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if urls["xray"]:
|
||||
j = await _fetch_json(cli, urls["xray"])
|
||||
# split short/long bands
|
||||
short = next((d for d in reversed(j) if d.get("energy") == "0.05-0.4nm"), None)
|
||||
long_ = next((d for d in reversed(j) if d.get("energy") == "0.1-0.8nm"), None)
|
||||
s = float(short.get("flux", 0.0)) if short else 0.0
|
||||
l = float(long_.get("flux", 0.0)) if long_ else 0.0
|
||||
ctx.send("xray", s, l, _flare_class_norm(l))
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""USGS earthquakes — GeoJSON polling (1 min)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from ._util import RateMeter
|
||||
|
||||
LOG = logging.getLogger("feed.usgs")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["url"]
|
||||
period = float(cfg.get("poll_seconds", 60))
|
||||
seen: set[str] = set()
|
||||
rate = RateMeter(window=3600.0)
|
||||
async with httpx.AsyncClient(timeout=20.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
r = await cli.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
|
||||
now_ms = time.time() * 1000.0
|
||||
for feat in data.get("features", []):
|
||||
fid = feat.get("id")
|
||||
if not fid or fid in seen:
|
||||
continue
|
||||
seen.add(fid)
|
||||
props = feat.get("properties") or {}
|
||||
coords = (feat.get("geometry") or {}).get("coordinates") or [0, 0, 0]
|
||||
mag = float(props.get("mag") or 0.0)
|
||||
t_ms = float(props.get("time") or now_ms)
|
||||
age = max(0.0, (now_ms - t_ms) / 1000.0)
|
||||
ctx.send("event", mag, float(coords[0]), float(coords[1]),
|
||||
float(coords[2]), age)
|
||||
rate.tick()
|
||||
ctx.send("rate", rate.rate * 3600.0)
|
||||
|
||||
# garde la mémoire bornée
|
||||
if len(seen) > 4096:
|
||||
seen = set(list(seen)[-2048:])
|
||||
await asyncio.sleep(period)
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Resynchronise les sources du bridge depuis ../data_feeds, puis build.
|
||||
# Lancer depuis web_realart/.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
SRC="$HERE/../data_feeds"
|
||||
|
||||
if [ ! -d "$SRC" ]; then
|
||||
echo "ERR: $SRC introuvable. Sync skip."
|
||||
else
|
||||
echo "[sync] data_feeds -> bridge/src"
|
||||
mkdir -p "$HERE/bridge/src/feeds"
|
||||
cp "$SRC/bridge.py" "$HERE/bridge/src/bridge.py"
|
||||
rsync -a --delete --exclude '__pycache__' "$SRC/feeds/" "$HERE/bridge/src/feeds/"
|
||||
fi
|
||||
|
||||
echo "[build] docker compose build"
|
||||
cd "$HERE"
|
||||
docker compose build "$@"
|
||||
echo "[ok]"
|
||||
@@ -0,0 +1,55 @@
|
||||
# =====================================================================
|
||||
# web_realart -- stack de production pour real.art.saillant.cc
|
||||
#
|
||||
# Deux containers :
|
||||
# web -- Node, sert /webgl /audio /hydra + WS, ecoute UDP :57124
|
||||
# bridge -- Python, lit les flux temps reel et pousse en OSC sur web:57124
|
||||
#
|
||||
# Network "data" interne pour la cible OSC bridge -> web (UDP).
|
||||
# Network "traefik" externe pour HTTPS via le tunnel cloudflared.
|
||||
# =====================================================================
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: av-live-realart:latest
|
||||
container_name: realart-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- HTTP_PORT=4400
|
||||
- DATA_PORT_IN=57124
|
||||
networks:
|
||||
- traefik
|
||||
- data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.realart.rule=Host(`real.art.saillant.cc`)"
|
||||
- "traefik.http.routers.realart.entrypoints=websecure"
|
||||
- "traefik.http.routers.realart.tls=true"
|
||||
- "traefik.http.routers.realart.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.realart.loadbalancer.server.port=4400"
|
||||
# WS endpoint /ws : Traefik gere upgrade auto via header `Connection: Upgrade`
|
||||
|
||||
bridge:
|
||||
build:
|
||||
context: ./bridge
|
||||
dockerfile: Dockerfile
|
||||
image: av-live-realart-bridge:latest
|
||||
container_name: realart-bridge
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- web
|
||||
networks:
|
||||
- data
|
||||
# config.toml monte en read-only -- modifie sans rebuild
|
||||
volumes:
|
||||
- ./bridge/config.toml:/app/config.toml:ro
|
||||
command: ["python", "bridge.py", "--config", "/app/config.toml"]
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
external: true
|
||||
data:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "av-live-realart",
|
||||
"version": "0.1.0",
|
||||
"description": "AV-Live data_feeds standalone -- portage web (WebGL + Web Audio + Hydra) deployable sur real.art.saillant.cc",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"osc": "^2.4.5",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// =====================================================================
|
||||
// audio/app.js -- Portage Web Audio des SynthDef SC.
|
||||
//
|
||||
// Reproduit, dans la mesure du raisonnable cote Web Audio API, les
|
||||
// presets cles de sound_algo/examples/16_data_feeds.scd et 17_data_feeds_more.scd :
|
||||
//
|
||||
// cavity (16-A) : Schumann drone harmonique + grain percussif sur foudre
|
||||
// mix (16-B) : pad sawtooth + cutoff RLPF pilote par RTE renew_pct
|
||||
// geo (16-C) : BPF saw + sub-bass declenche par USGS magnitudes
|
||||
// aurora (17-D) : pad additif harmonique pilote par Bz/wind/Kp
|
||||
// pulse (17-E) : kick au tempo Netzfrequenz + clicks Bluesky + sub USGS
|
||||
//
|
||||
// Architecture commune :
|
||||
// - 1 master GainNode + AnalyserNode (oscilloscope)
|
||||
// - chaque preset alloue ses noeuds dans une fonction startXxx()
|
||||
// et les referme via stopAll() avant de switcher
|
||||
// =====================================================================
|
||||
|
||||
let ctx = null;
|
||||
let master = null;
|
||||
let analyser = null;
|
||||
let active = null;
|
||||
let activeName = null;
|
||||
|
||||
const startBtn = document.getElementById("start");
|
||||
const presetBtns = [...document.querySelectorAll("[data-preset]")];
|
||||
const nowEl = document.getElementById("now");
|
||||
const volSlider = document.getElementById("vol");
|
||||
const volV = document.getElementById("vol-v");
|
||||
|
||||
startBtn.addEventListener("click", () => {
|
||||
if (ctx) return;
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: "interactive" });
|
||||
master = ctx.createGain();
|
||||
master.gain.value = parseFloat(volSlider.value);
|
||||
analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
master.connect(analyser); analyser.connect(ctx.destination);
|
||||
presetBtns.forEach(b => b.disabled = false);
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = "running @ " + ctx.sampleRate + " Hz";
|
||||
drawScope();
|
||||
});
|
||||
|
||||
volSlider.addEventListener("input", () => {
|
||||
const v = parseFloat(volSlider.value);
|
||||
if (master) master.gain.linearRampToValueAtTime(v, ctx.currentTime + 0.05);
|
||||
volV.textContent = v.toFixed(2);
|
||||
});
|
||||
|
||||
presetBtns.forEach(b => b.addEventListener("click", () => {
|
||||
const p = b.dataset.preset;
|
||||
stopAll();
|
||||
presetBtns.forEach(x => x.classList.remove("active"));
|
||||
if (p === "stop") {
|
||||
nowEl.innerHTML = "stopped.";
|
||||
activeName = null;
|
||||
return;
|
||||
}
|
||||
b.classList.add("active");
|
||||
activeName = p;
|
||||
nowEl.innerHTML = `<span class="name">${p}</span> running. CC0 source flux : data_feeds (USGS, SWPC, Netzfrequenz, Blitzortung, OpenSky, Bluesky, RTE).`;
|
||||
switch (p) {
|
||||
case "cavity": active = startCavity(); break;
|
||||
case "mix": active = startMix(); break;
|
||||
case "geo": active = startGeo(); break;
|
||||
case "aurora": active = startAurora(); break;
|
||||
case "pulse": active = startPulse(); break;
|
||||
}
|
||||
}));
|
||||
|
||||
function stopAll() {
|
||||
if (active && typeof active.stop === "function") active.stop();
|
||||
active = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// helpers feeds (defaut sains si bridge down)
|
||||
// ---------------------------------------------------------------------
|
||||
const F = () => window.feeds ?? {
|
||||
netz:{freq:50,dev:0}, swpc:{wind_speed:400,bz:0,kp:2,flare_norm:0},
|
||||
usgs:{last_mag:0}, light:{}, sky:{}, bsky:{rate_s:0},
|
||||
rte:{renew_pct:0.25, total:50000},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PRESET cavity (16-A) : drone Schumann + grain foudre
|
||||
// ---------------------------------------------------------------------
|
||||
function startCavity() {
|
||||
const out = ctx.createGain(); out.gain.value = 0.0;
|
||||
out.connect(master);
|
||||
out.gain.linearRampToValueAtTime(0.22, ctx.currentTime + 2.0);
|
||||
|
||||
// additif harmonique sur 7.83 Hz x [1,2,3,4,5] -- monte d'octave x8
|
||||
// (7.83 trop sub pour Web Audio, on fait 7.83 * 8 = 62.6 Hz fondamental)
|
||||
const f0 = 7.83 * 8;
|
||||
const partials = [1, 2, 3, 4, 5];
|
||||
const amps = [1, 0.5, 0.33, 0.25, 0.2];
|
||||
const oscs = partials.map((h, i) => {
|
||||
const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = f0 * h;
|
||||
const g = ctx.createGain(); g.gain.value = amps[i] * 0.18;
|
||||
o.connect(g).connect(out);
|
||||
// FM par derive netz_dev (tres petite)
|
||||
o.start();
|
||||
return { o, g };
|
||||
});
|
||||
|
||||
// routine maj FM
|
||||
const tickFm = setInterval(() => {
|
||||
const dev = F().netz.dev || 0;
|
||||
oscs.forEach(({ o }, i) => {
|
||||
const target = f0 * partials[i] + dev * 80;
|
||||
o.frequency.linearRampToValueAtTime(target, ctx.currentTime + 0.1);
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// foudre : grain percussif spatialise (filtered noise + sin click)
|
||||
const onStrike = (a) => {
|
||||
const lat = a[0], lon = a[1], age = a[2], mult = a[3] || 1;
|
||||
if (age > 30) return;
|
||||
const pan = Math.max(-1, Math.min(1, lon / 180));
|
||||
const freq = 200 + (lat + 90) / 180 * 2800;
|
||||
const dur = 0.2 + Math.min(mult, 10) / 10 * 0.6;
|
||||
triggerStrike(out, freq, pan, dur, 0.4 + mult / 25);
|
||||
};
|
||||
window.onFeed("/data/blitzortung/strike", onStrike);
|
||||
|
||||
return { stop: () => {
|
||||
clearInterval(tickFm);
|
||||
out.gain.cancelScheduledValues(ctx.currentTime);
|
||||
out.gain.linearRampToValueAtTime(0, ctx.currentTime + 2);
|
||||
oscs.forEach(({ o }) => o.stop(ctx.currentTime + 2.2));
|
||||
}};
|
||||
}
|
||||
|
||||
function triggerStrike(dest, freq, pan, dur, amp) {
|
||||
const t = ctx.currentTime;
|
||||
const noise = ctx.createBufferSource();
|
||||
const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1);
|
||||
noise.buffer = buf;
|
||||
const bp = ctx.createBiquadFilter();
|
||||
bp.type = "bandpass"; bp.frequency.value = freq; bp.Q.value = 8;
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(0, t);
|
||||
env.gain.linearRampToValueAtTime(amp, t + 0.005);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
const panner = ctx.createStereoPanner(); panner.pan.value = pan;
|
||||
noise.connect(bp).connect(env).connect(panner).connect(dest);
|
||||
noise.start(t); noise.stop(t + dur + 0.05);
|
||||
const sin = ctx.createOscillator(); sin.frequency.value = freq;
|
||||
const sg = ctx.createGain();
|
||||
sg.gain.setValueAtTime(0, t);
|
||||
sg.gain.linearRampToValueAtTime(amp * 0.5, t + 0.005);
|
||||
sg.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
sin.connect(sg).connect(panner);
|
||||
sin.start(t); sin.stop(t + dur + 0.05);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PRESET mix (16-B) : pad sawtooth + cutoff RLPF par renew_pct
|
||||
// ---------------------------------------------------------------------
|
||||
function startMix() {
|
||||
const out = ctx.createGain(); out.gain.value = 0; out.connect(master);
|
||||
out.gain.linearRampToValueAtTime(0.18, ctx.currentTime + 1.5);
|
||||
|
||||
const f0 = 55;
|
||||
const ratios = [1, 1.005, 0.503, 2.01];
|
||||
const oscs = ratios.map(r => {
|
||||
const o = ctx.createOscillator(); o.type = "sawtooth";
|
||||
o.frequency.value = f0 * r;
|
||||
return o;
|
||||
});
|
||||
const sum = ctx.createGain(); sum.gain.value = 1 / ratios.length;
|
||||
oscs.forEach(o => o.connect(sum));
|
||||
const lpf = ctx.createBiquadFilter();
|
||||
lpf.type = "lowpass"; lpf.frequency.value = 800; lpf.Q.value = 3;
|
||||
sum.connect(lpf).connect(out);
|
||||
oscs.forEach(o => o.start());
|
||||
|
||||
const tick = setInterval(() => {
|
||||
const pct = F().rte.renew_pct ?? 0.25;
|
||||
// 0..0.5 -> 200..5000 (exp)
|
||||
const cut = 200 * Math.pow(5000/200, Math.min(0.5, pct) / 0.5);
|
||||
const q = 0.5 + (1 - Math.min(1, pct * 2)) * 8;
|
||||
lpf.frequency.linearRampToValueAtTime(cut, ctx.currentTime + 1.5);
|
||||
lpf.Q.linearRampToValueAtTime(q, ctx.currentTime + 1.5);
|
||||
}, 1500);
|
||||
|
||||
// OpenSky -> blip melodique
|
||||
const onPlane = (a) => {
|
||||
const lon = a[1], alt = a[3], vel = a[4];
|
||||
const pan = Math.max(-1, Math.min(1, (lon - 4.9) / 0.3));
|
||||
const midi = Math.max(24, Math.min(96, 36 + alt / 12000 * 48));
|
||||
const freq = 440 * Math.pow(2, (midi - 69) / 12);
|
||||
triggerBlip(out, freq, pan, 0.6, Math.min(0.35, 0.1 + vel / 1500));
|
||||
};
|
||||
window.onFeed("/data/opensky/plane", onPlane);
|
||||
|
||||
return { stop: () => {
|
||||
clearInterval(tick);
|
||||
out.gain.cancelScheduledValues(ctx.currentTime);
|
||||
out.gain.linearRampToValueAtTime(0, ctx.currentTime + 2);
|
||||
oscs.forEach(o => o.stop(ctx.currentTime + 2.2));
|
||||
}};
|
||||
}
|
||||
|
||||
function triggerBlip(dest, freq, pan, dur, amp) {
|
||||
const t = ctx.currentTime;
|
||||
const o1 = ctx.createOscillator(); o1.type = "sine"; o1.frequency.value = freq;
|
||||
const o2 = ctx.createOscillator(); o2.type = "sine"; o2.frequency.value = freq * 2.01;
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(0, t);
|
||||
env.gain.linearRampToValueAtTime(amp, t + 0.01);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
const pann = ctx.createStereoPanner(); pann.pan.value = pan;
|
||||
o1.connect(env); o2.connect(env); env.connect(pann).connect(dest);
|
||||
o1.start(t); o2.start(t); o1.stop(t + dur + 0.05); o2.stop(t + dur + 0.05);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PRESET geo (16-C) : BPF + sub-bass quake + flare boost
|
||||
// ---------------------------------------------------------------------
|
||||
function startGeo() {
|
||||
const out = ctx.createGain(); out.gain.value = 0; out.connect(master);
|
||||
out.gain.linearRampToValueAtTime(0.25, ctx.currentTime + 2.0);
|
||||
|
||||
const oscs = [55, 110, 165].map(f => {
|
||||
const o = ctx.createOscillator(); o.type = "sawtooth"; o.frequency.value = f;
|
||||
return o;
|
||||
});
|
||||
const sum = ctx.createGain(); sum.gain.value = 0.33;
|
||||
oscs.forEach(o => o.connect(sum));
|
||||
const bpf = ctx.createBiquadFilter();
|
||||
bpf.type = "bandpass"; bpf.frequency.value = 400; bpf.Q.value = 2;
|
||||
sum.connect(bpf).connect(out);
|
||||
oscs.forEach(o => o.start());
|
||||
|
||||
const tick = setInterval(() => {
|
||||
const kp = F().swpc.kp ?? 2;
|
||||
// 0..9 -> 200..3000 (exp)
|
||||
const f = 200 * Math.pow(15, Math.min(9, kp) / 9);
|
||||
const q = 1 + (1 - Math.min(1, kp/9)) * 18;
|
||||
bpf.frequency.linearRampToValueAtTime(f, ctx.currentTime + 4);
|
||||
bpf.Q.linearRampToValueAtTime(q, ctx.currentTime + 4);
|
||||
}, 4000);
|
||||
|
||||
const onQuake = (mag) => triggerQuakeSub(out, mag);
|
||||
const wrap = (a) => onQuake(a[0]);
|
||||
window.onFeed("/data/usgs/event", wrap);
|
||||
|
||||
const onFlare = (a) => {
|
||||
if ((a[2] || 0) > 0.5) {
|
||||
out.gain.cancelScheduledValues(ctx.currentTime);
|
||||
out.gain.linearRampToValueAtTime(0.55, ctx.currentTime + 0.05);
|
||||
out.gain.linearRampToValueAtTime(0.25, ctx.currentTime + 3);
|
||||
}
|
||||
};
|
||||
window.onFeed("/data/swpc/xray", onFlare);
|
||||
|
||||
return { stop: () => {
|
||||
clearInterval(tick);
|
||||
out.gain.cancelScheduledValues(ctx.currentTime);
|
||||
out.gain.linearRampToValueAtTime(0, ctx.currentTime + 2);
|
||||
oscs.forEach(o => o.stop(ctx.currentTime + 2.2));
|
||||
}};
|
||||
}
|
||||
|
||||
function triggerQuakeSub(dest, mag) {
|
||||
const t = ctx.currentTime;
|
||||
const m = Math.max(2, Math.min(8, mag));
|
||||
const f = 30 * Math.pow(4, (m - 2) / 6);
|
||||
const dur = 1 + (m - 2) * 0.7;
|
||||
const amp = 0.4 + (m - 2) / 6 * 0.5;
|
||||
const o1 = ctx.createOscillator(); o1.type = "sine"; o1.frequency.value = f;
|
||||
const o2 = ctx.createOscillator(); o2.type = "triangle"; o2.frequency.value = f * 0.5;
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(0, t);
|
||||
env.gain.linearRampToValueAtTime(amp, t + 0.05);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
o1.connect(env); o2.connect(env); env.connect(dest);
|
||||
o1.start(t); o2.start(t); o1.stop(t + dur + 0.05); o2.stop(t + dur + 0.05);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PRESET aurora (17-D) : additif Bz/wind/Kp
|
||||
// ---------------------------------------------------------------------
|
||||
function startAurora() {
|
||||
const out = ctx.createGain(); out.gain.value = 0; out.connect(master);
|
||||
out.gain.linearRampToValueAtTime(0.22, ctx.currentTime + 3.0);
|
||||
|
||||
const f0 = 55;
|
||||
const harm = [1, 1.0, 2, 2.01, 3, 3.02, 5];
|
||||
const ratio = 1; // ajuste par routine
|
||||
const amps = [1, 0.7, 0.5, 0.4, 0.3, 0.25, 0.15];
|
||||
const oscs = harm.map((h, i) => {
|
||||
const o = ctx.createOscillator(); o.type = "sine";
|
||||
const g = ctx.createGain(); g.gain.value = amps[i] * 0.12;
|
||||
o.connect(g);
|
||||
return { o, g, h };
|
||||
});
|
||||
const lpf = ctx.createBiquadFilter();
|
||||
lpf.type = "lowpass"; lpf.frequency.value = 2000; lpf.Q.value = 0.7;
|
||||
oscs.forEach(({ g }) => g.connect(lpf));
|
||||
lpf.connect(out);
|
||||
oscs.forEach(({ o }) => o.start());
|
||||
|
||||
const tick = setInterval(() => {
|
||||
const fe = F();
|
||||
const bz = fe.swpc.bz ?? 0;
|
||||
const wind = fe.swpc.wind_speed ?? 400;
|
||||
const kp = fe.swpc.kp ?? 2;
|
||||
const a = fe.swpc.a ?? 5;
|
||||
// shift de freq par Bz : -20 nT -> *0.5, +20 -> *1.4
|
||||
const fmul = 0.5 * Math.pow(2.8, Math.max(-20, Math.min(20, bz + 20)) / 40);
|
||||
// detune par vent (250..900 -> 0.002..0.025)
|
||||
const det = 0.002 + Math.max(0, Math.min(650, wind - 250)) / 650 * 0.023;
|
||||
oscs.forEach(({ o, h }) => {
|
||||
const target = f0 * fmul * h * (1 + (Math.random() - 0.5) * det);
|
||||
o.frequency.linearRampToValueAtTime(target, ctx.currentTime + 2.0);
|
||||
});
|
||||
// kp -> brillance (600..8000 exp)
|
||||
const cut = 600 * Math.pow(8000/600, Math.min(9, kp) / 9);
|
||||
lpf.frequency.linearRampToValueAtTime(cut, ctx.currentTime + 2.0);
|
||||
// a-index -> amp 0.18..0.32
|
||||
const amp = 0.18 + Math.min(50, a) / 50 * 0.14;
|
||||
out.gain.linearRampToValueAtTime(amp, ctx.currentTime + 2.0);
|
||||
}, 2000);
|
||||
|
||||
return { stop: () => {
|
||||
clearInterval(tick);
|
||||
out.gain.cancelScheduledValues(ctx.currentTime);
|
||||
out.gain.linearRampToValueAtTime(0, ctx.currentTime + 4);
|
||||
oscs.forEach(({ o }) => o.stop(ctx.currentTime + 4.2));
|
||||
}};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PRESET pulse (17-E) : kick netz + clicks bsky + sub usgs
|
||||
// ---------------------------------------------------------------------
|
||||
function startPulse() {
|
||||
const out = ctx.createGain(); out.gain.value = 0.6; out.connect(master);
|
||||
let stopped = false;
|
||||
let timer = null;
|
||||
|
||||
function tick() {
|
||||
if (stopped) return;
|
||||
const fe = F();
|
||||
const hz = fe.netz.freq || 50.0;
|
||||
const dev = fe.netz.dev || 0;
|
||||
const bpm = hz * 2.5;
|
||||
const beat = 60 / bpm;
|
||||
// kick
|
||||
const t = ctx.currentTime;
|
||||
const fK = 50 + dev * 30;
|
||||
const o = ctx.createOscillator(); o.type = "sine";
|
||||
o.frequency.setValueAtTime(fK * 5, t);
|
||||
o.frequency.exponentialRampToValueAtTime(fK, t + 0.12);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0, t);
|
||||
g.gain.linearRampToValueAtTime(0.7, t + 0.005);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.4);
|
||||
const sat = ctx.createWaveShaper(); sat.curve = makeTanhCurve(2);
|
||||
o.connect(g).connect(sat).connect(out);
|
||||
o.start(t); o.stop(t + 0.45);
|
||||
// hat off-beat si grille stable
|
||||
if (Math.abs(dev) < 0.02) {
|
||||
timer = setTimeout(() => triggerHat(out), beat * 500);
|
||||
setTimeout(tick, beat * 1000);
|
||||
} else {
|
||||
setTimeout(tick, beat * 1000);
|
||||
}
|
||||
}
|
||||
tick();
|
||||
|
||||
const onBskyRate = (a) => {
|
||||
const n = Math.min(30, Math.floor(a[0] || 0));
|
||||
for (let i = 0; i < n; i++) {
|
||||
setTimeout(() => triggerClick(out), i * 30 + Math.random() * 1000);
|
||||
}
|
||||
};
|
||||
window.onFeed("/data/bluesky/rate", onBskyRate);
|
||||
|
||||
const onQuake = (a) => triggerQuakeSub(out, a[0]);
|
||||
window.onFeed("/data/usgs/event", onQuake);
|
||||
|
||||
return { stop: () => {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
out.gain.cancelScheduledValues(ctx.currentTime);
|
||||
out.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5);
|
||||
}};
|
||||
}
|
||||
|
||||
function triggerHat(dest) {
|
||||
const t = ctx.currentTime;
|
||||
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.04, ctx.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||||
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 8000;
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(0, t);
|
||||
env.gain.linearRampToValueAtTime(0.18, t + 0.001);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.04);
|
||||
const pan = ctx.createStereoPanner(); pan.pan.value = 0.3;
|
||||
src.connect(hp).connect(env).connect(pan).connect(dest);
|
||||
src.start(t); src.stop(t + 0.05);
|
||||
}
|
||||
|
||||
function triggerClick(dest) {
|
||||
const t = ctx.currentTime;
|
||||
const f = 800 * Math.pow(10, Math.random());
|
||||
const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = f;
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(0, t);
|
||||
env.gain.linearRampToValueAtTime(0.06 + Math.random() * 0.1, t + 0.0005);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.02);
|
||||
const pan = ctx.createStereoPanner(); pan.pan.value = Math.random() * 2 - 1;
|
||||
o.connect(env).connect(pan).connect(dest);
|
||||
o.start(t); o.stop(t + 0.025);
|
||||
}
|
||||
|
||||
function makeTanhCurve(k) {
|
||||
const n = 1024, c = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i / (n - 1)) * 2 - 1;
|
||||
c[i] = Math.tanh(x * k);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HUD + scope
|
||||
// ---------------------------------------------------------------------
|
||||
const aliveEl = document.getElementById("alive");
|
||||
const kpEl = document.getElementById("kp");
|
||||
const bzEl = document.getElementById("bz");
|
||||
const windEl = document.getElementById("wind");
|
||||
const netzEl = document.getElementById("netz");
|
||||
const flareEl = document.getElementById("flare");
|
||||
setInterval(() => {
|
||||
const a = window.feeds?.alive;
|
||||
aliveEl.textContent = a ? "ALIVE" : "DOWN";
|
||||
aliveEl.className = a ? "alive" : "dead";
|
||||
kpEl.textContent = (window.feeds?.swpc.kp ?? 0).toFixed(1);
|
||||
bzEl.textContent = (window.feeds?.swpc.bz ?? 0).toFixed(1);
|
||||
windEl.textContent = (window.feeds?.swpc.wind_speed ?? 0).toFixed(0);
|
||||
netzEl.textContent = (window.feeds?.netz.freq ?? 0).toFixed(3);
|
||||
flareEl.textContent = (window.feeds?.swpc.flare_norm ?? 0).toFixed(2);
|
||||
}, 250);
|
||||
|
||||
const scope = document.getElementById("scope");
|
||||
const sctx = scope.getContext("2d");
|
||||
function drawScope() {
|
||||
if (!analyser) return;
|
||||
requestAnimationFrame(drawScope);
|
||||
const W = scope.width = scope.clientWidth;
|
||||
const H = scope.height = scope.clientHeight;
|
||||
const buf = new Uint8Array(analyser.fftSize);
|
||||
analyser.getByteTimeDomainData(buf);
|
||||
sctx.fillStyle = "#0a0a12"; sctx.fillRect(0, 0, W, H);
|
||||
sctx.strokeStyle = "#5b8bff"; sctx.lineWidth = 1.2; sctx.beginPath();
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const x = i / buf.length * W;
|
||||
const y = H/2 + (buf[i] / 128.0 - 1) * H/2 * 0.9;
|
||||
if (i === 0) sctx.moveTo(x, y); else sctx.lineTo(x, y);
|
||||
}
|
||||
sctx.stroke();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>realart / audio (Web Audio portage SC)</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; min-height: 100vh; background: #050308;
|
||||
color: #e8e8e8; font-family: ui-monospace, Menlo, monospace;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 2rem; gap: 1.4rem; }
|
||||
h1 { margin: 0; font-size: 1.4rem; letter-spacing: -0.01em; }
|
||||
h1 a { color: #5b8bff; text-decoration: none; }
|
||||
p.lead { color: #888; max-width: 560px; text-align: center; margin: 0;
|
||||
font-size: 0.85rem; line-height: 1.5; }
|
||||
.presets { display: grid; gap: 0.6rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
width: 100%; max-width: 720px; }
|
||||
button {
|
||||
background: #1b1b27; border: 1px solid #2c2c36; color: #e8e8e8;
|
||||
padding: 0.7rem 0.5rem; border-radius: 6px; cursor: pointer;
|
||||
font-family: inherit; font-size: 0.8rem; transition: border-color 0.15s;
|
||||
}
|
||||
button:hover { border-color: #5b8bff; }
|
||||
button.active { border-color: #38d977; background: #182218; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
#start { padding: 1rem 2rem; font-size: 1rem; border-color: #5b8bff;
|
||||
background: #15151f; color: #5b8bff; }
|
||||
#start:hover { background: #1d1d2c; }
|
||||
#meters { display: flex; gap: 1rem; font-size: 0.7rem; color: #999;
|
||||
background: rgba(255,255,255,0.03); padding: 0.6rem 1rem;
|
||||
border-radius: 6px; border: 1px solid rgba(255,255,255,0.06); }
|
||||
#meters .v { color: #5b8bff; min-width: 40px; display: inline-block; }
|
||||
#meters .alive { color: #38d977; }
|
||||
#meters .dead { color: #ff5151; }
|
||||
#now { font-size: 0.7rem; color: #777; max-width: 720px;
|
||||
line-height: 1.5; text-align: center; }
|
||||
#now .name { color: #38d977; }
|
||||
#vol-row { display: flex; gap: 0.8rem; align-items: center; }
|
||||
#vol-row label { color: #888; font-size: 0.75rem; }
|
||||
#vol { width: 200px; }
|
||||
canvas { display: block; }
|
||||
#scope { width: 720px; max-width: 100%; height: 80px;
|
||||
background: #0a0a12; border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><a href="/">/ realart</a> · audio (portage SC)</h1>
|
||||
<p class="lead">
|
||||
Synthese Web Audio pilotee par les memes flux que les presets
|
||||
SuperCollider (sound_algo/examples/16+17). Cliquer "start audio"
|
||||
(geste utilisateur requis), puis selectionner un preset.
|
||||
</p>
|
||||
|
||||
<button id="start">START AUDIO</button>
|
||||
|
||||
<div id="meters">
|
||||
<div>feeds : <span id="alive">…</span></div>
|
||||
<div>kp <span class="v" id="kp">--</span></div>
|
||||
<div>bz <span class="v" id="bz">--</span> nT</div>
|
||||
<div>wind <span class="v" id="wind">--</span> km/s</div>
|
||||
<div>netz <span class="v" id="netz">--</span> Hz</div>
|
||||
<div>flare <span class="v" id="flare">--</span></div>
|
||||
</div>
|
||||
|
||||
<div class="presets">
|
||||
<button data-preset="cavity" disabled>Cavity (16-A)</button>
|
||||
<button data-preset="mix" disabled>Mix (16-B)</button>
|
||||
<button data-preset="geo" disabled>Geo (16-C)</button>
|
||||
<button data-preset="aurora" disabled>Aurora (17-D)</button>
|
||||
<button data-preset="pulse" disabled>Pulse (17-E)</button>
|
||||
<button data-preset="stop" disabled>STOP</button>
|
||||
</div>
|
||||
|
||||
<div id="vol-row">
|
||||
<label>master</label>
|
||||
<input id="vol" type="range" min="0" max="1" step="0.01" value="0.4" />
|
||||
<span id="vol-v" style="color:#5b8bff;font-size:0.7rem;width:30px">0.40</span>
|
||||
</div>
|
||||
|
||||
<canvas id="scope"></canvas>
|
||||
|
||||
<p id="now">aucun preset actif. <span class="name">cavity</span>
|
||||
= drone Schumann + foudre, <span class="name">aurora</span>
|
||||
= pad geomagnetique, <span class="name">pulse</span>
|
||||
= kick netz + sub seismes...</p>
|
||||
|
||||
<script src="/feeds_client.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,168 @@
|
||||
// =====================================================================
|
||||
// feeds_client.js -- WebSocket client partage par les 3 modules
|
||||
// (WebGL, Audio, Hydra). Expose window.feeds + window.f helpers.
|
||||
//
|
||||
// Reconnexion auto, dedup, snapshot "feeds._lastByAddr".
|
||||
// Les modules s'abonnent via window.onFeed("/data/usgs/event", fn).
|
||||
// =====================================================================
|
||||
(() => {
|
||||
if (window.__feedsClientLoaded) return;
|
||||
window.__feedsClientLoaded = true;
|
||||
|
||||
window.feeds = window.feeds ?? {
|
||||
netz: { freq: 50, dev: 0, time_dev: 0 },
|
||||
swpc: { wind_speed: 400, wind_dens: 5, bz: 0, bt: 5, kp: 2, a: 5, flare_norm: 0 },
|
||||
usgs: { last_mag: 0, last_lon: 0, last_lat: 0, last_age: 9999, rate_h: 0 },
|
||||
light: { rate_min: 0, last_lat: 0, last_lon: 0, last_age: 9999 },
|
||||
sky: { count: 0, last_lon: 0, last_lat: 0, last_alt: 0, last_vel: 0,
|
||||
last_head: 0, last_pan: 0 },
|
||||
bsky: { rate_s: 0 },
|
||||
rte: { renew_pct: 0.25, total: 50000 },
|
||||
pose: { count: 0, persons: [], skels: [] },
|
||||
events: [], // ring de derniers evenements ponctuels (visualizers)
|
||||
tick: 0,
|
||||
alive: false,
|
||||
_lastHb: 0,
|
||||
};
|
||||
|
||||
const listeners = new Map();
|
||||
window.onFeed = (addr, fn) => {
|
||||
if (!listeners.has(addr)) listeners.set(addr, []);
|
||||
listeners.get(addr).push(fn);
|
||||
};
|
||||
function notify(addr, args) {
|
||||
const arr = listeners.get(addr);
|
||||
if (arr) for (const fn of arr) try { fn(args); } catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
function pushEvent(kind, data) {
|
||||
window.feeds.events.push({ kind, data, t: performance.now() });
|
||||
if (window.feeds.events.length > 256) window.feeds.events.splice(0, 64);
|
||||
}
|
||||
|
||||
function ingest(addr, args) {
|
||||
window.feeds.tick++;
|
||||
const a = args || [];
|
||||
switch (addr) {
|
||||
case "/data/heartbeat":
|
||||
window.feeds._lastHb = Date.now();
|
||||
window.feeds.alive = true; return;
|
||||
case "/data/netzfrequenz/freq": window.feeds.netz.freq = a[0]; break;
|
||||
case "/data/netzfrequenz/dev": window.feeds.netz.dev = a[0]; break;
|
||||
case "/data/netzfrequenz/time_dev": window.feeds.netz.time_dev = a[0]; break;
|
||||
case "/data/swpc/wind":
|
||||
window.feeds.swpc.wind_speed = a[0];
|
||||
window.feeds.swpc.wind_dens = a[1]; break;
|
||||
case "/data/swpc/bz":
|
||||
window.feeds.swpc.bz = a[0]; window.feeds.swpc.bt = a[1]; break;
|
||||
case "/data/swpc/kp":
|
||||
window.feeds.swpc.kp = a[0]; window.feeds.swpc.a = a[1]; break;
|
||||
case "/data/swpc/xray":
|
||||
window.feeds.swpc.flare_norm = a[2];
|
||||
if (a[2] > 0.5) pushEvent("flare", { norm: a[2] });
|
||||
break;
|
||||
case "/data/usgs/event":
|
||||
window.feeds.usgs.last_mag = a[0];
|
||||
window.feeds.usgs.last_lon = a[1];
|
||||
window.feeds.usgs.last_lat = a[2];
|
||||
window.feeds.usgs.last_age = a[4];
|
||||
pushEvent("quake", { mag: a[0], lon: a[1], lat: a[2], depth: a[3] });
|
||||
break;
|
||||
case "/data/usgs/rate": window.feeds.usgs.rate_h = a[0]; break;
|
||||
case "/data/blitzortung/strike":
|
||||
window.feeds.light.last_lat = a[0];
|
||||
window.feeds.light.last_lon = a[1];
|
||||
window.feeds.light.last_age = a[2];
|
||||
pushEvent("strike", { lat: a[0], lon: a[1], mult: a[3] });
|
||||
break;
|
||||
case "/data/blitzortung/rate": window.feeds.light.rate_min = a[0]; break;
|
||||
case "/data/opensky/count": window.feeds.sky.count = a[0]; break;
|
||||
case "/data/opensky/plane":
|
||||
window.feeds.sky.last_lon = a[1];
|
||||
window.feeds.sky.last_lat = a[2];
|
||||
window.feeds.sky.last_alt = a[3];
|
||||
window.feeds.sky.last_vel = a[4];
|
||||
window.feeds.sky.last_head = a[5];
|
||||
window.feeds.sky.last_pan = (a[1] - 4.9) / 0.6;
|
||||
pushEvent("plane", { lon: a[1], lat: a[2], alt: a[3], vel: a[4], head: a[5] });
|
||||
break;
|
||||
case "/data/bluesky/rate": window.feeds.bsky.rate_s = a[0]; break;
|
||||
case "/data/pose/count":
|
||||
window.feeds.pose.count = a[0];
|
||||
if (a[0] === 0) { window.feeds.pose.persons = []; window.feeds.pose.skels = []; }
|
||||
break;
|
||||
case "/data/pose/person": {
|
||||
const idx = a[0]|0;
|
||||
window.feeds.pose.persons[idx] = {
|
||||
cx: a[1], cy: a[2], w: a[3], h: a[4], conf: a[5],
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "/data/pose/skel": {
|
||||
const idx = a[0]|0;
|
||||
window.feeds.pose.skels[idx] = { conf: a[1], kp: a.slice(2) };
|
||||
break;
|
||||
}
|
||||
case "/data/rte_eco2mix/mix": {
|
||||
const total = a.reduce((s, x) => s + (x||0), 0);
|
||||
const renew = (a[4]||0)+(a[5]||0)+(a[6]||0)+(a[7]||0);
|
||||
window.feeds.rte.total = total;
|
||||
window.feeds.rte.renew_pct = total > 0 ? renew / total : 0.25;
|
||||
break;
|
||||
}
|
||||
}
|
||||
notify(addr, a);
|
||||
}
|
||||
|
||||
// helpers compactes pour Hydra et shaders
|
||||
window.f = {
|
||||
quakePulse: () => {
|
||||
const ev = [...window.feeds.events].reverse().find(e => e.kind === "quake");
|
||||
if (!ev) return 0;
|
||||
const age = (performance.now() - ev.t) / 1000;
|
||||
return Math.max(0, 1 - age / 2);
|
||||
},
|
||||
strikePulse: () => {
|
||||
const ev = [...window.feeds.events].reverse().find(e => e.kind === "strike");
|
||||
if (!ev) return 0;
|
||||
const age = (performance.now() - ev.t) / 1000;
|
||||
return Math.max(0, 1 - age / 1.2);
|
||||
},
|
||||
flarePulse: () => Math.min(1, (window.feeds.swpc.flare_norm||0) * 2),
|
||||
netzDev: () => window.feeds.netz.dev || 0,
|
||||
bz: () => window.feeds.swpc.bz || 0,
|
||||
kp01: () => Math.min(1, (window.feeds.swpc.kp||0) / 9),
|
||||
wind01: () => Math.min(1, ((window.feeds.swpc.wind_speed||400) - 250) / 650),
|
||||
renew: () => window.feeds.rte.renew_pct || 0.25,
|
||||
skyCount01: () => Math.min(1, (window.feeds.sky.count||0) / 50),
|
||||
bskyDensity: () => Math.min(1, (window.feeds.bsky.rate_s||0) / 30),
|
||||
lightRate01: () => Math.min(1, (window.feeds.light.rate_min||0) / 60),
|
||||
};
|
||||
|
||||
let ws = null;
|
||||
function connect() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
ws.addEventListener("open", () => {
|
||||
window.feeds.alive = true;
|
||||
console.log("[feeds] WS open");
|
||||
});
|
||||
ws.addEventListener("close", () => {
|
||||
window.feeds.alive = false;
|
||||
setTimeout(connect, 1000);
|
||||
});
|
||||
ws.addEventListener("message", (ev) => {
|
||||
try {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (typeof m.address === "string") ingest(m.address, m.args);
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
connect();
|
||||
|
||||
setInterval(() => {
|
||||
if (window.feeds._lastHb && Date.now() - window.feeds._lastHb > 15000) {
|
||||
window.feeds.alive = false;
|
||||
}
|
||||
}, 5000);
|
||||
})();
|
||||
@@ -0,0 +1,116 @@
|
||||
// =====================================================================
|
||||
// hydra/app.js -- Patches Hydra data-driven (real.art version).
|
||||
//
|
||||
// Reuse les memes 7 patches que sound_algo/web/public/hydra (presets
|
||||
// feeds_*) mais sans dependances aux /sync/* SC. Tout vient de
|
||||
// window.feeds + window.f via /feeds_client.js.
|
||||
// =====================================================================
|
||||
|
||||
const canvas = document.getElementById("hydra-canvas");
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
window.addEventListener("resize", () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
});
|
||||
new Hydra({ canvas, detectAudio: false, makeGlobal: true });
|
||||
|
||||
const PRESETS = {
|
||||
aurora: () => {
|
||||
osc(() => 6 + window.f.wind01() * 18, 0.05, () => 1 + window.f.kp01())
|
||||
.modulate(noise(() => 2 + window.f.kp01() * 6, 0.1)
|
||||
.scrollY(() => time * 0.05))
|
||||
.rotate(() => window.f.bz() * 0.03)
|
||||
.color(() => 0.2 + window.f.kp01() * 0.4,
|
||||
() => 0.6 + window.f.wind01() * 0.4,
|
||||
() => 0.4 + window.f.flarePulse())
|
||||
.scale(() => 1 + window.f.flarePulse() * 0.6)
|
||||
.out();
|
||||
},
|
||||
quake: () => {
|
||||
shape(() => 4 + Math.floor(window.feeds.usgs.last_mag), 0.05, 0.02)
|
||||
.repeat(8, 8)
|
||||
.scale(() => 0.5 + window.f.quakePulse() * 2.5)
|
||||
.modulateRotate(noise(2, 0.2), () => window.f.quakePulse() * 3)
|
||||
.color(() => 0.8 + window.f.quakePulse(),
|
||||
() => 0.2 - window.f.quakePulse() * 0.2, 0.1)
|
||||
.add(osc(20, 0.05, 1).pixelate(8, 8), () => window.f.quakePulse() * 0.4)
|
||||
.out();
|
||||
},
|
||||
lightning: () => {
|
||||
voronoi(() => 8 + window.f.lightRate01() * 30, 0.3, 0.1)
|
||||
.modulate(noise(6, 0.3))
|
||||
.invert(() => window.f.strikePulse() > 0.6 ? 1 : 0)
|
||||
.add(solid(1, 1, 1, () => window.f.strikePulse() * 0.6))
|
||||
.color(0.7, 0.7, () => 0.9 + window.f.strikePulse())
|
||||
.out();
|
||||
},
|
||||
flightmap: () => {
|
||||
osc(() => 10 + window.feeds.sky.count, 0.05, 1)
|
||||
.kaleid(() => 4 + Math.floor(window.f.skyCount01() * 8))
|
||||
.modulate(osc(2).scrollX(() => (window.feeds.sky.last_pan || 0) * 0.1)
|
||||
.scrollY(() => (window.feeds.sky.last_alt || 0) / 50000))
|
||||
.rotate(() => (window.feeds.sky.last_pan || 0) * 0.5)
|
||||
.color(() => 0.3 + window.f.skyCount01() * 0.5,
|
||||
() => 0.5 + (window.feeds.sky.last_vel || 0) / 500,
|
||||
() => 0.6 + (window.feeds.sky.last_alt || 0) / 20000)
|
||||
.out();
|
||||
},
|
||||
gridpulse: () => {
|
||||
osc(() => 30 + (window.feeds.netz.freq || 50) * 0.5, 0.05, 1.5)
|
||||
.modulateScale(osc(8).rotate(() => time * 0.5),
|
||||
() => 0.05 + Math.abs(window.f.netzDev()) * 4)
|
||||
.scale(() => 1 + window.f.netzDev() * 8)
|
||||
.color(() => 0.5 - window.f.netzDev() * 5,
|
||||
() => 0.5 + window.f.netzDev() * 5,
|
||||
() => 0.5 + window.f.renew() * 0.5)
|
||||
.contrast(() => 1.2 + Math.abs(window.f.netzDev()) * 8)
|
||||
.out();
|
||||
},
|
||||
solarwind: () => {
|
||||
noise(() => 2 + window.f.wind01() * 8,
|
||||
() => 0.05 + window.f.flarePulse() * 0.3)
|
||||
.modulate(osc(() => 5 + window.f.wind01() * 10, 0.05)
|
||||
.rotate(() => time * 0.2))
|
||||
.colorama(() => 0.05 + window.f.kp01() * 0.4)
|
||||
.add(solid(1, 0.6, 0.2, () => window.f.flarePulse() * 0.5))
|
||||
.scale(() => 0.8 + window.f.wind01() * 0.5)
|
||||
.saturate(() => 1.3 + window.f.flarePulse() * 2)
|
||||
.out();
|
||||
},
|
||||
bskyrain: () => {
|
||||
shape(4, 0.005, 0.001)
|
||||
.repeat(() => 40 + window.f.bskyDensity() * 80,
|
||||
() => 40 + window.f.bskyDensity() * 80)
|
||||
.scrollY(() => time * (0.05 + window.f.bskyDensity() * 0.3))
|
||||
.scrollX(() => Math.sin(time * 0.3) * 0.02)
|
||||
.color(() => 0.4 + window.f.bskyDensity() * 0.4,
|
||||
() => 0.7 + window.f.kp01() * 0.3, 1)
|
||||
.modulate(noise(2, 0.2), 0.05)
|
||||
.out();
|
||||
},
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-p]").forEach(b => b.addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-p]").forEach(x => x.classList.remove("active"));
|
||||
b.classList.add("active");
|
||||
const p = PRESETS[b.dataset.p];
|
||||
if (p) p();
|
||||
}));
|
||||
|
||||
// HUD
|
||||
const aliveEl = document.getElementById("alive");
|
||||
const kpEl = document.getElementById("kp");
|
||||
const bzEl = document.getElementById("bz");
|
||||
const windEl = document.getElementById("wind");
|
||||
setInterval(() => {
|
||||
const a = window.feeds?.alive;
|
||||
aliveEl.textContent = a ? "ALIVE" : "DOWN";
|
||||
aliveEl.className = a ? "alive" : "dead";
|
||||
kpEl.textContent = (window.feeds?.swpc.kp ?? 0).toFixed(1);
|
||||
bzEl.textContent = (window.feeds?.swpc.bz ?? 0).toFixed(1);
|
||||
windEl.textContent = (window.feeds?.swpc.wind_speed ?? 0).toFixed(0);
|
||||
}, 250);
|
||||
|
||||
PRESETS.aurora();
|
||||
document.querySelector("[data-p=aurora]").classList.add("active");
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>realart / hydra</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: #000; color: #e8e8e8;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
overflow: hidden; }
|
||||
#hydra-canvas { position: fixed; inset: 0; width: 100vw; height: 100vh; z-index: 0; }
|
||||
#ui { position: fixed; top: 0; left: 0; right: 0; z-index: 10;
|
||||
display: flex; justify-content: space-between; padding: 1rem;
|
||||
pointer-events: none; }
|
||||
#ui > * { pointer-events: auto; }
|
||||
a { color: #888; text-decoration: none; }
|
||||
a:hover { color: #5b8bff; }
|
||||
.badge { background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.1); padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px; font-size: 0.75rem; color: #b0b0b0; }
|
||||
.badge .v { color: #5b8bff; font-variant-numeric: tabular-nums; }
|
||||
.badge .alive { color: #38d977; }
|
||||
.badge .dead { color: #ff5151; }
|
||||
button { background: rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.15);
|
||||
color: white; padding: 0.4rem 0.7rem; border-radius: 5px; cursor: pointer;
|
||||
font-family: inherit; font-size: 0.7rem; }
|
||||
button:hover { border-color: #5b8bff; }
|
||||
button.active { border-color: #38d977; }
|
||||
#presets { position: fixed; bottom: 0; left: 0; right: 0; z-index: 10;
|
||||
display: flex; gap: 0.4rem; padding: 0.8rem 1rem; flex-wrap: wrap;
|
||||
justify-content: center; background: linear-gradient(transparent, rgba(0,0,0,0.6)); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="hydra-canvas"></canvas>
|
||||
<div id="ui">
|
||||
<div class="badge">
|
||||
<a href="/">/ realart</a> · hydra ·
|
||||
feeds <span id="alive">…</span>
|
||||
</div>
|
||||
<div class="badge">
|
||||
kp <span class="v" id="kp">--</span> ·
|
||||
bz <span class="v" id="bz">--</span> ·
|
||||
wind <span class="v" id="wind">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="presets">
|
||||
<button data-p="aurora">aurora</button>
|
||||
<button data-p="quake">quake</button>
|
||||
<button data-p="lightning">lightning</button>
|
||||
<button data-p="flightmap">flights</button>
|
||||
<button data-p="gridpulse">grid</button>
|
||||
<button data-p="solarwind">solar</button>
|
||||
<button data-p="bskyrain">bsky</button>
|
||||
</div>
|
||||
|
||||
<script src="/feeds_client.js"></script>
|
||||
<script src="https://unpkg.com/hydra-synth@1.3.29/dist/hydra-synth.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>real.art -- AV-Live data_feeds</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh;
|
||||
background: radial-gradient(ellipse at 30% 20%, #1a1029, #050308 70%);
|
||||
color: #e8e8e8; font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 2rem; gap: 2rem;
|
||||
}
|
||||
h1 { font-size: clamp(1.8rem, 4vw, 3rem); margin: 0; letter-spacing: -0.02em; }
|
||||
h1 .dot { color: #ff5b5b; }
|
||||
p.lead { color: #a0a0b0; max-width: 640px; text-align: center; margin: 0; line-height: 1.5; }
|
||||
.grid {
|
||||
display: grid; gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
width: 100%; max-width: 800px;
|
||||
}
|
||||
a.card {
|
||||
display: block; padding: 1.4rem 1.2rem; border-radius: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
color: inherit; text-decoration: none;
|
||||
transition: transform 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
a.card:hover { transform: translateY(-2px); border-color: #5b8bff;
|
||||
background: rgba(91,139,255,0.06); }
|
||||
a.card .k { font-size: 0.7rem; text-transform: uppercase; color: #6b6b85;
|
||||
letter-spacing: 0.1em; }
|
||||
a.card .t { font-size: 1.2rem; margin-top: 0.3rem; }
|
||||
a.card .d { font-size: 0.85rem; color: #9090a0; margin-top: 0.4rem; line-height: 1.4; }
|
||||
#status {
|
||||
position: fixed; bottom: 1rem; left: 1rem;
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: 0.7rem;
|
||||
color: #6b6b75; padding: 0.5rem 0.8rem;
|
||||
background: rgba(0,0,0,0.5); border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
#status .alive { color: #38d977; }
|
||||
#status .dead { color: #ff5151; }
|
||||
footer { color: #5b5b6f; font-size: 0.75rem; }
|
||||
footer a { color: #7a7a90; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>real<span class="dot">.</span>art</h1>
|
||||
<p class="lead">
|
||||
Captation audiovisuelle continue de flux temps reel : sismicite,
|
||||
vent solaire, foudre, trafic aerien, frequence reseau, mix electrique.
|
||||
Trois portages -- WebGL globe, Web Audio synthese, Hydra DSL --
|
||||
partageant le meme bus de donnees.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<a class="card" href="/webgl/">
|
||||
<div class="k">visuel · portage oF</div>
|
||||
<div class="t">Globe WebGL</div>
|
||||
<div class="d">Quakes · foudre · vols ADS-B · flares geo-localises sur sphere temps reel.</div>
|
||||
</a>
|
||||
<a class="card" href="/audio/">
|
||||
<div class="k">sonore · portage SC</div>
|
||||
<div class="t">Web Audio engine</div>
|
||||
<div class="d">5 presets portes des SynthDef SC (cavity, aurora, pulse, geo, mix).</div>
|
||||
</a>
|
||||
<a class="card" href="/hydra/">
|
||||
<div class="k">visuel · DSL</div>
|
||||
<div class="t">Hydra patches</div>
|
||||
<div class="d">7 patches data-driven (aurora, quake, lightning, flightmap, gridpulse...).</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="/api/health">/api/health</a> ·
|
||||
bridge UDP :57124 ·
|
||||
<a href="https://github.com/electron-rare/AV-Live">source</a>
|
||||
</footer>
|
||||
|
||||
<div id="status">feeds : <span id="alive">…</span> · ticks <span id="ticks">0</span></div>
|
||||
|
||||
<script src="/feeds_client.js"></script>
|
||||
<script>
|
||||
const aliveEl = document.getElementById("alive");
|
||||
const ticksEl = document.getElementById("ticks");
|
||||
setInterval(() => {
|
||||
const a = window.feeds?.alive;
|
||||
aliveEl.textContent = a ? "ALIVE" : "DOWN";
|
||||
aliveEl.className = a ? "alive" : "dead";
|
||||
ticksEl.textContent = window.feeds?.tick ?? 0;
|
||||
}, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,283 @@
|
||||
// =====================================================================
|
||||
// webgl/app.js -- Portage WebGL des visualizers oF data-driven.
|
||||
//
|
||||
// Stack : three.js r171 + WebGPURenderer (auto-fallback WebGL2)
|
||||
// Materiaux ecrits en TSL (Three Shading Language) pour beneficier
|
||||
// de la generation automatique GLSL/WGSL et des nodes builtin
|
||||
// (mx_noise pour fbm, etc).
|
||||
//
|
||||
// Architecture inspiree directement de oscope-of/src/visualizers :
|
||||
// BG : ShaderVis-like fullscreen (aurore, flares)
|
||||
// GLOBE : MeshVis-like wireframe sphere
|
||||
// POINTS : ParticleVis-like quake/strike/plane (instances)
|
||||
// =====================================================================
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
Fn, vec2, vec3, vec4, float, mix, smoothstep, length, sin, cos,
|
||||
uniform, time, attribute, uv, positionLocal, mx_fractal_noise_float,
|
||||
abs as tslAbs, clamp, pow as tslPow, varyingProperty,
|
||||
} from "three/tsl";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Renderer : WebGPU prefere, fallback WebGL2 transparent (three.js le
|
||||
// fait via WebGPURenderer{ forceWebGL: ! navigator.gpu }).
|
||||
// ---------------------------------------------------------------------
|
||||
const canvas = document.getElementById("c");
|
||||
const backendEl = document.getElementById("backend");
|
||||
|
||||
const hasWebGPU = !!navigator.gpu;
|
||||
const renderer = new THREE.WebGPURenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
forceWebGL: !hasWebGPU,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
await renderer.init();
|
||||
backendEl.textContent = hasWebGPU ? "WebGPU" : "WebGL2 (fallback)";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
|
||||
camera.position.set(0, 1.0, 3.6);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
function resize() {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
window.addEventListener("resize", resize);
|
||||
resize();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Uniforms partages -- pilotes par window.feeds a chaque frame
|
||||
// ---------------------------------------------------------------------
|
||||
const uKp = uniform(0.2);
|
||||
const uWind = uniform(0.3);
|
||||
const uBz = uniform(0.0);
|
||||
const uFlare = uniform(0.0);
|
||||
const uRenew = uniform(0.25);
|
||||
const uDev = uniform(0.0);
|
||||
const uAlive = uniform(1.0);
|
||||
const uNow = uniform(0.0);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Background : aurore TSL (fbm via mx_fractal_noise + bandes verticales)
|
||||
// Equivalent shader des PRESET D Aurora (cote son).
|
||||
// ---------------------------------------------------------------------
|
||||
const bgGeo = new THREE.PlaneGeometry(2, 2);
|
||||
const bgMat = new THREE.NodeMaterial();
|
||||
|
||||
bgMat.colorNode = Fn(() => {
|
||||
const p = uv().sub(0.5).mul(vec2(2.4, 1.4));
|
||||
const t = time.mul(uWind.mul(0.2).add(0.05));
|
||||
const np = vec2(p.x.mul(3.0).add(t), p.y.mul(1.5).add(t.mul(0.3)));
|
||||
const aurora = tslPow(
|
||||
mx_fractal_noise_float(np, 5, 2.03, 0.5, 1.0).add(0.5),
|
||||
uBz.mul(0.8).add(1.6),
|
||||
);
|
||||
const maskY = smoothstep(uBz.mul(0.3).sub(0.6), float(0.6), p.y);
|
||||
const aLit = aurora.mul(maskY).mul(uKp.add(0.4));
|
||||
|
||||
const colA = mix(vec3(0.05, 0.4, 0.2), vec3(0.1, 0.9, 0.6), uKp);
|
||||
const colB = mix(vec3(0.4, 0.1, 0.6), vec3(0.9, 0.3, 0.8), uRenew);
|
||||
let col = aLit.mul(colA).add(aLit.mul(aLit).mul(colB));
|
||||
col = col.add(vec3(1.0, 0.6, 0.2).mul(uFlare).mul(0.5));
|
||||
|
||||
const r = length(p);
|
||||
col = col.mul(smoothstep(float(1.4), float(0.2), r));
|
||||
return vec4(col, 1.0);
|
||||
})();
|
||||
|
||||
bgMat.depthWrite = false;
|
||||
bgMat.depthTest = false;
|
||||
const bgMesh = new THREE.Mesh(bgGeo, bgMat);
|
||||
bgMesh.frustumCulled = false;
|
||||
// rendre en background : on ajoute a une scene bg
|
||||
const bgScene = new THREE.Scene();
|
||||
const bgCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
bgScene.add(bgMesh);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Globe wireframe : SphereGeometry -> WireframeGeometry -> LineSegments
|
||||
// ---------------------------------------------------------------------
|
||||
const sphereGeo = new THREE.SphereGeometry(1.0, 36, 18);
|
||||
const wireGeo = new THREE.WireframeGeometry(sphereGeo);
|
||||
const wireMat = new THREE.LineBasicNodeMaterial({ transparent: true });
|
||||
|
||||
wireMat.colorNode = Fn(() => {
|
||||
const y = positionLocal.y;
|
||||
const base = mix(vec3(0.3, 0.5, 1.0), vec3(0.4, 1.0, 0.7), uRenew);
|
||||
return vec4(base.mul(y.mul(0.5).add(0.5)).mul(uAlive.mul(0.6).add(0.4)), 0.7);
|
||||
})();
|
||||
|
||||
// micro-tremblement via positionNode pilote par dev (Netzfrequenz)
|
||||
wireMat.positionNode = Fn(() => {
|
||||
const p = positionLocal;
|
||||
const wob = sin(p.y.mul(30.0).add(uDev)).mul(tslAbs(uDev)).mul(0.5);
|
||||
return p.add(p.mul(wob));
|
||||
})();
|
||||
|
||||
const globe = new THREE.LineSegments(wireGeo, wireMat);
|
||||
scene.add(globe);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Particules : quake/strike/plane projetees sur la sphere
|
||||
// ---------------------------------------------------------------------
|
||||
const MAX_PTS = 1024;
|
||||
const ptPositions = new Float32Array(MAX_PTS * 3);
|
||||
const ptKinds = new Float32Array(MAX_PTS);
|
||||
const ptT0s = new Float32Array(MAX_PTS);
|
||||
const ptMags = new Float32Array(MAX_PTS);
|
||||
let ptCount = 0;
|
||||
|
||||
function lonLatToVec3(lon, lat, r) {
|
||||
const lonR = lon * Math.PI / 180;
|
||||
const latR = lat * Math.PI / 180;
|
||||
return [
|
||||
r * Math.cos(latR) * Math.cos(lonR),
|
||||
r * Math.sin(latR),
|
||||
r * Math.cos(latR) * Math.sin(lonR),
|
||||
];
|
||||
}
|
||||
|
||||
function addPoint(lon, lat, kind, mag) {
|
||||
if (ptCount >= MAX_PTS) {
|
||||
ptPositions.copyWithin(0, 256 * 3, ptCount * 3);
|
||||
ptKinds .copyWithin(0, 256, ptCount);
|
||||
ptT0s .copyWithin(0, 256, ptCount);
|
||||
ptMags .copyWithin(0, 256, ptCount);
|
||||
ptCount -= 256;
|
||||
}
|
||||
const r = kind === 2 ? 1.05 + mag * 0.3 : 1.01;
|
||||
const [x, y, z] = lonLatToVec3(lon, lat, r);
|
||||
const i = ptCount;
|
||||
ptPositions[i*3+0] = x;
|
||||
ptPositions[i*3+1] = y;
|
||||
ptPositions[i*3+2] = z;
|
||||
ptKinds[i] = kind;
|
||||
ptT0s[i] = performance.now();
|
||||
ptMags[i] = mag;
|
||||
ptCount++;
|
||||
geomDirty = true;
|
||||
}
|
||||
|
||||
window.onFeed("/data/usgs/event", (a) => addPoint(a[1], a[2], 0, Math.max(0, a[0])));
|
||||
window.onFeed("/data/blitzortung/strike", (a) => addPoint(a[1], a[0], 1, Math.min(10, a[3] || 1)));
|
||||
window.onFeed("/data/opensky/plane", (a) => addPoint(a[1], a[2], 2, (a[3] || 0) / 12000));
|
||||
|
||||
const ptGeo = new THREE.BufferGeometry();
|
||||
ptGeo.setAttribute("position", new THREE.BufferAttribute(ptPositions, 3));
|
||||
ptGeo.setAttribute("kind", new THREE.BufferAttribute(ptKinds, 1));
|
||||
ptGeo.setAttribute("t0", new THREE.BufferAttribute(ptT0s, 1));
|
||||
ptGeo.setAttribute("mag", new THREE.BufferAttribute(ptMags, 1));
|
||||
ptGeo.setDrawRange(0, 0);
|
||||
|
||||
const ptMat = new THREE.PointsNodeMaterial({
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const aKind = attribute("kind");
|
||||
const aT0 = attribute("t0");
|
||||
const aMag = attribute("mag");
|
||||
|
||||
const lifeNode = aKind.lessThan(0.5).select(float(8.0),
|
||||
aKind.lessThan(1.5).select(float(2.5), float(6.0)));
|
||||
const ageNode = uNow.sub(aT0).div(1000.0);
|
||||
const age01 = clamp(ageNode.div(lifeNode), 0.0, 1.0);
|
||||
|
||||
ptMat.sizeNode = Fn(() => {
|
||||
const base = aKind.lessThan(0.5).select(aMag.mul(6.0).add(8.0),
|
||||
aKind.lessThan(1.5).select(aMag.mul(0.6).add(4.0), float(3.0)));
|
||||
return base.mul(age01.mul(-0.5).add(1.0));
|
||||
})();
|
||||
|
||||
ptMat.colorNode = Fn(() => {
|
||||
const quakeCol = mix(vec3(1.0, 0.4, 0.0), vec3(1.0, 0.1, 0.0),
|
||||
clamp(aMag.div(8.0), 0.0, 1.0));
|
||||
const strikeCol = vec3(0.7, 0.9, 1.0).add(age01.mul(-0.4).add(0.4));
|
||||
const planeCol = vec3(0.3, 0.9, 0.7);
|
||||
const col = aKind.lessThan(0.5).select(quakeCol,
|
||||
aKind.lessThan(1.5).select(strikeCol, planeCol));
|
||||
const alpha = age01.mul(-1.0).add(1.0);
|
||||
return vec4(col, alpha);
|
||||
})();
|
||||
|
||||
const points = new THREE.Points(ptGeo, ptMat);
|
||||
scene.add(points);
|
||||
let geomDirty = false;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HUD update
|
||||
// ---------------------------------------------------------------------
|
||||
const aliveEl = document.getElementById("alive");
|
||||
const evtEl = document.getElementById("evt");
|
||||
const kpEl = document.getElementById("kp");
|
||||
const windEl = document.getElementById("wind");
|
||||
setInterval(() => {
|
||||
const a = window.feeds?.alive;
|
||||
aliveEl.textContent = a ? "ALIVE" : "DOWN";
|
||||
aliveEl.className = a ? "alive" : "dead";
|
||||
evtEl.textContent = window.feeds?.tick ?? 0;
|
||||
kpEl.textContent = (window.feeds?.swpc.kp ?? 0).toFixed(1);
|
||||
windEl.textContent = (window.feeds?.swpc.wind_speed ?? 0).toFixed(0);
|
||||
}, 250);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Render loop
|
||||
// ---------------------------------------------------------------------
|
||||
let t0Start = performance.now();
|
||||
function frame() {
|
||||
const now = performance.now();
|
||||
|
||||
// Sync uniforms <- feeds
|
||||
uKp.value = window.f.kp01();
|
||||
uWind.value = window.f.wind01();
|
||||
uBz.value = Math.max(-1, Math.min(1, (window.feeds?.swpc.bz ?? 0) / 20));
|
||||
uFlare.value = window.f.flarePulse();
|
||||
uRenew.value = window.f.renew();
|
||||
uDev.value = (window.feeds?.netz.dev ?? 0) * 50;
|
||||
uAlive.value = window.feeds?.alive ? 1.0 : 0.4;
|
||||
uNow.value = now;
|
||||
|
||||
// Auto-rotation pilotee par Kp (orages magnetiques accelerent)
|
||||
const t = (now - t0Start) / 1000;
|
||||
globe.rotation.y = t * 0.05 * (1 + window.f.kp01());
|
||||
globe.rotation.x = -0.3;
|
||||
|
||||
// GC particules age > 12 s
|
||||
if (ptCount > 0) {
|
||||
let writeI = 0;
|
||||
for (let i = 0; i < ptCount; i++) {
|
||||
if (now - ptT0s[i] < 12000) {
|
||||
if (writeI !== i) {
|
||||
ptPositions.copyWithin(writeI*3, i*3, i*3 + 3);
|
||||
ptKinds[writeI] = ptKinds[i];
|
||||
ptT0s[writeI] = ptT0s[i];
|
||||
ptMags[writeI] = ptMags[i];
|
||||
}
|
||||
writeI++;
|
||||
}
|
||||
}
|
||||
if (writeI !== ptCount) { ptCount = writeI; geomDirty = true; }
|
||||
}
|
||||
if (geomDirty) {
|
||||
ptGeo.setDrawRange(0, ptCount);
|
||||
ptGeo.attributes.position.needsUpdate = true;
|
||||
ptGeo.attributes.kind .needsUpdate = true;
|
||||
ptGeo.attributes.t0 .needsUpdate = true;
|
||||
ptGeo.attributes.mag .needsUpdate = true;
|
||||
geomDirty = false;
|
||||
}
|
||||
|
||||
renderer.autoClear = false;
|
||||
renderer.clear();
|
||||
renderer.render(bgScene, bgCam);
|
||||
renderer.render(scene, camera);
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>realart / globe (three.js + WebGPU TSL)</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; background: #000; color: #eee;
|
||||
font-family: ui-monospace, Menlo, monospace; overflow: hidden; }
|
||||
#c { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; }
|
||||
#hud {
|
||||
position: fixed; top: 0; left: 0; right: 0; padding: 0.6rem 1rem;
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
font-size: 0.7rem; color: #888; pointer-events: none; z-index: 10;
|
||||
}
|
||||
#hud .right { text-align: right; }
|
||||
#hud .v { color: #5b8bff; }
|
||||
#hud .alive { color: #38d977; }
|
||||
#hud .dead { color: #ff5151; }
|
||||
a { color: #888; text-decoration: none; pointer-events: auto; }
|
||||
a:hover { color: #5b8bff; }
|
||||
#renderer {
|
||||
position: fixed; bottom: 0.6rem; right: 0.8rem;
|
||||
font-size: 0.65rem; color: #555;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.171.0/build/three.webgpu.js",
|
||||
"three/tsl": "https://unpkg.com/three@0.171.0/build/three.tsl.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.171.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<div id="hud">
|
||||
<div>
|
||||
<a href="/">/ realart</a> · globe (three.js TSL)
|
||||
<br><span id="legend">quakes · strikes · flights · aurore</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
feeds <span id="alive">…</span> ·
|
||||
evt <span id="evt" class="v">0</span> ·
|
||||
kp <span id="kp" class="v">--</span> ·
|
||||
wind <span id="wind" class="v">--</span> km/s
|
||||
</div>
|
||||
</div>
|
||||
<div id="renderer">backend : <span id="backend">…</span></div>
|
||||
|
||||
<script src="/feeds_client.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
// =====================================================================
|
||||
// web_realart / server.js -- Bridge OSC <- data_feeds bridge.py vers
|
||||
// WS pour clients web (real.art.saillant.cc).
|
||||
//
|
||||
// Aucune dependance a SuperCollider : ce serveur ne fait QUE relayer
|
||||
// les /data/<source>/<sub> recus en OSC vers tous les clients WS.
|
||||
//
|
||||
// Architecture cible :
|
||||
//
|
||||
// data_feeds/bridge.py --OSC UDP 57124--> server.js --WS--> navigateurs
|
||||
// ^
|
||||
// |
|
||||
// Cloudflared tunnel
|
||||
// real.art.saillant.cc
|
||||
//
|
||||
// Variables d'environnement :
|
||||
// HTTP_PORT (defaut 4400)
|
||||
// DATA_PORT_IN (defaut 57124, ce que bridge.py envoie ici)
|
||||
// =====================================================================
|
||||
import express from "express";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import osc from "osc";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "4400", 10);
|
||||
const DATA_PORT_IN = parseInt(process.env.DATA_PORT_IN ?? "57124", 10);
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(join(__dirname, "public")));
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
clients: wss?.clients.size ?? 0,
|
||||
data_port: DATA_PORT_IN,
|
||||
last_event: lastEventAt,
|
||||
});
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
|
||||
|
||||
// derniere snapshot de chaque /data/<source>/<sub> -> envoye a chaque
|
||||
// nouveau client pour qu'il ne demarre pas a vide.
|
||||
const lastByAddress = new Map();
|
||||
let lastEventAt = 0;
|
||||
|
||||
function broadcast(msg) {
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === client.OPEN) client.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
console.log(`[ws] client connecte (total: ${wss.clients.size})`);
|
||||
// replay snapshot
|
||||
for (const [address, args] of lastByAddress) {
|
||||
ws.send(JSON.stringify({ address, args }));
|
||||
}
|
||||
ws.send(JSON.stringify({ address: "/sync/hello",
|
||||
args: [{ data_port: DATA_PORT_IN }] }));
|
||||
ws.on("close", () => console.log(`[ws] client deconnecte (total: ${wss.clients.size})`));
|
||||
});
|
||||
|
||||
const dataPort = new osc.UDPPort({
|
||||
localAddress: "0.0.0.0",
|
||||
localPort: DATA_PORT_IN,
|
||||
metadata: false,
|
||||
});
|
||||
|
||||
dataPort.on("ready", () => {
|
||||
console.log(`[data] ecoute :${DATA_PORT_IN} <- bridge.py`);
|
||||
});
|
||||
|
||||
dataPort.on("message", (oscMsg) => {
|
||||
lastEventAt = Date.now();
|
||||
if (oscMsg.address.startsWith("/data/")) {
|
||||
lastByAddress.set(oscMsg.address, oscMsg.args);
|
||||
}
|
||||
broadcast({ address: oscMsg.address, args: oscMsg.args });
|
||||
});
|
||||
|
||||
dataPort.on("error", (err) => console.error("[data] erreur:", err.message));
|
||||
dataPort.open();
|
||||
|
||||
httpServer.listen(HTTP_PORT, () => {
|
||||
console.log("");
|
||||
console.log("=== AV-Live realart ===");
|
||||
console.log(` Landing : http://localhost:${HTTP_PORT}/`);
|
||||
console.log(` WebGL : http://localhost:${HTTP_PORT}/webgl/`);
|
||||
console.log(` Audio : http://localhost:${HTTP_PORT}/audio/`);
|
||||
console.log(` Hydra : http://localhost:${HTTP_PORT}/hydra/`);
|
||||
console.log(` WS : ws://localhost:${HTTP_PORT}/ws`);
|
||||
console.log(` Health : http://localhost:${HTTP_PORT}/api/health`);
|
||||
console.log("");
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
console.log("\n[server] shutdown...");
|
||||
wss.close();
|
||||
dataPort.close();
|
||||
httpServer.close(() => process.exit(0));
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
Reference in New Issue
Block a user