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:
L'électron rare
2026-05-11 07:21:04 +02:00
parent 0d189a2139
commit 02de5302c9
52 changed files with 4091 additions and 7 deletions
+5 -1
View File
@@ -2,7 +2,11 @@
"permissions": {
"allow": [
"Bash(awk 'BEGIN{p=0;b=0;tlb=0;depth=0} /^\\\\\\(/{if\\(depth==0\\)tlb++; depth++} {for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b \" TLB:\" tlb}' sound_algo/control/data_feeds.scd)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/examples/16_data_feeds.scd sound_algo/control/data_feeds.scd)"
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/examples/16_data_feeds.scd sound_algo/control/data_feeds.scd)",
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/control/data_feeds.scd)",
"Bash(find . -name \"*.yml\" -path \"*workflows*\" 2>/dev/null *)",
"Read(//Applications/**)",
"Bash(swift build *)"
]
}
}
+10
View File
@@ -78,6 +78,16 @@ Each act lasts 12-55 s, transitions between acts trigger flash + glitch postfx +
- `Espace` — manual glitch pulse
- `F1``F5` — fullscreen / GUI / postFx / autoglitch / reload shaders
### 🌐 Real-world data feeds — `data_feeds/` + `web_realart/`
- **9 live sources** ingested by `data_feeds/bridge.py` and broadcast as OSC `/data/<source>/<sub>` to SC (`:57121`), oF (`:57123`) and the web bridge (`:57124`) :
USGS quakes · NOAA SWPC (solar wind, Bz IMF, Kp, X-ray flares) · Mainsfrequenz.de · RTE eCO2mix · Blitzortung lightning · OpenSky ADS-B · Bluesky firehose · Bitcoin mempool · GitHub events · GCN astrophysics · YOLOv8 webcam pose
- **SC presets** `sound_algo/examples/16_data_feeds.scd` and `17_data_feeds_more.scd` map each source to synthesis : Schumann cavity drone (foudre), aurora additive pad (Bz/wind/Kp), Netzfrequenz pulse kick, RTE 8-op carbon FM, OpenSky granular swarm
- **Web standalone** `web_realart/` ports the visualizers and synths to the browser for `real.art.saillant.cc` :
- **WebGPU + three.js TSL** globe with quake/strike/flight particles (auto-fallback WebGL2)
- **Web Audio** ports of 5 SC SynthDef presets (cavity, mix, geo, aurora, pulse)
- **Hydra** with 7 data-driven patches (aurora, quake, lightning, flightmap, gridpulse, solarwind, bskyrain)
- All three layers share `feeds_client.js` (window.feeds) over one WebSocket
### 📡 Audio reactivity pipeline
```
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
.uv-cache/
uv.lock
+6 -1
View File
@@ -8,7 +8,7 @@ et les rebalance en OSC vers SuperCollider (`:57121`) et openFrameworks
## Architecture
```
```text
┌────────────────────────────────┐
│ data_feeds/bridge.py │
│ ├─ usgs (HTTP 60 s) │
@@ -75,6 +75,7 @@ complet.
| `rte_eco2mix` | `mix` | 15 min |
| `github` | `event` | 30 s |
| `gcn` | `alert` | rare |
| `pose` | `count`, `person`, `skel`, `bone` | ~20 fps |
## Configuration
@@ -92,6 +93,10 @@ Flux nécessitant des identifiants (désactivés par défaut) :
<https://data.rte-france.com/> puis renseigner `client_id` /
`client_secret`.
- `gcn` : <https://gcn.nasa.gov/quickstart> + `uv add gcn-kafka`.
- `pose` : install les deps optionnelles avec `uv sync --extra pose`
(opencv-python + ultralytics). Sur Mac M5 utiliser `device = "mps"`.
Une seule app peut grabber la webcam : si oF tourne `WebcamVis` en
capture locale, mettre `feeds.pose.enabled = false` (et inversement).
## Diagnostic
+25
View File
@@ -11,10 +11,35 @@
targets = [
{ host = "127.0.0.1", port = 57121 }, # SuperCollider
{ host = "127.0.0.1", port = 57123 }, # openFrameworks
{ host = "127.0.0.1", port = 57124 }, # web bridge (sound_algo/web + web_realart)
]
# Préfixe commun. Toutes les routes sont /data/<feed>/...
prefix = "/data"
# -- Pose / webcam --------------------------------------------------------
[feeds.pose]
enabled = true
# YOLOv8/v11-pose via ultralytics. Auto-download du .pt au premier run.
# Modeles : yolov8n-pose (fast), yolov8s-pose, yolov8m-pose, yolov8l-pose.
# Sur Mac M5 prefere `n` ou `s`, device="mps" (Metal).
model = "yolov8n-pose.pt"
device = "mps" # "cpu", "mps" (Apple Silicon), "cuda:0"
camera = 0 # index ofVideoGrabber-style (0 = camera par defaut)
width = 640
height = 480
target_fps = 20 # plafond (le serveur peut faire moins)
conf_thresh = 0.35
max_persons = 4
# Si false, n'emit que `/data/pose/count` et les bbox (pas les 17 kp).
emit_keypoints = true
# Routes :
# /data/pose/count <n>
# /data/pose/person <idx> <cx> <cy> <w> <h> <conf> (normalises 0..1)
# /data/pose/skel <idx> <conf_avg> <x0 y0 c0 ... x16 y16 c16> (17 kp COCO)
# /data/pose/bone <idx> <kp_a> <kp_b> (segments du skeleton)
# -- Sismique / géophysique ------------------------------------------------
[feeds.usgs]
+158
View File
@@ -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()
+7
View File
@@ -12,5 +12,12 @@ dependencies = [
"skyfield>=1.49",
]
[project.optional-dependencies]
pose = [
"opencv-python>=4.10",
"ultralytics>=8.3", # YOLOv8/v11-pose (CoreML/MPS sur Apple Silicon)
"numpy>=1.26",
]
[tool.uv]
package = false
+151
View File
@@ -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 300800 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
View File
@@ -1,9 +1,10 @@
# AVLiveLauncher
macOS menubar launcher for AV-Live. Starts and stops `sclang` (which
auto-loads `sound_algo/00_load.scd` and serves the web UI) and the
`oscope-of` visualizer with one click each, and aggregates their logs in
one window.
auto-loads `sound_algo/00_load.scd` and serves the web UI), the
`oscope-of` visualizer, and the `data_feeds` Python bridge (USGS,
SWPC, grid frequency, lightning, pose, etc. → OSC) with one click
each, and aggregates their logs in one window.
## Build
@@ -27,6 +28,8 @@ Three paths are stored in `UserDefaults` (`~/Library/Preferences/cc.saillant.AVL
| `/Applications/SuperCollider.app/Contents/MacOS/sclang` | Paths… → sclang binary |
| `~/Documents/Projets/AV-Live/sound_algo/00_load.scd` | Paths… → load file |
| `~/Documents/Projets/AV-Live/oscope-of/bin/oscope-of` | Paths… → oscope binary |
| `/opt/homebrew/bin/uv` (or `~/.local/bin/uv`) | Paths… → uv binary |
| `~/Documents/Projets/AV-Live/data_feeds` | Paths… → data_feeds directory |
## What it does
@@ -35,4 +38,7 @@ Three paths are stored in `UserDefaults` (`~/Library/Preferences/cc.saillant.AVL
palette, and serves the web bridge on `:8080`.
- Spawns the `oscope-of` binary with cwd set to its parent directory
(so oF can find `bin/data/`).
- Quitting the menubar app sends `SIGTERM` to both children.
- Spawns `uv run python bridge.py -v` in `data_feeds/` for the real-world
flux → OSC bridge. Off by default (Auto-start data_feeds toggle in
Settings to enable). uv auto-syncs the venv on first run.
- Quitting the menubar app sends `SIGTERM` to all children.
@@ -36,6 +36,14 @@ struct MenuBarContent: View {
stop: processManager.stopWeb
)
ProcessRow(
title: "Data Feeds",
subtitle: "USGS · SWPC · Grid · Lightning · Pose · …",
isRunning: processManager.dataFeedsRunning,
start: processManager.startDataFeeds,
stop: processManager.stopDataFeeds
)
HStack {
Button(action: processManager.openBrowser) {
Label("Control", systemImage: "slider.horizontal.3")
@@ -171,6 +179,11 @@ private struct SettingsView: View {
get: { processManager.autoOpenBrowser },
set: { processManager.autoOpenBrowser = $0 }
))
Toggle("Auto-start data_feeds bridge (USGS, SWPC, grid, pose…)",
isOn: Binding(
get: { processManager.autoStartDataFeeds },
set: { processManager.autoStartDataFeeds = $0 }
))
Divider()
Text("Paths").font(.headline)
PathField(
@@ -213,6 +226,22 @@ private struct SettingsView: View {
),
isDirectory: false
)
PathField(
label: "uv binary",
path: Binding(
get: { processManager.uvPath },
set: { processManager.uvPath = $0 }
),
isDirectory: false
)
PathField(
label: "data_feeds directory",
path: Binding(
get: { processManager.dataFeedsDir },
set: { processManager.dataFeedsDir = $0 }
),
isDirectory: true
)
HStack {
Spacer()
Button("Done", action: dismiss).keyboardShortcut(.defaultAction)
@@ -14,6 +14,7 @@ final class ProcessManager: ObservableObject {
@Published var sclangRunning = false
@Published var oscopeRunning = false
@Published var webRunning = false
@Published var dataFeedsRunning = false
@Published private(set) var logs: [LogLine] = []
// Persisted paths (UserDefaults, key/value)
@@ -23,13 +24,18 @@ final class ProcessManager: ObservableObject {
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
@Published var webServerScript: String { didSet { defaults.set(webServerScript, forKey: "webServerScript") } }
@Published var webPort: Int { didSet { defaults.set(webPort, forKey: "webPort") } }
@Published var uvPath: String { didSet { defaults.set(uvPath, forKey: "uvPath") } }
@Published var dataFeedsDir: String { didSet { defaults.set(dataFeedsDir, forKey: "dataFeedsDir") } }
@Published var autoStart: Bool { didSet { defaults.set(autoStart, forKey: "autoStart") } }
@Published var autoOpenBrowser: Bool { didSet { defaults.set(autoOpenBrowser, forKey: "autoOpenBrowser") } }
@Published var autoStartDataFeeds: Bool { didSet { defaults.set(autoStartDataFeeds, forKey: "autoStartDataFeeds") } }
private let defaults = UserDefaults.standard
private var sclangProc: Process?
private var oscopeProc: Process?
private var webProc: Process?
private var dataFeedsProc: Process?
private var dataFeedsWantsRestart = false
private var sclangWantsRestart = false
let osc = OSCSender(host: "127.0.0.1", port: 57121)
private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log")
@@ -101,6 +107,17 @@ final class ProcessManager: ObservableObject {
webPort = (defaults.object(forKey: "webPort") as? Int) ?? 3000
autoStart = (defaults.object(forKey: "autoStart") as? Bool) ?? true
autoOpenBrowser = (defaults.object(forKey: "autoOpenBrowser") as? Bool) ?? true
// uv (Astral) usually installed via curl|sh or brew. Use ~/.local/bin
// for the curl installer, /opt/homebrew/bin for Apple Silicon brew.
let uvCandidates = ["/opt/homebrew/bin/uv", "/usr/local/bin/uv",
"\(home)/.local/bin/uv", "/usr/bin/uv"]
uvPath = defaults.string(forKey: "uvPath")
?? (uvCandidates.first(where: { fm.isExecutableFile(atPath: $0) })
?? "/opt/homebrew/bin/uv")
dataFeedsDir = defaults.string(forKey: "dataFeedsDir")
?? "\(avLive)/data_feeds"
autoStartDataFeeds = (defaults.object(forKey: "autoStartDataFeeds") as? Bool) ?? false
}
/// Start everything that's currently stopped. Used by AppDelegate on
@@ -117,6 +134,12 @@ final class ProcessManager: ObservableObject {
guard let self = self else { return }
if !self.webRunning { self.startWeb() }
}
if autoStartDataFeeds {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
guard let self = self else { return }
if !self.dataFeedsRunning { self.startDataFeeds() }
}
}
if autoOpenBrowser {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
guard let self = self else { return }
@@ -394,12 +417,77 @@ final class ProcessManager: ObservableObject {
return URL(fileURLWithPath: binaryPath).deletingLastPathComponent()
}
// MARK: - data_feeds bridge (Python OSC)
/// Lance `uv run python bridge.py -v` depuis `dataFeedsDir`. uv s'occupe
/// de creer/sync le venv au premier run. Le pont diffuse sur :57121
/// (sclang) et :57123 (oF) selon config.toml.
func startDataFeeds() {
guard dataFeedsProc == nil else { return }
guard FileManager.default.isExecutableFile(atPath: uvPath) else {
append(source: "launcher", text: "uv not found at \(uvPath) — install with: curl -LsSf https://astral.sh/uv/install.sh | sh")
return
}
let bridgePy = dataFeedsDir + "/bridge.py"
guard FileManager.default.fileExists(atPath: bridgePy) else {
append(source: "launcher", text: "bridge.py not found at \(bridgePy)")
return
}
let p = Process()
p.executableURL = URL(fileURLWithPath: uvPath)
p.arguments = ["run", "python", "bridge.py", "-v"]
p.currentDirectoryURL = URL(fileURLWithPath: dataFeedsDir)
// uv resout les binaries depuis ~/.local/bin et /opt/homebrew/bin
var env = ProcessInfo.processInfo.environment
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
+ ":/usr/local/bin:/opt/homebrew/bin:\(env["HOME"] ?? "")/.local/bin"
// Force la couleur off pour des logs propres dans la TextView
env["NO_COLOR"] = "1"
p.environment = env
attach(process: p, label: "feeds")
do {
try p.run()
dataFeedsProc = p
DispatchQueue.main.async { self.dataFeedsRunning = true }
p.terminationHandler = { [weak self] proc in
self?.append(source: "feeds", text: "exited with status \(proc.terminationStatus)")
DispatchQueue.main.async {
self?.dataFeedsProc = nil
self?.dataFeedsRunning = false
if self?.dataFeedsWantsRestart == true {
self?.dataFeedsWantsRestart = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self?.startDataFeeds()
}
}
}
}
append(source: "launcher", text: "started data_feeds bridge (\(dataFeedsDir))")
} catch {
append(source: "launcher", text: "failed to start data_feeds: \(error)")
}
}
func stopDataFeeds() {
dataFeedsWantsRestart = false
dataFeedsProc?.terminate()
}
func restartDataFeeds() {
guard dataFeedsProc != nil else { startDataFeeds(); return }
append(source: "launcher", text: "restarting data_feeds…")
dataFeedsWantsRestart = true
dataFeedsProc?.terminate()
dataFeedsWantsRestart = true
}
// MARK: - utilities
func stopAll() {
sclangProc?.terminate()
oscopeProc?.terminate()
webProc?.terminate()
dataFeedsProc?.terminate()
}
func clearLogs() {
+1
View File
@@ -1,2 +1,3 @@
ofxOsc
ofxGui
ofxOpenCv
+6
View File
@@ -74,6 +74,7 @@ void ofApp::setup() {
spectro_ = std::make_unique<oscope::SpectrogramVis>();
reactive_ = std::make_unique<oscope::ReactiveVis>();
waveform_ = std::make_unique<oscope::WaveformVis>();
webcam_ = std::make_unique<oscope::WebcamVis>();
polar_ = std::make_unique<oscope::PolarVis>();
plasma_ = std::make_unique<oscope::PlasmaVis>();
particles_ = std::make_unique<oscope::ParticleVis>();
@@ -157,6 +158,7 @@ void ofApp::setup() {
spectro_->setup(W, H / 4);
reactive_->setup(W, H);
waveform_->setup(W, H);
webcam_->setup(W, H);
polar_->setup(W, H);
plasma_->setup(W, H);
particles_->setup(W, H);
@@ -385,6 +387,7 @@ void ofApp::update() {
spectro_->update(frame);
reactive_->update(frame);
waveform_->update(frame);
webcam_->update(frame);
polar_->update(frame);
plasma_->update(frame);
particles_->update(frame);
@@ -1354,6 +1357,7 @@ void ofApp::drawScope4(int W, int H) {
case BgKind::ImgIrix: imgIrix_->draw(0, 0, W, H); break;
case BgKind::ImgZX: imgZX_->draw(0, 0, W, H); break;
case BgKind::ImgC64: imgC64_->draw(0, 0, W, H); break;
case BgKind::Webcam: webcam_->draw(0, 0, W, H); break;
}
// 1bis) HUD pseudo-aléatoire de valeurs sub-10 Hz.
@@ -1663,6 +1667,8 @@ void ofApp::keyPressed(int key) {
case 'i': liveBg_ = BgKind::Mode7; liveBgOverride_=true; break;
case 'o': liveBg_ = BgKind::Octahedron; liveBgOverride_=true; break;
case 'p': liveBg_ = BgKind::PlasmaC64; liveBgOverride_=true; break;
// Webcam + ofxOpenCv + overlay pose (touche '&' = AZERTY shift+1).
case '&': liveBg_ = BgKind::Webcam; liveBgOverride_=true; break;
// --- 17 paramètres FX (middle + bottom row) ---
// Tunnel mults (multiplicatifs ×0.83/×1.20, clamp 0.05..12)
+5 -1
View File
@@ -29,6 +29,7 @@
#include "visualizers/ModelVis.h"
#include "visualizers/SphereWaveVis.h"
#include "visualizers/ImageVis.h"
#include "visualizers/WebcamVis.h"
#include <memory>
@@ -108,7 +109,8 @@ private:
SphereWave,
ImgWin1, ImgWin311, ImgWin95, ImgLotus, ImgDos,
ImgAtari, ImgApple, ImgWB, ImgNeXT, ImgBeOS,
ImgOS2, ImgIrix, ImgZX, ImgC64 };
ImgOS2, ImgIrix, ImgZX, ImgC64,
Webcam };
struct DemoScene {
const char* name;
float durSec;
@@ -227,6 +229,8 @@ private:
std::unique_ptr<oscope::ImageVis> imgLotus_, imgDos_, imgAtari_, imgApple_;
std::unique_ptr<oscope::ImageVis> imgWB_, imgNeXT_, imgBeOS_, imgOS2_;
std::unique_ptr<oscope::ImageVis> imgIrix_, imgZX_, imgC64_;
// Webcam + ofxOpenCv + overlay du skeleton OSC (/data/pose/*).
std::unique_ptr<oscope::WebcamVis> webcam_;
std::vector<float> ch1_, ch2_;
Mode mode_ = Mode::Scope4;
+130
View File
@@ -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
+73
View File
@@ -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
+57
View File
@@ -174,6 +174,63 @@
"*** GCN alert ***".postln; msg.postln;
});
// =====================================================================
// POSE -- webcam + YOLOv8-pose (17 keypoints COCO)
// =====================================================================
// Layout COCO :
// 0 nose, 1/2 eyes L/R, 3/4 ears L/R,
// 5/6 shoulders L/R, 7/8 elbows L/R, 9/10 wrists L/R,
// 11/12 hips L/R, 13/14 knees L/R, 15/16 ankles L/R
~poseKpNames = [\nose, \eye_l, \eye_r, \ear_l, \ear_r,
\sho_l, \sho_r, \elb_l, \elb_r, \wri_l, \wri_r,
\hip_l, \hip_r, \kne_l, \kne_r, \ank_l, \ank_r];
~_feedDef.(\d_pose_count, '/data/pose/count', { |msg|
~feedSet.(\pose_count, msg[1]);
});
~_feedDef.(\d_pose_person, '/data/pose/person', { |msg|
var idx = msg[1].asInteger;
var persons = ~feeds[\pose_persons] ? IdentityDictionary.new;
persons[idx] = (cx: msg[2], cy: msg[3], w: msg[4], h: msg[5], conf: msg[6]);
~feedSet.(\pose_persons, persons);
});
~_feedDef.(\d_pose_skel, '/data/pose/skel', { |msg|
var idx = msg[1].asInteger;
var avg = msg[2];
// msg[3..] = [x0,y0,c0, x1,y1,c1, ..., x16,y16,c16] (51 floats)
var flat = msg.copyRange(3, msg.size - 1);
var skel = Array.new(17);
17.do { |k|
var off = k * 3;
skel = skel.add((
x: flat[off],
y: flat[off + 1],
c: flat[off + 2],
name: ~poseKpNames[k]
));
};
var all = ~feeds[\pose_skels] ? IdentityDictionary.new;
all[idx] = (avg: avg, kp: skel);
~feedSet.(\pose_skels, all);
// raccourci : premier sujet
if(idx == 0) { ~feedSet.(\pose_skel0, skel) };
});
~_feedDef.(\d_pose_bone, '/data/pose/bone', { |msg|
// Annonce du squelette a la connexion. On stocke la liste des paires.
var bones = ~feeds[\pose_bones] ? Array.new;
var pair = [msg[1].asInteger, msg[2].asInteger];
if(bones.indexOfEqual(pair).isNil) {
~feedSet.(\pose_bones, bones.add(pair));
};
});
// Helper : lit le keypoint `name` du sujet 0. Renvoie (x:, y:, c:).
~poseKp = { |name|
var skel = ~feeds[\pose_skel0];
var i = ~poseKpNames.indexOf(name);
if(skel.notNil and: { i.notNil }) { skel[i] } { nil }
};
// =====================================================================
// Heartbeat -- detection de pont down
// =====================================================================
+72
View File
@@ -208,6 +208,77 @@ s.sync;
)
// ---------------------------------------------------------------------
// [4] PRESET D -- "Body"
// Corps detecte (YOLOv8-pose) pilote 4 synths :
// - poignet droit -> filtre passe-bas (X hor) + amp (Y vert)
// - poignet gauche -> pitch d'un drone
// - hauteur des epaules -> seuil de granulateur
// - confiance moyenne -> reverb wet
// ---------------------------------------------------------------------
(
SynthDef(\body_drone, { |out=0, freq=80, amp=0.25, cutoff=800, q=0.4, pan=0|
var sig = Saw.ar(freq * [1, 1.005, 0.503]);
sig = RLPF.ar(sig, cutoff.lag(0.05).clip(80, 8000), q);
sig = sig.sum * amp;
Out.ar(out, Pan2.ar(sig, pan));
}).add;
SynthDef(\body_gran, { |out=0, dens=10, rate=1.0, amp=0.3|
var trig = Impulse.kr(dens);
var grain = SinOsc.ar(
TRand.kr(200, 1200, trig) * rate.lag(0.1),
0,
EnvGen.kr(Env.perc(0.005, 0.05), trig)
);
Out.ar(out, Pan2.ar(grain * amp, TRand.kr(-1, 1, trig)));
}).add;
s.sync;
~bodyDrone = Synth(\body_drone, [\amp, 0.2]);
~bodyGran = Synth(\body_gran, [\amp, 0.15]);
~bodyR = Routine({
inf.do {
var wr = ~poseKp.(\wri_r);
var wl = ~poseKp.(\wri_l);
var sl = ~poseKp.(\sho_l), sR = ~poseKp.(\sho_r);
var avg, shoY;
if(wr.notNil and: { wr.c > 0.3 }) {
~bodyDrone.set(\cutoff, wr.x.linexp(0, 1, 200, 6000));
~bodyDrone.set(\amp, (1 - wr.y).linlin(0, 1, 0.05, 0.5));
};
if(wl.notNil and: { wl.c > 0.3 }) {
~bodyDrone.set(\freq, wl.y.linexp(0, 1, 220, 55));
};
if(sl.notNil and: { sR.notNil }) {
shoY = (sl.y + sR.y) * 0.5;
~bodyGran.set(\dens, (1 - shoY).linexp(0, 1, 2, 80));
~bodyGran.set(\rate, (1 - shoY).linlin(0, 1, 0.5, 2.5));
};
avg = ~feedGet.(\pose_count, 0);
if(avg < 1) {
// personne -> on baisse tout
~bodyDrone.set(\amp, 0.0);
~bodyGran.set(\amp, 0.0);
} {
~bodyGran.set(\amp, 0.15);
};
0.05.wait;
};
}).play(AppClock);
"[body] up. arret : ~bodyStop.value".postln;
~bodyStop = {
~bodyDrone.release(2);
~bodyGran.release(1);
~bodyR.stop;
"[body] off".postln;
};
)
// ---------------------------------------------------------------------
// [9] ARRET TOTAL
// ---------------------------------------------------------------------
@@ -215,4 +286,5 @@ s.sync;
~cavityStop !? { ~cavityStop.value };
~mixStop !? { ~mixStop.value };
~geoStop !? { ~geoStop.value };
~bodyStop !? { ~bodyStop.value };
)
+305
View File
@@ -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 };
)
+219
View File
@@ -27,6 +27,34 @@ window.amp = { kick: 0, hat: 0, snare: 0, clap: 0, perc: 0,
window.rms = { master: 0 };
window.hydraParams = { intensity: 1, hueShift: 0, speed: 1, density: 1, feedback: 0.5 };
// ---------------------------------------------------------------------
// Flux temps reel externes -- alimente par /data/<source>/<sub> via WS
// (server.js DATA_PORT_IN <- bridge.py)
//
// Cles agregees pour faciliter l'usage Hydra :
// feeds.netz = { freq, dev, time_dev }
// feeds.swpc = { wind_speed, wind_dens, bz, bt, kp, a, flare_norm }
// feeds.usgs = { last_mag, last_age, rate_h }
// feeds.light = { rate_min, last_lat, last_lon, last_age }
// feeds.sky = { count, last_alt, last_vel, last_pan }
// feeds.bsky = { rate_s }
// feeds.rte = { renew_pct, total }
// feeds.tick = compteur incremente a chaque /data/<...>
// feeds.alive = bool (heartbeat < 15s)
// ---------------------------------------------------------------------
window.feeds = {
netz: { freq: 50, dev: 0, time_dev: 0 },
swpc: { wind_speed: 400, wind_dens: 5, bz: 0, bt: 5, kp: 2, a: 5, flare_norm: 0 },
usgs: { last_mag: 0, last_age: 9999, rate_h: 0 },
light: { rate_min: 0, last_lat: 0, last_lon: 0, last_age: 9999 },
sky: { count: 0, last_alt: 0, last_vel: 0, last_pan: 0 },
bsky: { rate_s: 0 },
rte: { renew_pct: 0.25, total: 50000 },
tick: 0,
alive: false,
_lastHb: 0,
};
const wsState = document.getElementById("ws-state");
const bpmDisplay = document.getElementById("bpm");
const beatDisplay = document.getElementById("beat");
@@ -85,6 +113,8 @@ function connect() {
codeArea.value = code;
run();
}
} else if (addr.startsWith("/data/")) {
ingestDataFeed(addr, msg.args);
} else if (addr === "/hydra/param") {
const [name, val] = msg.args;
if (name in window.hydraParams) {
@@ -95,6 +125,105 @@ function connect() {
});
}
// ---------- ingestion data_feeds -------------------------------------
function ingestDataFeed(addr, args) {
window.feeds.tick++;
const a = args || [];
switch (addr) {
case "/data/heartbeat":
window.feeds._lastHb = Date.now();
window.feeds.alive = true;
return;
case "/data/netzfrequenz/freq": window.feeds.netz.freq = a[0]; break;
case "/data/netzfrequenz/dev": window.feeds.netz.dev = a[0]; break;
case "/data/netzfrequenz/time_dev": window.feeds.netz.time_dev = a[0]; break;
case "/data/swpc/wind":
window.feeds.swpc.wind_speed = a[0];
window.feeds.swpc.wind_dens = a[1];
break;
case "/data/swpc/bz":
window.feeds.swpc.bz = a[0];
window.feeds.swpc.bt = a[1];
break;
case "/data/swpc/kp":
window.feeds.swpc.kp = a[0];
window.feeds.swpc.a = a[1];
break;
case "/data/swpc/xray":
window.feeds.swpc.flare_norm = a[2];
break;
case "/data/usgs/event":
window.feeds.usgs.last_mag = a[0];
window.feeds.usgs.last_age = a[4];
// marqueur visuel : pulse pendant 2 s via window.feeds._quakeHit
window.feeds._quakeHit = Date.now();
break;
case "/data/usgs/rate":
window.feeds.usgs.rate_h = a[0];
break;
case "/data/blitzortung/strike":
window.feeds.light.last_lat = a[0];
window.feeds.light.last_lon = a[1];
window.feeds.light.last_age = a[2];
window.feeds._strikeHit = Date.now();
break;
case "/data/blitzortung/rate":
window.feeds.light.rate_min = a[0];
break;
case "/data/opensky/count":
window.feeds.sky.count = a[0];
break;
case "/data/opensky/plane":
window.feeds.sky.last_alt = a[3];
window.feeds.sky.last_vel = a[4];
window.feeds.sky.last_pan = (a[1] - 4.9) / 0.6; // bbox-relative
break;
case "/data/bluesky/rate":
window.feeds.bsky.rate_s = a[0];
break;
case "/data/rte_eco2mix/mix": {
const total = (a[0]||0)+(a[1]||0)+(a[2]||0)+(a[3]||0)+
(a[4]||0)+(a[5]||0)+(a[6]||0)+(a[7]||0);
const renew = (a[4]||0)+(a[5]||0)+(a[6]||0)+(a[7]||0);
window.feeds.rte.total = total;
window.feeds.rte.renew_pct = total > 0 ? renew / total : 0.25;
break;
}
}
}
// helpers exposes pour les patches Hydra (acces concis)
window.f = {
quakePulse: () => {
const t = window.feeds._quakeHit;
if (!t) return 0;
const age = (Date.now() - t) / 1000;
return Math.max(0, 1 - age / 2);
},
strikePulse: () => {
const t = window.feeds._strikeHit;
if (!t) return 0;
const age = (Date.now() - t) / 1000;
return Math.max(0, 1 - age / 1.2);
},
flarePulse: () => Math.min(1, (window.feeds.swpc.flare_norm||0) * 2),
netzDev: () => window.feeds.netz.dev || 0,
bz: () => window.feeds.swpc.bz || 0,
kp01: () => Math.min(1, (window.feeds.swpc.kp||0) / 9),
wind01: () => Math.min(1, ((window.feeds.swpc.wind_speed||400) - 250) / 650),
renew: () => window.feeds.rte.renew_pct || 0.25,
skyCount01: () => Math.min(1, (window.feeds.sky.count||0) / 50),
bskyDensity: () => Math.min(1, (window.feeds.bsky.rate_s||0) / 30),
lightRate01: () => Math.min(1, (window.feeds.light.rate_min||0) / 60),
};
// heartbeat watchdog
setInterval(() => {
if (window.feeds._lastHb && Date.now() - window.feeds._lastHb > 15000) {
window.feeds.alive = false;
}
}, 5000);
// ---------- Hydra setup ----------------------------------------------
const canvas = document.getElementById("hydra-canvas");
canvas.width = window.innerWidth;
@@ -304,6 +433,96 @@ voronoi(20, 0.1, 0.5)
.out()
`.trim(),
// ============ DATA FEEDS PRESETS ============
// Tous utilisent window.feeds (alimente par /data/* via WS).
// Lance d'abord : cd data_feeds && uv run python bridge.py
feeds_aurora: `
// Aurora : Bz IMF + vent solaire + Kp -> drape de lumiere
osc(() => 6 + window.f.wind01() * 18, 0.05, () => 1 + window.f.kp01())
.modulate(noise(() => 2 + window.f.kp01() * 6, 0.1).scrollY(() => window.beat * 0.002))
.rotate(() => window.f.bz() * 0.03)
.color(() => 0.2 + window.f.kp01() * 0.4,
() => 0.6 + window.f.wind01() * 0.4,
() => 0.4 + window.f.flarePulse())
.scale(() => 1 + window.f.flarePulse() * 0.6)
.out()
`.trim(),
feeds_quake: `
// Quake : USGS magnitude -> onde de choc radiale
shape(() => 4 + Math.floor(window.feeds.usgs.last_mag), 0.05, 0.02)
.repeat(8, 8)
.scale(() => 0.5 + window.f.quakePulse() * 2.5)
.modulateRotate(noise(2, 0.2), () => window.f.quakePulse() * 3)
.color(() => 0.8 + window.f.quakePulse(),
() => 0.2 - window.f.quakePulse() * 0.2,
0.1)
.add(osc(20, 0.05, 1).pixelate(8, 8), () => window.f.quakePulse() * 0.4)
.out()
`.trim(),
feeds_lightning: `
// Lightning : foudre Blitzortung -> flash blanc + voronoi rapide
voronoi(() => 8 + window.f.lightRate01() * 30, 0.3, 0.1)
.modulate(noise(6, 0.3))
.invert(() => window.f.strikePulse() > 0.6 ? 1 : 0)
.add(solid(1, 1, 1, () => window.f.strikePulse() * 0.6))
.color(0.7, 0.7, () => 0.9 + window.f.strikePulse())
.out()
`.trim(),
feeds_flightmap: `
// Flightmap : OpenSky count + altitude + cap
osc(() => 10 + window.feeds.sky.count, 0.05, 1)
.kaleid(() => 4 + Math.floor(window.f.skyCount01() * 8))
.modulate(osc(2).scrollX(() => window.feeds.sky.last_pan * 0.1)
.scrollY(() => window.feeds.sky.last_alt / 50000))
.rotate(() => window.feeds.sky.last_pan * 0.5)
.color(() => 0.3 + window.f.skyCount01() * 0.5,
() => 0.5 + window.feeds.sky.last_vel / 500,
() => 0.6 + window.feeds.sky.last_alt / 20000)
.out()
`.trim(),
feeds_gridpulse: `
// Gridpulse : derivation Netzfrequenz -> tremblement micro
osc(() => 30 + window.feeds.netz.freq * 0.5, 0.05, 1.5)
.modulateScale(osc(8).rotate(() => window.beat * 0.01),
() => 0.05 + Math.abs(window.f.netzDev()) * 4)
.scale(() => 1 + window.f.netzDev() * 8)
.color(() => 0.5 - window.f.netzDev() * 5,
() => 0.5 + window.f.netzDev() * 5,
() => 0.5 + window.f.renew() * 0.5)
.contrast(() => 1.2 + Math.abs(window.f.netzDev()) * 8)
.out()
`.trim(),
feeds_solarwind: `
// Solar wind : vent + densite + flare X-ray
noise(() => 2 + window.f.wind01() * 8, () => 0.05 + window.f.flarePulse() * 0.3)
.modulate(osc(() => 5 + window.f.wind01() * 10, 0.05).rotate(() => window.beat * 0.02))
.colorama(() => 0.05 + window.f.kp01() * 0.4)
.add(solid(1, 0.6, 0.2, () => window.f.flarePulse() * 0.5))
.scale(() => 0.8 + window.f.wind01() * 0.5)
.saturate(() => 1.3 + window.f.flarePulse() * 2)
.out()
`.trim(),
feeds_bskyrain: `
// Bskyrain : pluie de pixels proportionnelle au firehose Bluesky
shape(4, 0.005, 0.001)
.repeat(() => 40 + window.f.bskyDensity() * 80,
() => 40 + window.f.bskyDensity() * 80)
.scrollY(() => time * (0.05 + window.f.bskyDensity() * 0.3))
.scrollX(() => Math.sin(time * 0.3) * 0.02)
.color(() => 0.4 + window.f.bskyDensity() * 0.4,
() => 0.7 + window.f.kp01() * 0.3,
1)
.modulate(noise(2, 0.2), 0.05)
.out()
`.trim(),
cosmos: `
// Cosmos : multi-osc layered + rotate
osc(5, 0.05, 1)
+7
View File
@@ -131,6 +131,13 @@
<button data-preset="grid">grid</button>
<button data-preset="warp">warp</button>
<button data-preset="pixel">pixel</button>
<button data-preset="feeds_aurora">aurora*</button>
<button data-preset="feeds_quake">quake*</button>
<button data-preset="feeds_lightning">lightning*</button>
<button data-preset="feeds_flightmap">flights*</button>
<button data-preset="feeds_gridpulse">grid*</button>
<button data-preset="feeds_solarwind">solar*</button>
<button data-preset="feeds_bskyrain">bsky*</button>
<button id="run">run [Ctrl+Enter]</button>
</div>
<div id="status"></div>
+28
View File
@@ -33,6 +33,7 @@ const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3000", 10);
const SC_HOST = process.env.SC_HOST ?? "127.0.0.1";
const SC_PORT_OUT = parseInt(process.env.SC_PORT_OUT ?? "57121", 10); // -> SC
const SC_PORT_IN = parseInt(process.env.SC_PORT_IN ?? "57122", 10); // <- SC
const DATA_PORT_IN = parseInt(process.env.DATA_PORT_IN ?? "57124", 10); // <- data_feeds bridge.py
// ---------------------------------------------------------------------
// HTTP : Express sert public/
@@ -130,6 +131,32 @@ oscPort.on("error", (err) => {
oscPort.open();
// ---------------------------------------------------------------------
// OSC : server.js <- data_feeds/bridge.py (UDP, lecture seule)
// Le pont Python emet /data/<source>/<sub> et /data/heartbeat sur ce
// port. On les reflechit en WS sous forme JSON pour les clients web.
// ---------------------------------------------------------------------
const dataPort = new osc.UDPPort({
localAddress: "0.0.0.0",
localPort: DATA_PORT_IN,
metadata: false,
});
dataPort.on("ready", () => {
console.log(`[data] ready -- ecoute :${DATA_PORT_IN} <- bridge.py`);
});
dataPort.on("message", (oscMsg) => {
// broadcast direct, meme format que /sync/* : { address, args }
broadcast({ address: oscMsg.address, args: oscMsg.args });
});
dataPort.on("error", (err) => {
console.error("[data] erreur:", err.message);
});
dataPort.open();
function sendToSC(address, args) {
const payload = args.map((v) => {
if (typeof v === "number") {
@@ -165,6 +192,7 @@ function shutdown() {
console.log("\n[server] shutdown...");
wss.close();
oscPort.close();
dataPort.close();
httpServer.close(() => process.exit(0));
}
+17
View File
@@ -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"]
+142
View File
@@ -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).
+21
View File
@@ -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"]
+71
View File
@@ -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 = ""
+143
View File
@@ -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())
+49
View File
@@ -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)
+46
View File
@@ -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)
+66
View File
@@ -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
+37
View File
@@ -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)
+43
View File
@@ -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)
+48
View File
@@ -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)
+158
View File
@@ -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)
+90
View File
@@ -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)
+51
View File
@@ -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)
+21
View File
@@ -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]"
+55
View File
@@ -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
+19
View File
@@ -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"
}
}
+471
View File
@@ -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();
}
+93
View File
@@ -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>
+168
View File
@@ -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);
})();
+116
View File
@@ -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");
+63
View File
@@ -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>
+98
View File
@@ -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>
+283
View File
@@ -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);
+57
View File
@@ -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>
+110
View File
@@ -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);