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": {
|
"permissions": {
|
||||||
"allow": [
|
"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 '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
|
- `Espace` — manual glitch pulse
|
||||||
- `F1`–`F5` — fullscreen / GUI / postFx / autoglitch / reload shaders
|
- `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
|
### 📡 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
|
## Architecture
|
||||||
|
|
||||||
```
|
```text
|
||||||
┌────────────────────────────────┐
|
┌────────────────────────────────┐
|
||||||
│ data_feeds/bridge.py │
|
│ data_feeds/bridge.py │
|
||||||
│ ├─ usgs (HTTP 60 s) │
|
│ ├─ usgs (HTTP 60 s) │
|
||||||
@@ -75,6 +75,7 @@ complet.
|
|||||||
| `rte_eco2mix` | `mix` | 15 min |
|
| `rte_eco2mix` | `mix` | 15 min |
|
||||||
| `github` | `event` | 30 s |
|
| `github` | `event` | 30 s |
|
||||||
| `gcn` | `alert` | rare |
|
| `gcn` | `alert` | rare |
|
||||||
|
| `pose` | `count`, `person`, `skel`, `bone` | ~20 fps |
|
||||||
|
|
||||||
## Configuration
|
## 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` /
|
<https://data.rte-france.com/> puis renseigner `client_id` /
|
||||||
`client_secret`.
|
`client_secret`.
|
||||||
- `gcn` : <https://gcn.nasa.gov/quickstart> + `uv add gcn-kafka`.
|
- `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
|
## Diagnostic
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,35 @@
|
|||||||
targets = [
|
targets = [
|
||||||
{ host = "127.0.0.1", port = 57121 }, # SuperCollider
|
{ host = "127.0.0.1", port = 57121 }, # SuperCollider
|
||||||
{ host = "127.0.0.1", port = 57123 }, # openFrameworks
|
{ 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>/...
|
# Préfixe commun. Toutes les routes sont /data/<feed>/...
|
||||||
prefix = "/data"
|
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 ------------------------------------------------
|
# -- Sismique / géophysique ------------------------------------------------
|
||||||
|
|
||||||
[feeds.usgs]
|
[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",
|
"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]
|
[tool.uv]
|
||||||
package = false
|
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
|
# AVLiveLauncher
|
||||||
|
|
||||||
macOS menubar launcher for AV-Live. Starts and stops `sclang` (which
|
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
|
auto-loads `sound_algo/00_load.scd` and serves the web UI), the
|
||||||
`oscope-of` visualizer with one click each, and aggregates their logs in
|
`oscope-of` visualizer, and the `data_feeds` Python bridge (USGS,
|
||||||
one window.
|
SWPC, grid frequency, lightning, pose, etc. → OSC) with one click
|
||||||
|
each, and aggregates their logs in one window.
|
||||||
|
|
||||||
## Build
|
## 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 |
|
| `/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/sound_algo/00_load.scd` | Paths… → load file |
|
||||||
| `~/Documents/Projets/AV-Live/oscope-of/bin/oscope-of` | Paths… → oscope binary |
|
| `~/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
|
## 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`.
|
palette, and serves the web bridge on `:8080`.
|
||||||
- Spawns the `oscope-of` binary with cwd set to its parent directory
|
- Spawns the `oscope-of` binary with cwd set to its parent directory
|
||||||
(so oF can find `bin/data/`).
|
(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
|
stop: processManager.stopWeb
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ProcessRow(
|
||||||
|
title: "Data Feeds",
|
||||||
|
subtitle: "USGS · SWPC · Grid · Lightning · Pose · …",
|
||||||
|
isRunning: processManager.dataFeedsRunning,
|
||||||
|
start: processManager.startDataFeeds,
|
||||||
|
stop: processManager.stopDataFeeds
|
||||||
|
)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: processManager.openBrowser) {
|
Button(action: processManager.openBrowser) {
|
||||||
Label("Control", systemImage: "slider.horizontal.3")
|
Label("Control", systemImage: "slider.horizontal.3")
|
||||||
@@ -171,6 +179,11 @@ private struct SettingsView: View {
|
|||||||
get: { processManager.autoOpenBrowser },
|
get: { processManager.autoOpenBrowser },
|
||||||
set: { processManager.autoOpenBrowser = $0 }
|
set: { processManager.autoOpenBrowser = $0 }
|
||||||
))
|
))
|
||||||
|
Toggle("Auto-start data_feeds bridge (USGS, SWPC, grid, pose…)",
|
||||||
|
isOn: Binding(
|
||||||
|
get: { processManager.autoStartDataFeeds },
|
||||||
|
set: { processManager.autoStartDataFeeds = $0 }
|
||||||
|
))
|
||||||
Divider()
|
Divider()
|
||||||
Text("Paths").font(.headline)
|
Text("Paths").font(.headline)
|
||||||
PathField(
|
PathField(
|
||||||
@@ -213,6 +226,22 @@ private struct SettingsView: View {
|
|||||||
),
|
),
|
||||||
isDirectory: false
|
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 {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Done", action: dismiss).keyboardShortcut(.defaultAction)
|
Button("Done", action: dismiss).keyboardShortcut(.defaultAction)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class ProcessManager: ObservableObject {
|
|||||||
@Published var sclangRunning = false
|
@Published var sclangRunning = false
|
||||||
@Published var oscopeRunning = false
|
@Published var oscopeRunning = false
|
||||||
@Published var webRunning = false
|
@Published var webRunning = false
|
||||||
|
@Published var dataFeedsRunning = false
|
||||||
@Published private(set) var logs: [LogLine] = []
|
@Published private(set) var logs: [LogLine] = []
|
||||||
|
|
||||||
// Persisted paths (UserDefaults, key/value)
|
// 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 nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
|
||||||
@Published var webServerScript: String { didSet { defaults.set(webServerScript, forKey: "webServerScript") } }
|
@Published var webServerScript: String { didSet { defaults.set(webServerScript, forKey: "webServerScript") } }
|
||||||
@Published var webPort: Int { didSet { defaults.set(webPort, forKey: "webPort") } }
|
@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 autoStart: Bool { didSet { defaults.set(autoStart, forKey: "autoStart") } }
|
||||||
@Published var autoOpenBrowser: Bool { didSet { defaults.set(autoOpenBrowser, forKey: "autoOpenBrowser") } }
|
@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 let defaults = UserDefaults.standard
|
||||||
private var sclangProc: Process?
|
private var sclangProc: Process?
|
||||||
private var oscopeProc: Process?
|
private var oscopeProc: Process?
|
||||||
private var webProc: Process?
|
private var webProc: Process?
|
||||||
|
private var dataFeedsProc: Process?
|
||||||
|
private var dataFeedsWantsRestart = false
|
||||||
private var sclangWantsRestart = false
|
private var sclangWantsRestart = false
|
||||||
let osc = OSCSender(host: "127.0.0.1", port: 57121)
|
let osc = OSCSender(host: "127.0.0.1", port: 57121)
|
||||||
private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log")
|
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
|
webPort = (defaults.object(forKey: "webPort") as? Int) ?? 3000
|
||||||
autoStart = (defaults.object(forKey: "autoStart") as? Bool) ?? true
|
autoStart = (defaults.object(forKey: "autoStart") as? Bool) ?? true
|
||||||
autoOpenBrowser = (defaults.object(forKey: "autoOpenBrowser") 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
|
/// Start everything that's currently stopped. Used by AppDelegate on
|
||||||
@@ -117,6 +134,12 @@ final class ProcessManager: ObservableObject {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if !self.webRunning { self.startWeb() }
|
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 {
|
if autoOpenBrowser {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -394,12 +417,77 @@ final class ProcessManager: ObservableObject {
|
|||||||
return URL(fileURLWithPath: binaryPath).deletingLastPathComponent()
|
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
|
// MARK: - utilities
|
||||||
|
|
||||||
func stopAll() {
|
func stopAll() {
|
||||||
sclangProc?.terminate()
|
sclangProc?.terminate()
|
||||||
oscopeProc?.terminate()
|
oscopeProc?.terminate()
|
||||||
webProc?.terminate()
|
webProc?.terminate()
|
||||||
|
dataFeedsProc?.terminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearLogs() {
|
func clearLogs() {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
ofxOsc
|
ofxOsc
|
||||||
ofxGui
|
ofxGui
|
||||||
|
ofxOpenCv
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ void ofApp::setup() {
|
|||||||
spectro_ = std::make_unique<oscope::SpectrogramVis>();
|
spectro_ = std::make_unique<oscope::SpectrogramVis>();
|
||||||
reactive_ = std::make_unique<oscope::ReactiveVis>();
|
reactive_ = std::make_unique<oscope::ReactiveVis>();
|
||||||
waveform_ = std::make_unique<oscope::WaveformVis>();
|
waveform_ = std::make_unique<oscope::WaveformVis>();
|
||||||
|
webcam_ = std::make_unique<oscope::WebcamVis>();
|
||||||
polar_ = std::make_unique<oscope::PolarVis>();
|
polar_ = std::make_unique<oscope::PolarVis>();
|
||||||
plasma_ = std::make_unique<oscope::PlasmaVis>();
|
plasma_ = std::make_unique<oscope::PlasmaVis>();
|
||||||
particles_ = std::make_unique<oscope::ParticleVis>();
|
particles_ = std::make_unique<oscope::ParticleVis>();
|
||||||
@@ -157,6 +158,7 @@ void ofApp::setup() {
|
|||||||
spectro_->setup(W, H / 4);
|
spectro_->setup(W, H / 4);
|
||||||
reactive_->setup(W, H);
|
reactive_->setup(W, H);
|
||||||
waveform_->setup(W, H);
|
waveform_->setup(W, H);
|
||||||
|
webcam_->setup(W, H);
|
||||||
polar_->setup(W, H);
|
polar_->setup(W, H);
|
||||||
plasma_->setup(W, H);
|
plasma_->setup(W, H);
|
||||||
particles_->setup(W, H);
|
particles_->setup(W, H);
|
||||||
@@ -385,6 +387,7 @@ void ofApp::update() {
|
|||||||
spectro_->update(frame);
|
spectro_->update(frame);
|
||||||
reactive_->update(frame);
|
reactive_->update(frame);
|
||||||
waveform_->update(frame);
|
waveform_->update(frame);
|
||||||
|
webcam_->update(frame);
|
||||||
polar_->update(frame);
|
polar_->update(frame);
|
||||||
plasma_->update(frame);
|
plasma_->update(frame);
|
||||||
particles_->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::ImgIrix: imgIrix_->draw(0, 0, W, H); break;
|
||||||
case BgKind::ImgZX: imgZX_->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::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.
|
// 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 'i': liveBg_ = BgKind::Mode7; liveBgOverride_=true; break;
|
||||||
case 'o': liveBg_ = BgKind::Octahedron; liveBgOverride_=true; break;
|
case 'o': liveBg_ = BgKind::Octahedron; liveBgOverride_=true; break;
|
||||||
case 'p': liveBg_ = BgKind::PlasmaC64; 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) ---
|
// --- 17 paramètres FX (middle + bottom row) ---
|
||||||
// Tunnel mults (multiplicatifs ×0.83/×1.20, clamp 0.05..12)
|
// Tunnel mults (multiplicatifs ×0.83/×1.20, clamp 0.05..12)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
#include "visualizers/ModelVis.h"
|
#include "visualizers/ModelVis.h"
|
||||||
#include "visualizers/SphereWaveVis.h"
|
#include "visualizers/SphereWaveVis.h"
|
||||||
#include "visualizers/ImageVis.h"
|
#include "visualizers/ImageVis.h"
|
||||||
|
#include "visualizers/WebcamVis.h"
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
@@ -108,7 +109,8 @@ private:
|
|||||||
SphereWave,
|
SphereWave,
|
||||||
ImgWin1, ImgWin311, ImgWin95, ImgLotus, ImgDos,
|
ImgWin1, ImgWin311, ImgWin95, ImgLotus, ImgDos,
|
||||||
ImgAtari, ImgApple, ImgWB, ImgNeXT, ImgBeOS,
|
ImgAtari, ImgApple, ImgWB, ImgNeXT, ImgBeOS,
|
||||||
ImgOS2, ImgIrix, ImgZX, ImgC64 };
|
ImgOS2, ImgIrix, ImgZX, ImgC64,
|
||||||
|
Webcam };
|
||||||
struct DemoScene {
|
struct DemoScene {
|
||||||
const char* name;
|
const char* name;
|
||||||
float durSec;
|
float durSec;
|
||||||
@@ -227,6 +229,8 @@ private:
|
|||||||
std::unique_ptr<oscope::ImageVis> imgLotus_, imgDos_, imgAtari_, imgApple_;
|
std::unique_ptr<oscope::ImageVis> imgLotus_, imgDos_, imgAtari_, imgApple_;
|
||||||
std::unique_ptr<oscope::ImageVis> imgWB_, imgNeXT_, imgBeOS_, imgOS2_;
|
std::unique_ptr<oscope::ImageVis> imgWB_, imgNeXT_, imgBeOS_, imgOS2_;
|
||||||
std::unique_ptr<oscope::ImageVis> imgIrix_, imgZX_, imgC64_;
|
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_;
|
std::vector<float> ch1_, ch2_;
|
||||||
Mode mode_ = Mode::Scope4;
|
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;
|
"*** 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
|
// 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
|
// [9] ARRET TOTAL
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -215,4 +286,5 @@ s.sync;
|
|||||||
~cavityStop !? { ~cavityStop.value };
|
~cavityStop !? { ~cavityStop.value };
|
||||||
~mixStop !? { ~mixStop.value };
|
~mixStop !? { ~mixStop.value };
|
||||||
~geoStop !? { ~geoStop.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.rms = { master: 0 };
|
||||||
window.hydraParams = { intensity: 1, hueShift: 0, speed: 1, density: 1, feedback: 0.5 };
|
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 wsState = document.getElementById("ws-state");
|
||||||
const bpmDisplay = document.getElementById("bpm");
|
const bpmDisplay = document.getElementById("bpm");
|
||||||
const beatDisplay = document.getElementById("beat");
|
const beatDisplay = document.getElementById("beat");
|
||||||
@@ -85,6 +113,8 @@ function connect() {
|
|||||||
codeArea.value = code;
|
codeArea.value = code;
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
|
} else if (addr.startsWith("/data/")) {
|
||||||
|
ingestDataFeed(addr, msg.args);
|
||||||
} else if (addr === "/hydra/param") {
|
} else if (addr === "/hydra/param") {
|
||||||
const [name, val] = msg.args;
|
const [name, val] = msg.args;
|
||||||
if (name in window.hydraParams) {
|
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 ----------------------------------------------
|
// ---------- Hydra setup ----------------------------------------------
|
||||||
const canvas = document.getElementById("hydra-canvas");
|
const canvas = document.getElementById("hydra-canvas");
|
||||||
canvas.width = window.innerWidth;
|
canvas.width = window.innerWidth;
|
||||||
@@ -304,6 +433,96 @@ voronoi(20, 0.1, 0.5)
|
|||||||
.out()
|
.out()
|
||||||
`.trim(),
|
`.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: `
|
||||||
// Cosmos : multi-osc layered + rotate
|
// Cosmos : multi-osc layered + rotate
|
||||||
osc(5, 0.05, 1)
|
osc(5, 0.05, 1)
|
||||||
|
|||||||
@@ -131,6 +131,13 @@
|
|||||||
<button data-preset="grid">grid</button>
|
<button data-preset="grid">grid</button>
|
||||||
<button data-preset="warp">warp</button>
|
<button data-preset="warp">warp</button>
|
||||||
<button data-preset="pixel">pixel</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>
|
<button id="run">run [Ctrl+Enter]</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="status"></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_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_OUT = parseInt(process.env.SC_PORT_OUT ?? "57121", 10); // -> SC
|
||||||
const SC_PORT_IN = parseInt(process.env.SC_PORT_IN ?? "57122", 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/
|
// HTTP : Express sert public/
|
||||||
@@ -130,6 +131,32 @@ oscPort.on("error", (err) => {
|
|||||||
|
|
||||||
oscPort.open();
|
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) {
|
function sendToSC(address, args) {
|
||||||
const payload = args.map((v) => {
|
const payload = args.map((v) => {
|
||||||
if (typeof v === "number") {
|
if (typeof v === "number") {
|
||||||
@@ -165,6 +192,7 @@ function shutdown() {
|
|||||||
console.log("\n[server] shutdown...");
|
console.log("\n[server] shutdown...");
|
||||||
wss.close();
|
wss.close();
|
||||||
oscPort.close();
|
oscPort.close();
|
||||||
|
dataPort.close();
|
||||||
httpServer.close(() => process.exit(0));
|
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