diff --git a/.claude/settings.json b/.claude/settings.json index 1f09941..37b9498 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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 *)" ] } } diff --git a/README.md b/README.md index 55eabdd..e5359c7 100644 --- a/README.md +++ b/README.md @@ -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//` 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 ``` diff --git a/data_feeds/.gitignore b/data_feeds/.gitignore new file mode 100644 index 0000000..d765fa3 --- /dev/null +++ b/data_feeds/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.uv-cache/ +uv.lock diff --git a/data_feeds/README.md b/data_feeds/README.md index 11439e5..04604dc 100644 --- a/data_feeds/README.md +++ b/data_feeds/README.md @@ -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) : puis renseigner `client_id` / `client_secret`. - `gcn` : + `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 diff --git a/data_feeds/config.toml b/data_feeds/config.toml index bfac252..cad3da2 100644 --- a/data_feeds/config.toml +++ b/data_feeds/config.toml @@ -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//... 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 +# /data/pose/person (normalises 0..1) +# /data/pose/skel (17 kp COCO) +# /data/pose/bone (segments du skeleton) + # -- Sismique / géophysique ------------------------------------------------ [feeds.usgs] diff --git a/data_feeds/feeds/pose.py b/data_feeds/feeds/pose.py new file mode 100644 index 0000000..00252d5 --- /dev/null +++ b/data_feeds/feeds/pose.py @@ -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 + /data/pose/person + /data/pose/skel + /data/pose/bone (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() diff --git a/data_feeds/pyproject.toml b/data_feeds/pyproject.toml index 3912b42..5304d95 100644 --- a/data_feeds/pyproject.toml +++ b/data_feeds/pyproject.toml @@ -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 diff --git a/docs/DATA_FEEDS_OSC.md b/docs/DATA_FEEDS_OSC.md new file mode 100644 index 0000000..e50874d --- /dev/null +++ b/docs/DATA_FEEDS_OSC.md @@ -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 : + +| Route | Args | Notes | +|---------------------|-------------------------------------|--------------------------------| +| `/data/usgs/event` | `mag lon lat depth_km age_sec` | Un par séisme nouveau | +| `/data/usgs/rate` | `events_per_hour` | Fenêtre glissante 1 h | + +## Météo spatiale — `swpc` + +Source : NOAA SWPC (DSCOVR, GOES, planetary index). + +| Route | Args | Notes | +|-------------------|-----------------------------------|----------------------------------------| +| `/data/swpc/wind` | `speed_kms density_pcm3 temp_K` | typique 300–800 km/s | +| `/data/swpc/bz` | `Bz_nT Bt_nT` | Bz < 0 = orage géomag possible | +| `/data/swpc/kp` | `Kp a_index` | Kp ∈ [0..9] | +| `/data/swpc/xray` | `short_Wm2 long_Wm2 flare_norm` | `flare_norm` 0..1 sur classes A→X | + +## Réseau électrique — `netzfrequenz` + +Source : Mainsfrequenz.de WebSocket (mesure ~200 ms à Karlsruhe). + +| Route | Args | Notes | +|--------------------------------|-------------|------------------------------------| +| `/data/netzfrequenz/freq` | `hz` | typiquement 49.95 .. 50.05 | +| `/data/netzfrequenz/dev` | `delta_hz` | `hz - 50.0` | +| `/data/netzfrequenz/time_dev` | `sec` | Dérive intégrée (horloge synchrone)| + +## Foudre — `blitzortung` + +Source : LightningMaps WebSocket relay (réseau Blitzortung). + +| Route | Args | Notes | +|-----------------------------|-------------------------------|----------------------| +| `/data/blitzortung/strike` | `lat lon age_sec multiplicity`| Un par impact | +| `/data/blitzortung/rate` | `strikes_per_min` | Fenêtre 60 s | + +## Aviation — `opensky` + +Source : OpenSky Network REST (anonyme : 15 s max). + +| Route | Args | Notes | +|------------------------|-----------------------------------------------------|------------------------| +| `/data/opensky/count` | `n` | Aéronefs dans la bbox | +| `/data/opensky/plane` | `"icao24" lon lat alt_m vel_ms heading_deg` | Un par avion par poll | + +Note : `icao24` est une string ; côté oF elle est hashée djb2 16 bits en float. + +## Mix électrique France — `rte_eco2mix` + +Source : RTE Open API (OAuth2 client_credentials, gratuit). + +| Route | Args | +|-------------------|-------------------------------------------------------------------| +| `/data/rte_eco2mix/mix` | `nuclear gas coal oil hydro wind solar bio` (MW) | + +Côté SC, `~feeds[\rte_renew_pct]` calcule `(hydro+wind+solar+bio)/total`. + +## Social — `bluesky` + +Source : Jetstream WebSocket (firehose posts publics). + +| Route | Args | Notes | +|----------------------|----------------------------|--------------------------------------| +| `/data/bluesky/post` | `text_len lang_hash` | Echantillonné selon `sample_rate` | +| `/data/bluesky/rate` | `posts_per_sec` | Fenêtre 10 s | + +## Bitcoin — `mempool` + +Source : mempool.space WebSocket. + +| Route | Args | +|----------------------|---------------------------------------| +| `/data/mempool/tx` | `value_btc fee_sat_vb` | +| `/data/mempool/block`| `height tx_count reward_btc` | + +## GitHub — `github` + +Source : `/events` API publique (60 req/h sans token). + +| Route | Args | +|----------------------|-------------------------------| +| `/data/github/event` | `type_hash repo_hash` | + +## Pose / webcam — `pose` + +Source : webcam locale (cv2.VideoCapture) + détection YOLOv8-pose +(17 keypoints COCO). Tourne dans `data_feeds/feeds/pose.py`. + +**Attention** : sur macOS, **une seule application** peut ouvrir la +webcam à la fois. Si le worker pose tourne, désactiver +`localCapture` dans `WebcamVis` côté oF (et inversement). + +| Route | Args | +|----------------------|---------------------------------------------------------------| +| `/data/pose/count` | `n` (nombre de personnes) | +| `/data/pose/person` | `idx cx cy w h conf` (bbox normalisée 0..1) | +| `/data/pose/skel` | `idx avg_conf x0 y0 c0 ... x16 y16 c16` (53 args si emit_kp) | +| `/data/pose/bone` | `kp_a kp_b` (annoncé statiquement à la connexion, 16 paires) | + +Layout COCO (17 keypoints) : + +```text + 0 nose 5 sho_l 6 sho_r + 1 eye_l 2 eye_r 7 elb_l 8 elb_r + 3 ear_l 4 ear_r 9 wri_l 10 wri_r + 11 hip_l 12 hip_r + 13 kne_l 14 kne_r + 15 ank_l 16 ank_r +``` + +Côté SC, helper de lecture : + +```supercollider +~poseKp.(\wri_r); // → (x:, y:, c:) pour le sujet 0 +~feeds[\pose_count]; // nombre de sujets +~feeds[\pose_persons][0]; // (cx:, cy:, w:, h:, conf:) +``` + +## GCN — `gcn` + +Source : NASA GCN Classic over Kafka (auth). + +| Route | Args | +|----------------------|-------------------------------------------| +| `/data/gcn/alert` | `mission_hash ra_deg dec_deg err_arcmin` | + +## Conventions + +- **Strings → hash** : tout argument string non-essentiel est encodé en + hash djb2 16 bits (`0..65535`) pour rester compatible numérique côté + SynthDef et shaders. Le pont Python et `OscClient` utilisent la même + fonction. +- **Age** : pour les événements horodatés (USGS, Blitzortung), un + `age_sec` est inclus pour permettre de filtrer les vieux événements + qui arriveraient en burst après une coupure réseau. +- **Idempotence** : tous les `OSCdef` côté SC sont libérés et recréés + à chaque chargement de `control/data_feeds.scd`. diff --git a/launcher/README.md b/launcher/README.md index 4a54ee7..074cc58 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -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. diff --git a/launcher/Sources/AVLiveLauncher/MenuBarContent.swift b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift index ace4a27..c7873b3 100644 --- a/launcher/Sources/AVLiveLauncher/MenuBarContent.swift +++ b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift @@ -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) diff --git a/launcher/Sources/AVLiveLauncher/ProcessManager.swift b/launcher/Sources/AVLiveLauncher/ProcessManager.swift index 5648512..35b329d 100644 --- a/launcher/Sources/AVLiveLauncher/ProcessManager.swift +++ b/launcher/Sources/AVLiveLauncher/ProcessManager.swift @@ -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() { diff --git a/oscope-of/addons.make b/oscope-of/addons.make index f766294..e6d392e 100644 --- a/oscope-of/addons.make +++ b/oscope-of/addons.make @@ -1,2 +1,3 @@ ofxOsc ofxGui +ofxOpenCv diff --git a/oscope-of/src/ofApp.cpp b/oscope-of/src/ofApp.cpp index 7b968bb..e052d21 100644 --- a/oscope-of/src/ofApp.cpp +++ b/oscope-of/src/ofApp.cpp @@ -74,6 +74,7 @@ void ofApp::setup() { spectro_ = std::make_unique(); reactive_ = std::make_unique(); waveform_ = std::make_unique(); + webcam_ = std::make_unique(); polar_ = std::make_unique(); plasma_ = std::make_unique(); particles_ = std::make_unique(); @@ -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) diff --git a/oscope-of/src/ofApp.h b/oscope-of/src/ofApp.h index aacc352..f00c32b 100644 --- a/oscope-of/src/ofApp.h +++ b/oscope-of/src/ofApp.h @@ -29,6 +29,7 @@ #include "visualizers/ModelVis.h" #include "visualizers/SphereWaveVis.h" #include "visualizers/ImageVis.h" +#include "visualizers/WebcamVis.h" #include @@ -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 imgLotus_, imgDos_, imgAtari_, imgApple_; std::unique_ptr imgWB_, imgNeXT_, imgBeOS_, imgOS2_; std::unique_ptr imgIrix_, imgZX_, imgC64_; + // Webcam + ofxOpenCv + overlay du skeleton OSC (/data/pose/*). + std::unique_ptr webcam_; std::vector ch1_, ch2_; Mode mode_ = Mode::Scope4; diff --git a/oscope-of/src/visualizers/WebcamVis.cpp b/oscope-of/src/visualizers/WebcamVis.cpp new file mode 100644 index 0000000..f141259 --- /dev/null +++ b/oscope-of/src/visualizers/WebcamVis.cpp @@ -0,0 +1,130 @@ +#include "WebcamVis.h" + +namespace oscope { + +namespace { +// Squelette COCO (17 kp) — paires d'os. +constexpr std::array, 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 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 diff --git a/oscope-of/src/visualizers/WebcamVis.h b/oscope-of/src/visualizers/WebcamVis.h new file mode 100644 index 0000000..56e3e17 --- /dev/null +++ b/oscope-of/src/visualizers/WebcamVis.h @@ -0,0 +1,73 @@ +#pragma once + +// Webcam + ofxOpenCv pipeline + overlay du skeleton recu via OSC +// (/data/pose/skel ). +// +// 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 +#include + +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 skel_{}; + float skelAvgConf_ = 0.0f; + int personCount_ = 0; + double lastSkelT_ = -1.0; +}; + +} // namespace oscope diff --git a/sound_algo/control/data_feeds.scd b/sound_algo/control/data_feeds.scd index 7a2b44e..73d0e6a 100644 --- a/sound_algo/control/data_feeds.scd +++ b/sound_algo/control/data_feeds.scd @@ -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 // ===================================================================== diff --git a/sound_algo/examples/16_data_feeds.scd b/sound_algo/examples/16_data_feeds.scd index 28197f7..b369b80 100644 --- a/sound_algo/examples/16_data_feeds.scd +++ b/sound_algo/examples/16_data_feeds.scd @@ -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 }; ) diff --git a/sound_algo/examples/17_data_feeds_more.scd b/sound_algo/examples/17_data_feeds_more.scd new file mode 100644 index 0000000..c4769b3 --- /dev/null +++ b/sound_algo/examples/17_data_feeds_more.scd @@ -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 }; +) diff --git a/sound_algo/web/public/hydra/app.js b/sound_algo/web/public/hydra/app.js index dd87ce4..3fce6de 100644 --- a/sound_algo/web/public/hydra/app.js +++ b/sound_algo/web/public/hydra/app.js @@ -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// 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) diff --git a/sound_algo/web/public/hydra/index.html b/sound_algo/web/public/hydra/index.html index e78584c..cc41dbc 100644 --- a/sound_algo/web/public/hydra/index.html +++ b/sound_algo/web/public/hydra/index.html @@ -131,6 +131,13 @@ + + + + + + +
diff --git a/sound_algo/web/server.js b/sound_algo/web/server.js index 5fafb33..046400a 100644 --- a/sound_algo/web/server.js +++ b/sound_algo/web/server.js @@ -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// 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)); } diff --git a/web_realart/Dockerfile b/web_realart/Dockerfile new file mode 100644 index 0000000..ff75866 --- /dev/null +++ b/web_realart/Dockerfile @@ -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"] diff --git a/web_realart/README.md b/web_realart/README.md new file mode 100644 index 0000000..81424fa --- /dev/null +++ b/web_realart/README.md @@ -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). diff --git a/web_realart/bridge/Dockerfile b/web_realart/bridge/Dockerfile new file mode 100644 index 0000000..0457e28 --- /dev/null +++ b/web_realart/bridge/Dockerfile @@ -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"] diff --git a/web_realart/bridge/config.toml b/web_realart/bridge/config.toml new file mode 100644 index 0000000..a76f109 --- /dev/null +++ b/web_realart/bridge/config.toml @@ -0,0 +1,71 @@ +# Config bridge data_feeds pour le deploiement real.art.saillant.cc. +# +# Cible OSC unique : container web sur le network "data" (docker compose). +# Le hostname "web" est resolu par le DNS Docker interne. + +[osc] +targets = [ + { host = "web", port = 57124 }, +] +prefix = "/data" + +# -- Pose : DESACTIVE en prod (besoin d'une webcam, pas pertinent serveur) +[feeds.pose] +enabled = false + +# -- Sismique / geophysique +[feeds.usgs] +enabled = true +url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson" +poll_seconds = 60 + +[feeds.swpc] +enabled = true +url_plasma = "https://services.swpc.noaa.gov/products/solar-wind/plasma-1-day.json" +url_mag = "https://services.swpc.noaa.gov/products/solar-wind/mag-1-day.json" +url_kp = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json" +url_xray = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json" +poll_seconds = 60 + +# -- Reseau electrique +[feeds.netzfrequenz] +enabled = true +ws_url = "wss://www.mainsfrequenz.de/frequenz.socket" + +[feeds.rte_eco2mix] +enabled = false # token RTE requis -- a remplir si besoin +client_id = "" +client_secret = "" +poll_seconds = 900 + +# -- Foudre +[feeds.blitzortung] +enabled = true +ws_url = "wss://ws1.blitzortung.org:443/" + +# -- Aviation +[feeds.opensky] +enabled = true +url = "https://opensky-network.org/api/states/all" +poll_seconds = 30 +# bbox Europe occidentale plus large pour avoir du trafic visible +bbox = [42.0, -5.0, 51.0, 10.0] + +# -- Pouls numerique +[feeds.bluesky] +enabled = true +ws_url = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post" +sample_rate = 0.02 + +[feeds.mempool] +enabled = false +ws_url = "wss://mempool.space/api/v1/ws" + +[feeds.github] +enabled = false +url = "https://api.github.com/events" + +[feeds.gcn] +enabled = false +client_id = "" +client_secret = "" diff --git a/web_realart/bridge/src/bridge.py b/web_realart/bridge/src/bridge.py new file mode 100644 index 0000000..9b0bc7e --- /dev/null +++ b/web_realart/bridge/src/bridge.py @@ -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.` 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()) diff --git a/web_realart/bridge/src/feeds/__init__.py b/web_realart/bridge/src/feeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web_realart/bridge/src/feeds/_util.py b/web_realart/bridge/src/feeds/_util.py new file mode 100644 index 0000000..93ff0b7 --- /dev/null +++ b/web_realart/bridge/src/feeds/_util.py @@ -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 diff --git a/web_realart/bridge/src/feeds/blitzortung.py b/web_realart/bridge/src/feeds/blitzortung.py new file mode 100644 index 0000000..1962c7c --- /dev/null +++ b/web_realart/bridge/src/feeds/blitzortung.py @@ -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) diff --git a/web_realart/bridge/src/feeds/bluesky.py b/web_realart/bridge/src/feeds/bluesky.py new file mode 100644 index 0000000..7c00e3a --- /dev/null +++ b/web_realart/bridge/src/feeds/bluesky.py @@ -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) diff --git a/web_realart/bridge/src/feeds/gcn.py b/web_realart/bridge/src/feeds/gcn.py new file mode 100644 index 0000000..6841306 --- /dev/null +++ b/web_realart/bridge/src/feeds/gcn.py @@ -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 diff --git a/web_realart/bridge/src/feeds/github.py b/web_realart/bridge/src/feeds/github.py new file mode 100644 index 0000000..db77961 --- /dev/null +++ b/web_realart/bridge/src/feeds/github.py @@ -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) diff --git a/web_realart/bridge/src/feeds/mempool.py b/web_realart/bridge/src/feeds/mempool.py new file mode 100644 index 0000000..94d07fb --- /dev/null +++ b/web_realart/bridge/src/feeds/mempool.py @@ -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) diff --git a/web_realart/bridge/src/feeds/netzfrequenz.py b/web_realart/bridge/src/feeds/netzfrequenz.py new file mode 100644 index 0000000..45dc518 --- /dev/null +++ b/web_realart/bridge/src/feeds/netzfrequenz.py @@ -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) diff --git a/web_realart/bridge/src/feeds/opensky.py b/web_realart/bridge/src/feeds/opensky.py new file mode 100644 index 0000000..557d06e --- /dev/null +++ b/web_realart/bridge/src/feeds/opensky.py @@ -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) diff --git a/web_realart/bridge/src/feeds/pose.py b/web_realart/bridge/src/feeds/pose.py new file mode 100644 index 0000000..00252d5 --- /dev/null +++ b/web_realart/bridge/src/feeds/pose.py @@ -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 + /data/pose/person + /data/pose/skel + /data/pose/bone (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() diff --git a/web_realart/bridge/src/feeds/rte_eco2mix.py b/web_realart/bridge/src/feeds/rte_eco2mix.py new file mode 100644 index 0000000..f3f39a8 --- /dev/null +++ b/web_realart/bridge/src/feeds/rte_eco2mix.py @@ -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) diff --git a/web_realart/bridge/src/feeds/swpc.py b/web_realart/bridge/src/feeds/swpc.py new file mode 100644 index 0000000..b646362 --- /dev/null +++ b/web_realart/bridge/src/feeds/swpc.py @@ -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) diff --git a/web_realart/bridge/src/feeds/usgs.py b/web_realart/bridge/src/feeds/usgs.py new file mode 100644 index 0000000..7c2e1f4 --- /dev/null +++ b/web_realart/bridge/src/feeds/usgs.py @@ -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) diff --git a/web_realart/build.sh b/web_realart/build.sh new file mode 100755 index 0000000..e63fa38 --- /dev/null +++ b/web_realart/build.sh @@ -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]" diff --git a/web_realart/docker-compose.yml b/web_realart/docker-compose.yml new file mode 100644 index 0000000..e38c739 --- /dev/null +++ b/web_realart/docker-compose.yml @@ -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 diff --git a/web_realart/package.json b/web_realart/package.json new file mode 100644 index 0000000..8d5b5f6 --- /dev/null +++ b/web_realart/package.json @@ -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" + } +} diff --git a/web_realart/public/audio/app.js b/web_realart/public/audio/app.js new file mode 100644 index 0000000..8619a6b --- /dev/null +++ b/web_realart/public/audio/app.js @@ -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 = `${p} 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(); +} diff --git a/web_realart/public/audio/index.html b/web_realart/public/audio/index.html new file mode 100644 index 0000000..b154b9b --- /dev/null +++ b/web_realart/public/audio/index.html @@ -0,0 +1,93 @@ + + + + + + realart / audio (Web Audio portage SC) + + + +

/ realart · audio (portage SC)

+

+ 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. +

+ + + +
+
feeds :
+
kp --
+
bz -- nT
+
wind -- km/s
+
netz -- Hz
+
flare --
+
+ +
+ + + + + + +
+ +
+ + + 0.40 +
+ + + +

aucun preset actif. cavity + = drone Schumann + foudre, aurora + = pad geomagnetique, pulse + = kick netz + sub seismes...

+ + + + + diff --git a/web_realart/public/feeds_client.js b/web_realart/public/feeds_client.js new file mode 100644 index 0000000..2ad8188 --- /dev/null +++ b/web_realart/public/feeds_client.js @@ -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); +})(); diff --git a/web_realart/public/hydra/app.js b/web_realart/public/hydra/app.js new file mode 100644 index 0000000..9594081 --- /dev/null +++ b/web_realart/public/hydra/app.js @@ -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"); diff --git a/web_realart/public/hydra/index.html b/web_realart/public/hydra/index.html new file mode 100644 index 0000000..c62ea25 --- /dev/null +++ b/web_realart/public/hydra/index.html @@ -0,0 +1,63 @@ + + + + + + realart / hydra + + + + +
+
+ / realart · hydra · + feeds +
+
+ kp -- · + bz -- · + wind -- +
+
+
+ + + + + + + +
+ + + + + + diff --git a/web_realart/public/index.html b/web_realart/public/index.html new file mode 100644 index 0000000..976f7d1 --- /dev/null +++ b/web_realart/public/index.html @@ -0,0 +1,98 @@ + + + + + + real.art -- AV-Live data_feeds + + + +

real.art

+

+ 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. +

+ + + + + +
feeds : · ticks 0
+ + + + + diff --git a/web_realart/public/webgl/app.js b/web_realart/public/webgl/app.js new file mode 100644 index 0000000..e43bfdc --- /dev/null +++ b/web_realart/public/webgl/app.js @@ -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); diff --git a/web_realart/public/webgl/index.html b/web_realart/public/webgl/index.html new file mode 100644 index 0000000..754f915 --- /dev/null +++ b/web_realart/public/webgl/index.html @@ -0,0 +1,57 @@ + + + + + + realart / globe (three.js + WebGPU TSL) + + + + + +
+
+ / realart · globe (three.js TSL) +
quakes · strikes · flights · aurore +
+
+ feeds · + evt 0 · + kp -- · + wind -- km/s +
+
+
backend :
+ + + + + diff --git a/web_realart/server.js b/web_realart/server.js new file mode 100644 index 0000000..553b832 --- /dev/null +++ b/web_realart/server.js @@ -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// 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// -> 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);