feat: add real-time data feeds for various sources

- Implemented new data feeds for Blitzortung, Bluesky, GCN, GitHub, Mempool, Netzfrequenz, OpenSky, RTE éCO2mix, SWPC, USGS.
- Introduced utility functions for rate limiting and hashing.
- Established OSC communication for data reception and processing.
- Created SuperCollider definitions for handling incoming data and generating audio output based on real-time data.
- Added configuration management for API credentials and polling intervals.
This commit is contained in:
L'électron rare
2026-05-11 06:59:44 +02:00
parent 02eaf654d5
commit 0d189a2139
21 changed files with 1509 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"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)"
]
}
}
+117
View File
@@ -0,0 +1,117 @@
# data_feeds — Pont flux temps réel → OSC
Worker Python asynchrone qui aspire des sources publiques (sismique,
géophysique, réseau électrique, foudre, aviation, social, blockchain…)
et les rebalance en OSC vers SuperCollider (`:57121`) et openFrameworks
(`:57123`). Le but : nourrir l'engine audio et le visualizer avec des
**signaux du monde réel**, sans bricoler du networking dans `sclang`.
## Architecture
```
┌────────────────────────────────┐
│ data_feeds/bridge.py │
│ ├─ usgs (HTTP 60 s) │
│ ├─ swpc (HTTP 60 s) │
│ ├─ netzfrequenz(WebSocket) │
│ ├─ blitzortung (WebSocket) │
│ ├─ opensky (HTTP 15 s) │
│ ├─ bluesky (WebSocket) │
│ ├─ mempool (WebSocket) │
│ ├─ rte_eco2mix (OAuth2) │
│ ├─ github (HTTP 30 s) │
│ └─ gcn (Kafka) │
└─────────────┬──────────────────┘
│ OSC broadcast
┌─────────────┴────────────┐
UDP :57121 UDP :57123
┌──▼────────────┐ ┌─────▼────────────┐
│ SuperCollider │ │ openFrameworks │
│ ~feeds dict │ │ OscClient.data()│
└───────────────┘ └──────────────────┘
```
## Démarrage
```bash
cd data_feeds
uv sync # créé .venv et installe les deps
uv run python bridge.py -v # -v = verbose
```
Côté SC :
```supercollider
"sound_algo/control/data_feeds.scd".loadRelative; // installe les OSCdef
~feedDump.value; // affiche l'état
```
Côté oF : automatique dès que `OscClient::update()` tourne (déjà appelé
chaque frame). Lecture :
```cpp
float kp = osc_.dataf("swpc", "kp", /*fallback*/ 2.0f);
std::vector<float> strike;
if (osc_.consumeDataPulse("blitzortung", "strike", strike)) {
// strike = [lat, lon, age, mult]
}
```
## Schéma OSC
Toutes les routes sont préfixées `/data/<feed>/<sub>`. Voir
[`docs/DATA_FEEDS_OSC.md`](../docs/DATA_FEEDS_OSC.md) pour le schéma
complet.
| Feed | Routes | Cadence |
|----------------|---------------------------------------------|-------------|
| `usgs` | `event`, `rate` | 60 s |
| `swpc` | `wind`, `bz`, `kp`, `xray` | 60 s |
| `netzfrequenz` | `freq`, `dev`, `time_dev` | ~200 ms |
| `blitzortung` | `strike`, `rate` | event-based |
| `opensky` | `count`, `plane` | 15 s |
| `bluesky` | `post`, `rate` | event-based |
| `mempool` | `tx`, `block` | event-based |
| `rte_eco2mix` | `mix` | 15 min |
| `github` | `event` | 30 s |
| `gcn` | `alert` | rare |
## Configuration
Éditer `config.toml` :
- `osc.targets` : liste `{host, port}` à arroser (par défaut SC + oF).
- `feeds.<name>.enabled` : booléen.
- `feeds.<name>.poll_seconds` : période pour les feeds HTTP.
- `feeds.opensky.bbox` : `[lamin, lomin, lamax, lomax]` (Lyon par défaut).
- `feeds.bluesky.sample_rate` : 0..1, fraction des posts conservée.
Flux nécessitant des identifiants (désactivés par défaut) :
- `rte_eco2mix` : créer un client sur
<https://data.rte-france.com/> puis renseigner `client_id` /
`client_secret`.
- `gcn` : <https://gcn.nasa.gov/quickstart> + `uv add gcn-kafka`.
## Diagnostic
```bash
# Sniffer les paquets recus cote SC
uv run python -c "from pythonosc import osc_server, dispatcher; \
d=dispatcher.Dispatcher(); d.set_default_handler(lambda a,*x: print(a,x)); \
osc_server.BlockingOSCUDPServer(('127.0.0.1',57121),d).serve_forever()"
```
Côté SC, vérifier le heartbeat :
```supercollider
~feedAlive.value // true si le pont émet depuis < 15 s
```
## Ajout d'un flux
1. Créer `data_feeds/feeds/<name>.py` exposant `async def run(ctx)`.
2. L'enregistrer dans `config.toml` avec `enabled = true`.
3. Ajouter les OSCdef correspondants dans
`sound_algo/control/data_feeds.scd`.
4. Documenter le schéma OSC dans `docs/DATA_FEEDS_OSC.md`.
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Orchestrateur du pont data_feeds → OSC.
Charge `config.toml`, lance un worker async par flux activé, et diffuse
en broadcast vers tous les `osc.targets`. Chaque worker doit exposer
une coroutine `run(ctx)` qui prend un `Context` et émet via `ctx.send(...)`.
"""
from __future__ import annotations
import argparse
import asyncio
import importlib
import logging
import signal
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
import tomllib # py311+
except ModuleNotFoundError:
import tomli as tomllib # type: ignore
from pythonosc.udp_client import SimpleUDPClient
LOG = logging.getLogger("bridge")
@dataclass
class Context:
cfg: dict[str, Any]
prefix: str
clients: list[SimpleUDPClient]
feed_name: str
def send(self, sub: str, *args: Any) -> None:
path = f"{self.prefix}/{self.feed_name}/{sub}"
for c in self.clients:
try:
c.send_message(path, list(args))
except OSError as e:
LOG.warning("OSC send failed %s: %s", path, e)
def load_config(path: Path) -> dict[str, Any]:
with path.open("rb") as f:
return tomllib.load(f)
async def run_feed(name: str, cfg: dict[str, Any], ctx: Context) -> None:
"""Charge `data_feeds.feeds.<name>` et appelle `run(ctx)`."""
try:
mod = importlib.import_module(f"data_feeds.feeds.{name}")
except ModuleNotFoundError:
# Fallback : exécution depuis le dossier data_feeds/
mod = importlib.import_module(f"feeds.{name}")
LOG.info("starting feed: %s", name)
backoff = 1.0
while True:
try:
await mod.run(ctx)
# Si run() retourne sans exception, on redémarre poliment
await asyncio.sleep(2.0)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
LOG.error("feed %s crashed: %s — retry in %.1fs", name, e, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60.0)
async def heartbeat(clients: list[SimpleUDPClient], prefix: str) -> None:
t0 = time.monotonic()
while True:
for c in clients:
c.send_message(f"{prefix}/heartbeat", [time.monotonic() - t0])
await asyncio.sleep(5.0)
async def main_async(cfg: dict[str, Any]) -> None:
osc_cfg = cfg.get("osc", {})
prefix = osc_cfg.get("prefix", "/data")
clients = [
SimpleUDPClient(t["host"], t["port"])
for t in osc_cfg.get("targets", [{"host": "127.0.0.1", "port": 57121}])
]
LOG.info(
"OSC targets: %s",
", ".join(f"{c._address}:{c._port}" for c in clients), # noqa: SLF001
)
tasks: list[asyncio.Task[None]] = []
for name, fcfg in cfg.get("feeds", {}).items():
if not fcfg.get("enabled", False):
LOG.info("feed disabled: %s", name)
continue
ctx = Context(cfg=fcfg, prefix=prefix, clients=clients, feed_name=name)
tasks.append(asyncio.create_task(run_feed(name, fcfg, ctx), name=name))
if not tasks:
LOG.warning("no feed enabled — exiting")
return
tasks.append(asyncio.create_task(heartbeat(clients, prefix), name="heartbeat"))
loop = asyncio.get_running_loop()
stop = loop.create_future()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, lambda: stop.cancel() if not stop.done() else None)
try:
await stop
except asyncio.CancelledError:
pass
finally:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
LOG.info("bridge stopped")
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("-c", "--config", type=Path, default=Path(__file__).parent / "config.toml")
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
cfg = load_config(args.config)
try:
asyncio.run(main_async(cfg))
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())
+108
View File
@@ -0,0 +1,108 @@
# Configuration du pont data_feeds → OSC.
#
# - SC écoute par défaut sur 57121 (cf. sound_algo/web_bridge.scd).
# - oF écoute sur 57123 (cf. ofApp::setup, oscListenPort_).
# Le pont diffuse en broadcast vers TOUS les `osc_targets` listés.
#
# Activer/désactiver un flux : `enabled = true|false`.
# Régler le débit avec `poll_seconds` (HTTP) ou laisser les WS gérer.
[osc]
targets = [
{ host = "127.0.0.1", port = 57121 }, # SuperCollider
{ host = "127.0.0.1", port = 57123 }, # openFrameworks
]
# Préfixe commun. Toutes les routes sont /data/<feed>/...
prefix = "/data"
# -- Sismique / géophysique ------------------------------------------------
[feeds.usgs]
enabled = true
url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
poll_seconds = 60
# Émet /data/usgs/event <mag> <lon> <lat> <depth> <age_sec>
# /data/usgs/rate <events_per_hour>
[feeds.swpc]
enabled = true
# Vent solaire (DSCOVR plasma)
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
# /data/swpc/wind <speed_kms> <density_pcm3> <temp_K>
# /data/swpc/bz <Bz_nT> <Bt_nT>
# /data/swpc/kp <kp> <a_index>
# /data/swpc/xray <short_W_m2> <long_W_m2> <flare_class_norm>
# -- Réseau électrique -----------------------------------------------------
[feeds.netzfrequenz]
enabled = true
# Mainsfrequenz.de WebSocket (résolution ~200 ms, mesure Karlsruhe)
ws_url = "wss://www.mainsfrequenz.de/frequenz.socket"
# /data/grid/freq <hz> 50.000 ± 0.200
# /data/grid/dev <delta_hz> écart vs 50 Hz
# /data/grid/time_dev <sec> dérive intégrée
[feeds.rte_eco2mix]
enabled = false # nécessite token OAuth RTE (gratuit, register)
client_id = ""
client_secret = ""
poll_seconds = 900
# /data/rte/mix <nuclear> <gas> <coal> <oil> <hydro> <wind> <solar> <bio>
# /data/rte/co2 <gCO2_per_kWh>
# -- Foudre / atmosphère ---------------------------------------------------
[feeds.blitzortung]
enabled = true
# LightningMaps relay (Blitzortung dérivé, public)
ws_url = "wss://ws1.blitzortung.org:443/"
# /data/lightning/strike <lat> <lon> <age_sec> <multiplicity>
# /data/lightning/rate <strikes_per_min>
# -- Aviation / mouvement --------------------------------------------------
[feeds.opensky]
enabled = true
url = "https://opensky-network.org/api/states/all"
poll_seconds = 15
# Bbox optionnelle (Lyon par défaut : lamin,lomin,lamax,lomax)
bbox = [45.5, 4.6, 46.0, 5.2]
# /data/aviation/count <n>
# /data/aviation/plane <icao> <lon> <lat> <alt_m> <vel_ms> <heading_deg>
# -- Pouls numérique -------------------------------------------------------
[feeds.bluesky]
enabled = true
# Jetstream firehose (posts publics WS, JSON décompressé)
ws_url = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post"
sample_rate = 0.02 # garde 2 % des événements pour pas saturer
# /data/social/post <text_len> <lang_hash>
# /data/social/rate <posts_per_sec>
[feeds.mempool]
enabled = false
ws_url = "wss://mempool.space/api/v1/ws"
# /data/btc/tx <value_btc> <fee_sat_vb>
# /data/btc/block <height> <tx_count> <reward_btc>
[feeds.github]
enabled = false
url = "https://api.github.com/events"
poll_seconds = 30
# /data/github/event <type_hash> <repo_hash>
# -- Espace / GCN ----------------------------------------------------------
[feeds.gcn]
enabled = false
# GCN Classic over Kafka : nécessite credentials.
# Voir https://gcn.nasa.gov/quickstart pour générer un token.
client_id = ""
client_secret = ""
# /data/gcn/alert <mission_hash> <ra_deg> <dec_deg> <error_arcmin>
View File
+49
View File
@@ -0,0 +1,49 @@
"""Helpers communs aux feeds."""
from __future__ import annotations
import collections
import time
from typing import Iterable
class RateMeter:
"""Compte les événements sur une fenêtre glissante (en secondes)."""
def __init__(self, window: float = 60.0) -> None:
self.window = window
self._events: collections.deque[float] = collections.deque()
def tick(self) -> int:
now = time.monotonic()
self._events.append(now)
while self._events and now - self._events[0] > self.window:
self._events.popleft()
return len(self._events)
@property
def rate(self) -> float:
return len(self._events) / max(self.window, 1e-6)
def djb2(s: str) -> int:
"""Hash stable 0..65535 pour transformer une string en float OSC."""
h = 5381
for c in s.encode("utf-8", errors="ignore"):
h = ((h << 5) + h + c) & 0xFFFF
return h
def fnorm(x: float, lo: float, hi: float) -> float:
if hi <= lo:
return 0.0
return max(0.0, min(1.0, (x - lo) / (hi - lo)))
def safe_get(d: dict, path: Iterable[str], default=None):
cur = d
for k in path:
if isinstance(cur, dict) and k in cur:
cur = cur[k]
else:
return default
return cur
+48
View File
@@ -0,0 +1,48 @@
"""Blitzortung / LightningMaps — impacts de foudre temps réel.
Protocole : à la connexion, envoyer `{"a":111}` (handshake LightningMaps).
Chaque message JSON contient { time, lat, lon, mds (multiplicity)... }.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import websockets
from ._util import RateMeter
LOG = logging.getLogger("feed.blitzortung")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
rate = RateMeter(window=60.0)
while True:
try:
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
await ws.send(json.dumps({"a": 111}))
LOG.info("connected %s", url)
async for raw in ws:
try:
d = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
lat = float(d.get("lat", 0.0))
lon = float(d.get("lon", 0.0))
# time est en ns Unix ; on calcule un age en secondes
t_ns = d.get("time", 0)
age = 0.0
if isinstance(t_ns, (int, float)) and t_ns > 0:
age = max(0.0, time.time() - t_ns / 1e9)
mult = int(d.get("mds") or 1)
ctx.send("strike", lat, lon, age, mult)
rate.tick()
if rate._events: # noqa: SLF001
ctx.send("rate", rate.rate * 60.0)
except Exception as e: # noqa: BLE001
LOG.warning("ws disconnected: %s — reconnecting", e)
await asyncio.sleep(5.0)
+46
View File
@@ -0,0 +1,46 @@
"""Bluesky Jetstream — firehose des posts publics (WebSocket JSON)."""
from __future__ import annotations
import asyncio
import json
import logging
import random
import time
import websockets
from ._util import RateMeter, djb2
LOG = logging.getLogger("feed.bluesky")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
sample = float(cfg.get("sample_rate", 0.02))
rate = RateMeter(window=10.0)
last_rate_emit = 0.0
while True:
try:
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
LOG.info("connected jetstream (sample %.0f%%)", sample * 100)
async for raw in ws:
rate.tick()
now = time.monotonic()
if now - last_rate_emit > 1.0:
ctx.send("rate", rate.rate)
last_rate_emit = now
if random.random() > sample:
continue
try:
d = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
commit = d.get("commit") or {}
rec = commit.get("record") or {}
text = rec.get("text") or ""
lang = (rec.get("langs") or ["?"])[0]
ctx.send("post", float(len(text)), float(djb2(lang)))
except Exception as e: # noqa: BLE001
LOG.warning("ws disconnected: %s — reconnecting", e)
await asyncio.sleep(3.0)
+66
View File
@@ -0,0 +1,66 @@
"""GCN Classic over Kafka — alertes astrophysiques (GRB, GW, neutrinos).
Nécessite des credentials Kafka. Voir https://gcn.nasa.gov/quickstart.
Cette implémentation est volontairement minimale : on extrait ra/dec/error.
"""
from __future__ import annotations
import asyncio
import logging
LOG = logging.getLogger("feed.gcn")
async def run(ctx) -> None:
cfg = ctx.cfg
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
if not (cid and csec):
LOG.warning("GCN credentials manquants — feed inactif (voir gcn.nasa.gov/quickstart)")
await asyncio.Event().wait()
return
try:
from gcn_kafka import Consumer # type: ignore
except ModuleNotFoundError:
LOG.error("`gcn-kafka` non installé. uv add gcn-kafka")
await asyncio.Event().wait()
return
from ._util import djb2
cons = Consumer(client_id=cid, client_secret=csec)
cons.subscribe([
"gcn.classic.text.SWIFT_BAT_GRB_POS_ACK",
"gcn.classic.text.FERMI_GBM_FLT_POS",
"gcn.classic.text.LVC_INITIAL",
"gcn.classic.text.ICECUBE_ASTROTRACK_GOLD",
])
LOG.info("subscribed GCN classic streams")
loop = asyncio.get_running_loop()
def _poll():
return cons.consume(num_messages=10, timeout=1.0)
while True:
msgs = await loop.run_in_executor(None, _poll)
for m in msgs or []:
if m.error():
continue
txt = m.value().decode("utf-8", "ignore")
ra, dec, err = _parse(txt)
ctx.send("alert", float(djb2(m.topic())), ra, dec, err)
def _parse(txt: str) -> tuple[float, float, float]:
ra = dec = err = 0.0
for line in txt.splitlines():
l = line.lower()
try:
if "ra:" in l and ra == 0.0:
ra = float(line.split(":", 1)[1].split()[0])
elif "dec:" in l and dec == 0.0:
dec = float(line.split(":", 1)[1].split()[0])
elif "error" in l and "arcmin" in l and err == 0.0:
err = float(line.split(":", 1)[1].split()[0])
except (ValueError, IndexError):
continue
return ra, dec, err
+37
View File
@@ -0,0 +1,37 @@
"""GitHub public events — firehose dev mondial (polling REST anonyme)."""
from __future__ import annotations
import asyncio
import logging
import httpx
from ._util import djb2
LOG = logging.getLogger("feed.github")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["url"]
period = float(cfg.get("poll_seconds", 30))
last_id = ""
async with httpx.AsyncClient(timeout=20.0,
headers={"Accept": "application/vnd.github+json"}) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
for ev in reversed(r.json()):
eid = ev.get("id", "")
if eid <= last_id:
continue
ctx.send(
"event",
float(djb2(ev.get("type", "?"))),
float(djb2(((ev.get("repo") or {}).get("name") or "?"))),
)
last_id = eid
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
+43
View File
@@ -0,0 +1,43 @@
"""mempool.space — Bitcoin txs and blocks (WebSocket)."""
from __future__ import annotations
import asyncio
import json
import logging
import websockets
LOG = logging.getLogger("feed.mempool")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
while True:
try:
async with websockets.connect(url, ping_interval=20, max_size=2**21) as ws:
await ws.send(json.dumps({"action": "want", "data": ["mempool-blocks", "blocks", "live-2h-chart"]}))
LOG.info("connected mempool.space")
async for raw in ws:
try:
d = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if "block" in d:
b = d["block"] or {}
ctx.send(
"block",
float(b.get("height", 0)),
float(b.get("tx_count", 0)),
float(b.get("extras", {}).get("reward", 0) or 0) / 1e8,
)
if "transactions" in d:
for tx in (d["transactions"] or [])[:5]:
ctx.send(
"tx",
float(tx.get("value", 0)) / 1e8,
float(tx.get("fee", 0)) / max(1.0, float(tx.get("vsize", 1))),
)
except Exception as e: # noqa: BLE001
LOG.warning("ws disconnected: %s — reconnecting", e)
await asyncio.sleep(5.0)
+49
View File
@@ -0,0 +1,49 @@
"""Fréquence du réseau électrique européen — WebSocket Mainsfrequenz.de.
Format payload (texte) : "f=49.987 t=2026-05-11T06:42:00Z" environ.
Le serveur peut changer ; on parse defensively et on extrait `f`.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
import websockets
LOG = logging.getLogger("feed.netzfrequenz")
_RE_F = re.compile(r"f\s*=\s*([0-9]+\.[0-9]+)")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["ws_url"]
time_dev = 0.0 # dérive intégrée (secondes)
last_t = time.monotonic()
while True:
try:
async with websockets.connect(url, ping_interval=20) as ws:
LOG.info("connected %s", url)
async for msg in ws:
text = msg if isinstance(msg, str) else msg.decode("utf-8", "ignore")
m = _RE_F.search(text)
if not m:
continue
try:
f = float(m.group(1))
except ValueError:
continue
now = time.monotonic()
dt = now - last_t
last_t = now
delta = f - 50.0
# Intégration : 1 s réelle à 49.5 Hz → -0.01 s d'horloge
time_dev += (delta / 50.0) * dt
ctx.send("freq", f)
ctx.send("dev", delta)
ctx.send("time_dev", time_dev)
except Exception as e: # noqa: BLE001
LOG.warning("ws disconnected: %s — reconnecting", e)
await asyncio.sleep(3.0)
+48
View File
@@ -0,0 +1,48 @@
"""OpenSky Network — ADS-B aircraft states (REST polling, anon ≤ 15 s)."""
from __future__ import annotations
import asyncio
import logging
import httpx
LOG = logging.getLogger("feed.opensky")
async def run(ctx) -> None:
cfg = ctx.cfg
base = cfg["url"]
period = float(cfg.get("poll_seconds", 15))
bbox = cfg.get("bbox") # [lamin, lomin, lamax, lomax]
params = None
if bbox and len(bbox) == 4:
params = {
"lamin": bbox[0], "lomin": bbox[1],
"lamax": bbox[2], "lomax": bbox[3],
}
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r = await cli.get(base, params=params)
r.raise_for_status()
data = r.json()
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
continue
states = data.get("states") or []
ctx.send("count", float(len(states)))
for s in states:
# index: 0=icao24, 5=lon, 6=lat, 7=baro_alt, 9=velocity, 10=heading
try:
icao = (s[0] or "?")[:8]
lon = float(s[5]) if s[5] is not None else 0.0
lat = float(s[6]) if s[6] is not None else 0.0
alt = float(s[7]) if s[7] is not None else 0.0
vel = float(s[9]) if s[9] is not None else 0.0
head = float(s[10]) if s[10] is not None else 0.0
except (IndexError, TypeError, ValueError):
continue
ctx.send("plane", icao, lon, lat, alt, vel, head)
await asyncio.sleep(period)
+61
View File
@@ -0,0 +1,61 @@
"""RTE éCO2mix — mix électrique France (OAuth2 client_credentials)."""
from __future__ import annotations
import asyncio
import logging
import time
import httpx
LOG = logging.getLogger("feed.rte_eco2mix")
TOKEN_URL = "https://digital.iservices.rte-france.com/token/oauth/"
API_URL = "https://digital.iservices.rte-france.com/open_api/actual_generation/v1/actual_generations_per_production_type"
async def _get_token(cli: httpx.AsyncClient, cid: str, csec: str) -> tuple[str, float]:
r = await cli.post(TOKEN_URL, auth=(cid, csec),
data={"grant_type": "client_credentials"})
r.raise_for_status()
j = r.json()
return j["access_token"], time.monotonic() + float(j.get("expires_in", 7200)) - 60
async def run(ctx) -> None:
cfg = ctx.cfg
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
period = float(cfg.get("poll_seconds", 900))
if not (cid and csec):
LOG.warning("client_id/client_secret manquants — feed inactif")
await asyncio.Event().wait()
return
token, exp = "", 0.0
async with httpx.AsyncClient(timeout=30.0) as cli:
while True:
try:
if time.monotonic() > exp:
token, exp = await _get_token(cli, cid, csec)
r = await cli.get(API_URL, headers={"Authorization": f"Bearer {token}"})
r.raise_for_status()
j = r.json()
latest = {}
for series in (j.get("actual_generations_per_production_type") or []):
typ = series.get("production_type", "?")
vals = series.get("values") or []
if vals:
latest[typ] = float(vals[-1].get("value", 0.0))
# mapping standard RTE → ordre des args
ctx.send(
"mix",
latest.get("NUCLEAR", 0.0),
latest.get("GAS", 0.0),
latest.get("COAL", 0.0),
latest.get("OIL", 0.0),
latest.get("HYDRO", 0.0),
latest.get("WIND", 0.0),
latest.get("SOLAR", 0.0),
latest.get("BIOENERGY", 0.0),
)
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
+90
View File
@@ -0,0 +1,90 @@
"""NOAA SWPC — vent solaire, IMF Bz, indice Kp, X-ray flux GOES."""
from __future__ import annotations
import asyncio
import logging
import math
import httpx
LOG = logging.getLogger("feed.swpc")
def _last_row(data) -> list | None:
if not data or len(data) < 2:
return None
return data[-1]
def _flare_class_norm(long_wm2: float) -> float:
"""Mappe le X-ray long band en classe normalisée 0..1.
A=1e-8, B=1e-7, C=1e-6, M=1e-5, X=1e-4. log10 → [0..1] sur A→X.
"""
if long_wm2 <= 0:
return 0.0
return max(0.0, min(1.0, (math.log10(long_wm2) + 8.0) / 4.0))
async def _fetch_json(cli: httpx.AsyncClient, url: str):
r = await cli.get(url)
r.raise_for_status()
return r.json()
async def run(ctx) -> None:
cfg = ctx.cfg
period = float(cfg.get("poll_seconds", 60))
urls = {
"plasma": cfg.get("url_plasma"),
"mag": cfg.get("url_mag"),
"kp": cfg.get("url_kp"),
"xray": cfg.get("url_xray"),
}
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
if urls["plasma"]:
j = await _fetch_json(cli, urls["plasma"])
row = _last_row(j)
if row:
# ["time_tag","density","speed","temperature"]
try:
density = float(row[1])
speed = float(row[2])
temp = float(row[3])
ctx.send("wind", speed, density, temp)
except (TypeError, ValueError):
pass
if urls["mag"]:
j = await _fetch_json(cli, urls["mag"])
row = _last_row(j)
if row:
# ["time_tag","bx_gsm","by_gsm","bz_gsm","lon_gsm","lat_gsm","bt"]
try:
bz = float(row[3])
bt = float(row[6])
ctx.send("bz", bz, bt)
except (TypeError, ValueError):
pass
if urls["kp"]:
j = await _fetch_json(cli, urls["kp"])
row = _last_row(j)
if row:
# ["time_tag","Kp","a_running","station_count"]
try:
kp = float(row[1])
a = float(row[2])
ctx.send("kp", kp, a)
except (TypeError, ValueError):
pass
if urls["xray"]:
j = await _fetch_json(cli, urls["xray"])
# split short/long bands
short = next((d for d in reversed(j) if d.get("energy") == "0.05-0.4nm"), None)
long_ = next((d for d in reversed(j) if d.get("energy") == "0.1-0.8nm"), None)
s = float(short.get("flux", 0.0)) if short else 0.0
l = float(long_.get("flux", 0.0)) if long_ else 0.0
ctx.send("xray", s, l, _flare_class_norm(l))
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
+51
View File
@@ -0,0 +1,51 @@
"""USGS earthquakes — GeoJSON polling (1 min)."""
from __future__ import annotations
import asyncio
import logging
import time
import httpx
from ._util import RateMeter
LOG = logging.getLogger("feed.usgs")
async def run(ctx) -> None:
cfg = ctx.cfg
url = cfg["url"]
period = float(cfg.get("poll_seconds", 60))
seen: set[str] = set()
rate = RateMeter(window=3600.0)
async with httpx.AsyncClient(timeout=20.0) as cli:
while True:
try:
r = await cli.get(url)
r.raise_for_status()
data = r.json()
except Exception as e: # noqa: BLE001
LOG.warning("fetch failed: %s", e)
await asyncio.sleep(period)
continue
now_ms = time.time() * 1000.0
for feat in data.get("features", []):
fid = feat.get("id")
if not fid or fid in seen:
continue
seen.add(fid)
props = feat.get("properties") or {}
coords = (feat.get("geometry") or {}).get("coordinates") or [0, 0, 0]
mag = float(props.get("mag") or 0.0)
t_ms = float(props.get("time") or now_ms)
age = max(0.0, (now_ms - t_ms) / 1000.0)
ctx.send("event", mag, float(coords[0]), float(coords[1]),
float(coords[2]), age)
rate.tick()
ctx.send("rate", rate.rate * 3600.0)
# garde la mémoire bornée
if len(seen) > 4096:
seen = set(list(seen)[-2048:])
await asyncio.sleep(period)
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "av-live-data-feeds"
version = "0.1.0"
description = "Real-world data → OSC bridge for AV-Live (SuperCollider + openFrameworks)"
requires-python = ">=3.11"
dependencies = [
"python-osc>=1.8.3",
"httpx>=0.27",
"websockets>=12.0",
"aiomqtt>=2.3",
"tomli>=2.0;python_version<'3.11'",
"skyfield>=1.49",
]
[tool.uv]
package = false
+69
View File
@@ -1,9 +1,15 @@
#include "OscClient.h"
#include "ofLog.h"
#include "ofUtils.h"
namespace oscope {
namespace {
constexpr const char* kDataPrefix = "/data/";
}
void OscClient::setup(int listenPort, const std::string& sendHost, int sendPort) {
receiver_.setup(listenPort);
sender_.setup(sendHost, sendPort);
@@ -42,10 +48,73 @@ void OscClient::update() {
} else if (a == "/oscope/glitch" && m.getNumArgs() >= 1) {
pendingGlitchPulse_ = m.getArgAsFloat(0);
hasGlitchPulse_ = true;
} else if (a.rfind(kDataPrefix, 0) == 0) {
// /data/heartbeat ou /data/<source>/<sub>
if (a == "/data/heartbeat") {
lastHeartbeat_ = ofGetElapsedTimef();
} else {
storeData(a, m);
}
}
}
}
void OscClient::storeData(const std::string& addr, const ofxOscMessage& m) {
// strip "/data/" prefix → key = "source/sub"
std::string key = addr.substr(6);
if (key.empty()) return;
auto& slot = data_[key];
slot.last.clear();
slot.last.reserve(m.getNumArgs());
for (std::size_t i = 0; i < (std::size_t)m.getNumArgs(); ++i) {
// Les flux poussent surtout des floats ; on tente string→hash
// pour rester homogene. Les ints sont remontes en float aussi.
auto t = m.getArgType(i);
if (t == OFXOSC_TYPE_FLOAT) {
slot.last.push_back(m.getArgAsFloat(i));
} else if (t == OFXOSC_TYPE_INT32) {
slot.last.push_back((float)m.getArgAsInt32(i));
} else if (t == OFXOSC_TYPE_DOUBLE) {
slot.last.push_back((float)m.getArgAsDouble(i));
} else if (t == OFXOSC_TYPE_STRING) {
// hash djb2 16 bits pour cohérence avec le pont Python
const auto& s = m.getArgAsString(i);
std::uint32_t h = 5381;
for (char c : s) h = ((h << 5) + h + (unsigned char)c) & 0xFFFF;
slot.last.push_back((float)h);
}
}
slot.pending = true;
}
const std::vector<float>& OscClient::data(const std::string& source,
const std::string& sub) const {
static const std::vector<float> empty;
auto it = data_.find(source + "/" + sub);
return (it == data_.end()) ? empty : it->second.last;
}
float OscClient::dataf(const std::string& source, const std::string& sub,
float fallback, std::size_t index) const {
const auto& v = data(source, sub);
return (index < v.size()) ? v[index] : fallback;
}
bool OscClient::dataAlive() const {
return lastHeartbeat_ >= 0.0 &&
(ofGetElapsedTimef() - lastHeartbeat_) < 15.0f;
}
bool OscClient::consumeDataPulse(const std::string& source,
const std::string& sub,
std::vector<float>& outArgs) {
auto it = data_.find(source + "/" + sub);
if (it == data_.end() || !it->second.pending) return false;
outArgs = it->second.last;
it->second.pending = false;
return true;
}
float OscClient::fx(const std::string& name, float fallback) const {
auto it = fx_.find(name);
return (it == fx_.end()) ? fallback : it->second;
+34
View File
@@ -16,10 +16,17 @@
#include "ofxOsc.h"
#include <deque>
#include <string>
#include <unordered_map>
#include <vector>
// En complement, ce client recoit aussi les flux /data/<source>/<sub>
// emis par data_feeds/bridge.py. Les arguments numeriques de chaque
// message sont conserves dans un vector accessible via data(source, sub).
// Le dernier evenement est aussi disponible comme "pulse" consommable
// (events de foudre, transactions BTC, posts Bluesky, etc.).
namespace oscope {
class OscClient {
@@ -52,7 +59,30 @@ public:
void sendControl(const std::string& addr, float value);
void sendControl(const std::string& addr, const std::string& value);
/// ----- Flux temps reel externes (data_feeds bridge) -----
/// Acces direct au dernier tuple recu sur /data/<source>/<sub>.
/// Renvoie vide si rien n'a encore ete recu.
const std::vector<float>& data(const std::string& source,
const std::string& sub) const;
/// Helper : premier arg float du dernier tuple, avec fallback.
float dataf(const std::string& source, const std::string& sub,
float fallback = 0.0f, std::size_t index = 0) const;
/// Heartbeat du pont Python (true si recu il y a < 15 s).
bool dataAlive() const;
/// Pulse pour les flux event-based : retourne true UNE fois si un
/// nouvel evenement /data/<source>/<sub> est arrive depuis le
/// dernier appel, et remplit `outArgs` avec ses arguments float.
bool consumeDataPulse(const std::string& source, const std::string& sub,
std::vector<float>& outArgs);
private:
struct DataSlot {
std::vector<float> last;
bool pending = false;
};
DataSlot* dataSlot(const std::string& key);
void storeData(const std::string& addr, const ofxOscMessage& m);
ofxOscReceiver receiver_;
ofxOscSender sender_;
@@ -67,6 +97,10 @@ private:
std::string album_;
std::string melody_;
std::string synthdef_;
// Flux /data/<source>/<sub>. Cle = "<source>/<sub>".
std::unordered_map<std::string, DataSlot> data_;
double lastHeartbeat_ = -1.0;
};
} // namespace oscope
+208
View File
@@ -0,0 +1,208 @@
// =====================================================================
// data_feeds.scd -- Reception OSC des flux temps reel externes.
//
// Lance le pont : cd data_feeds && uv run python bridge.py
//
// Architecture :
// Python (USGS, SWPC, Netzfrequenz, Blitzortung, OpenSky, Bluesky, ...)
// \--OSC--> 57121 (sclang) ET 57123 (oF)
//
// Toutes les valeurs sont stockees dans ~feeds (IdentityDictionary).
// Lire : ~feeds[\netz_freq] ?? 50.0
// Mapper:~feeds[\netz_dev].linlin(-0.2, 0.2, -100, 100)
//
// Les definitions sont idempotentes : on peut relire le fichier sans
// accumuler des OSCdef fantomes.
// =====================================================================
(
~feeds = ~feeds ? IdentityDictionary.new;
~feeds[\__t0] = SystemClock.seconds;
// ---- helper de pose : ferme + recree -----------------------------
~_feedDef = { |key, path, fn|
OSCdef(key).free;
OSCdef(key, fn, path);
};
// ---- bus de notification : permet aux abonnes (synths, patterns,
// visus) de reagir a un changement specifique.
// ~feedSub.(\netz_freq, { |val| ... }) renvoie une cle de retrait.
~feedListeners = ~feedListeners ? IdentityDictionary.new;
~feedSub = { |key, action|
var arr = ~feedListeners[key] ? Array.new;
~feedListeners[key] = arr.add(action);
action;
};
~feedUnsub = { |key, action|
var arr = ~feedListeners[key];
if(arr.notNil) { ~feedListeners[key] = arr.reject(_ == action) };
};
~_feedNotify = { |key, val|
(~feedListeners[key] ? #[]).do { |fn| fn.value(val) };
};
~feedSet = { |key, val| ~feeds[key] = val; ~_feedNotify.(key, val) };
// =====================================================================
// USGS -- seismicite
// =====================================================================
// /data/usgs/event <mag> <lon> <lat> <depth> <age_sec>
~_feedDef.(\d_usgs_event, '/data/usgs/event', { |msg|
var mag = msg[1], lon = msg[2], lat = msg[3], depth = msg[4], age = msg[5];
~feedSet.(\usgs_last_mag, mag);
~feedSet.(\usgs_last_pos, [lon, lat]);
~feedSet.(\usgs_last_age, age);
~feedSet.(\usgs_last_depth, depth);
});
~_feedDef.(\d_usgs_rate, '/data/usgs/rate', { |msg|
~feedSet.(\usgs_rate_h, msg[1]);
});
// =====================================================================
// SWPC -- vent solaire, IMF, Kp, X-ray
// =====================================================================
// /data/swpc/wind <speed_kms> <density_pcm3> <temp_K>
~_feedDef.(\d_swpc_wind, '/data/swpc/wind', { |msg|
~feedSet.(\swpc_wind_speed, msg[1]);
~feedSet.(\swpc_wind_dens, msg[2]);
~feedSet.(\swpc_wind_temp, msg[3]);
});
~_feedDef.(\d_swpc_bz, '/data/swpc/bz', { |msg|
~feedSet.(\swpc_bz, msg[1]);
~feedSet.(\swpc_bt, msg[2]);
});
~_feedDef.(\d_swpc_kp, '/data/swpc/kp', { |msg|
~feedSet.(\swpc_kp, msg[1]);
~feedSet.(\swpc_a, msg[2]);
});
~_feedDef.(\d_swpc_xray, '/data/swpc/xray', { |msg|
~feedSet.(\swpc_xray_short, msg[1]);
~feedSet.(\swpc_xray_long, msg[2]);
~feedSet.(\swpc_flare_norm, msg[3]); // 0..1 (A->X)
});
// =====================================================================
// GRID -- frequence du reseau 50 Hz
// =====================================================================
// /data/netzfrequenz/freq <hz>
~_feedDef.(\d_grid_freq, '/data/netzfrequenz/freq', { |msg|
~feedSet.(\netz_freq, msg[1]);
});
~_feedDef.(\d_grid_dev, '/data/netzfrequenz/dev', { |msg|
~feedSet.(\netz_dev, msg[1]);
});
~_feedDef.(\d_grid_tdev, '/data/netzfrequenz/time_dev', { |msg|
~feedSet.(\netz_time_dev, msg[1]);
});
// =====================================================================
// RTE eCO2mix -- mix electrique France (MW par filiere)
// =====================================================================
~_feedDef.(\d_rte_mix, '/data/rte_eco2mix/mix', { |msg|
var keys = [\nuclear, \gas, \coal, \oil, \hydro, \wind, \solar, \bio];
var total = 0.0;
keys.do { |k, i| var v = msg[i+1] ? 0.0; ~feeds[(\rte_ ++ k).asSymbol] = v; total = total + v };
~feedSet.(\rte_total, total);
// parts renouvelables et carbone tres simple
~feedSet.(\rte_renew_pct,
if(total > 0) {
(~feeds[\rte_hydro] + ~feeds[\rte_wind] + ~feeds[\rte_solar] + ~feeds[\rte_bio]) / total
} { 0.0 }
);
});
// =====================================================================
// BLITZORTUNG -- impacts de foudre
// =====================================================================
~_feedDef.(\d_lightning_strike, '/data/blitzortung/strike', { |msg|
var lat = msg[1], lon = msg[2], age = msg[3], mult = msg[4];
~feedSet.(\lightning_last, [lat, lon, age, mult]);
});
~_feedDef.(\d_lightning_rate, '/data/blitzortung/rate', { |msg|
~feedSet.(\lightning_rate_min, msg[1]);
});
// =====================================================================
// OPENSKY -- ADS-B
// =====================================================================
~_feedDef.(\d_aviation_count, '/data/opensky/count', { |msg|
~feedSet.(\aviation_count, msg[1]);
});
~_feedDef.(\d_aviation_plane, '/data/opensky/plane', { |msg|
var icao = msg[1], lon = msg[2], lat = msg[3], alt = msg[4], vel = msg[5], head = msg[6];
~feedSet.(\aviation_last, [icao, lon, lat, alt, vel, head]);
});
// =====================================================================
// BLUESKY JETSTREAM -- pouls social
// =====================================================================
~_feedDef.(\d_social_post, '/data/bluesky/post', { |msg|
~feedSet.(\social_last_len, msg[1]);
~feedSet.(\social_last_lang, msg[2]);
});
~_feedDef.(\d_social_rate, '/data/bluesky/rate', { |msg|
~feedSet.(\social_rate_s, msg[1]);
});
// =====================================================================
// MEMPOOL -- Bitcoin
// =====================================================================
~_feedDef.(\d_btc_tx, '/data/mempool/tx', { |msg|
~feedSet.(\btc_last_val, msg[1]);
~feedSet.(\btc_last_fee, msg[2]);
});
~_feedDef.(\d_btc_block, '/data/mempool/block', { |msg|
~feedSet.(\btc_height, msg[1]);
~feedSet.(\btc_block_tx, msg[2]);
~feedSet.(\btc_block_reward, msg[3]);
});
// =====================================================================
// GITHUB
// =====================================================================
~_feedDef.(\d_gh_event, '/data/github/event', { |msg|
~feedSet.(\gh_last_type, msg[1]);
~feedSet.(\gh_last_repo, msg[2]);
});
// =====================================================================
// GCN -- alertes astrophysiques (rares)
// =====================================================================
~_feedDef.(\d_gcn_alert, '/data/gcn/alert', { |msg|
~feedSet.(\gcn_last,
[msg[1], msg[2], msg[3], msg[4]] // [mission_hash, ra, dec, err_arcmin]
);
"*** GCN alert ***".postln; msg.postln;
});
// =====================================================================
// Heartbeat -- detection de pont down
// =====================================================================
~_feedDef.(\d_heartbeat, '/data/heartbeat', { |msg|
~feeds[\__last_heartbeat] = SystemClock.seconds;
});
// =====================================================================
// Helpers de lecture
// =====================================================================
~feedGet = { |key, fallback = 0.0|
var v = ~feeds[key];
if(v.isNil) { fallback } { v }
};
~feedAlive = {
var hb = ~feeds[\__last_heartbeat];
hb.notNil and: { (SystemClock.seconds - hb) < 15 }
};
~feedDump = {
~feeds.keysValuesDo { |k, v|
if(k.asString.beginsWith("__").not) {
(" " ++ k.asString.padRight(22) ++ " = " ++ v).postln
}
};
("[feeds] heartbeat: " ++ if(~feedAlive.value) { "ALIVE" } { "DOWN" }).postln;
};
"[data_feeds] OSCdef installes (USGS, SWPC, NETZ, RTE, BLITZ, OPENSKY, BSKY, MEMPOOL, GCN).".postln;
"[data_feeds] usage : ~feeds[\\swpc_kp], ~feedGet.(\\netz_dev, 0), ~feedDump.()".postln;
)
+218
View File
@@ -0,0 +1,218 @@
// =====================================================================
// 16_data_feeds.scd -- Pilotage musical par flux temps reel.
//
// Prerequis :
// [0] Bloc d'init de 01_live.scd (engine + setupAll + wrappers)
// [1] Pont Python lance :
// cd data_feeds && uv run python bridge.py
// [2] OSCdef installes :
// ("control/data_feeds.scd").loadRelative
//
// Trois presets ci-dessous, autonomes, a executer un par un dans l'IDE.
// =====================================================================
// ---------------------------------------------------------------------
// [0] Sanity-check : afficher l'etat des flux
// ---------------------------------------------------------------------
(
"sound_algo/control/data_feeds.scd".loadRelative;
fork {
"[16] verification du pont...".postln;
5.do { ~feedDump.value; 2.wait };
};
)
// ---------------------------------------------------------------------
// [1] PRESET A -- "Cavity"
// Schumann (drone harmonique) x Netzfrequenz (modulation FM subtile)
// x Blitzortung (percussions spatialisees).
// Trois echelles temporelles, trois ordres de grandeur en frequence.
// ---------------------------------------------------------------------
(
SynthDef(\cavity_drone, { |out=0, freq=7.83, amp=0.25, fm=0, pan=0|
var sig, harm = freq * [1, 2, 3, 4, 5];
var amps = [1, 0.5, 0.33, 0.25, 0.2];
sig = SinOsc.ar(harm + fm.lag(0.05), 0, amps).sum;
sig = sig + (LFTri.ar(freq * 0.5) * 0.1);
sig = LeakDC.ar(sig);
sig = sig.tanh * amp;
Out.ar(out, Pan2.ar(sig, pan));
}).add;
SynthDef(\strike_grain, { |out=0, freq=400, amp=0.5, pan=0, dur=0.4|
var env = EnvGen.kr(Env.perc(0.001, dur), doneAction: 2);
var sig = WhiteNoise.ar * env;
sig = BPF.ar(sig, freq, 0.05) * 6;
sig = sig + (SinOsc.ar(freq, 0, env * 0.5));
Out.ar(out, Pan2.ar(sig.tanh * amp, pan));
}).add;
// laisse le serveur compiler
s.sync;
~cavity = Synth(\cavity_drone, [\freq, 7.83, \amp, 0.22]);
// Mod FM par derive de la frequence reseau (dev ~ +-0.05 Hz typique)
~cavityFm = Routine({
inf.do {
var dev = ~feedGet.(\netz_dev, 0);
~cavity.set(\fm, dev * 80); // amplification non-lineaire
0.1.wait;
};
}).play(AppClock);
// Foudre = grain percussif spatialise
~strikeSub = ~feedSub.(\lightning_last, { |val|
var lat = val[0], lon = val[1], age = val[2], mult = val[3];
if(age < 30) {
var pan = lon.linlin(-180, 180, -1, 1);
var freq = lat.linlin(-90, 90, 200, 3000);
Synth(\strike_grain, [
\freq, freq, \pan, pan,
\amp, mult.linlin(1, 10, 0.4, 0.9),
\dur, mult.linlin(1, 10, 0.2, 0.8),
]);
};
});
"[cavity] up. arret : ~cavityStop.value".postln;
~cavityStop = {
~cavity.release(2);
~cavityFm.stop;
~feedUnsub.(\lightning_last, ~strikeSub);
"[cavity] off".postln;
};
)
// ---------------------------------------------------------------------
// [2] PRESET B -- "Mix"
// eCO2mix (RTE) regle la couleur harmonique : plus de renouvelable
// eclaire le filtre, plus de fossile l'assombrit.
// OpenSky donne des impulsions melodiques (avions au-dessus de Lyon).
// ---------------------------------------------------------------------
(
SynthDef(\mix_pad, { |out=0, freq=110, amp=0.3, cutoff=800, q=0.3, pan=0|
var sig = Mix(Saw.ar(freq * [1, 1.005, 0.503, 2.01]));
sig = RLPF.ar(sig, cutoff.lag(0.5).clip(80, 8000), q);
sig = sig * amp;
Out.ar(out, Pan2.ar(sig, pan));
}).add;
SynthDef(\plane_blip, { |out=0, freq=600, amp=0.3, pan=0|
var env = EnvGen.kr(Env.perc(0.01, 0.6), doneAction: 2);
var sig = SinOsc.ar(freq * [1, 2.01], 0, [0.7, 0.3]).sum;
sig = sig + (Pulse.ar(freq * 0.501) * env * 0.2);
sig = (sig * env).tanh * amp;
Out.ar(out, Pan2.ar(sig, pan));
}).add;
s.sync;
~mixPad = Synth(\mix_pad, [\freq, 55, \amp, 0.18]);
~mixCutoffR = Routine({
inf.do {
var pct = ~feedGet.(\rte_renew_pct, 0.25);
~mixPad.set(\cutoff, pct.linexp(0.0, 0.5, 200, 5000));
~mixPad.set(\q, pct.linlin(0.0, 0.5, 0.5, 0.15));
2.0.wait;
};
}).play(AppClock);
// chaque update OpenSky -> on tire 1 a 3 blips selon la densite
~planeSub = ~feedSub.(\aviation_last, { |val|
var icao = val[0], lon = val[1], lat = val[2], alt = val[3], vel = val[4];
var pan = lon.linlin(4.6, 5.2, -1, 1);
// altitude -> note ; vitesse -> brillance
var midinote = alt.linlin(0, 12000, 36, 84).clip(24, 96);
Synth(\plane_blip, [
\freq, midinote.midicps,
\pan, pan,
\amp, vel.linlin(50, 300, 0.1, 0.35),
]);
});
"[mix] up. arret : ~mixStop.value".postln;
~mixStop = {
~mixPad.release(3);
~mixCutoffR.stop;
~feedUnsub.(\aviation_last, ~planeSub);
"[mix] off".postln;
};
)
// ---------------------------------------------------------------------
// [3] PRESET C -- "Geomagnetic"
// Kp (geomagnetisme) ouvre/ferme un filtre passe-bande.
// X-ray flares declenchent un drop dramatique.
// USGS earthquakes -> sub-bass ponctuel proportionnel a la magnitude.
// ---------------------------------------------------------------------
(
SynthDef(\geo_bus, { |out=0, freq=200, amp=0.4, bw=0.4|
var sig = Mix(Saw.ar([55, 110, 165]));
sig = BPF.ar(sig, freq.lag(0.3).clip(80, 6000), bw.lag(0.3).clip(0.05, 1));
sig = sig * 1.5;
Out.ar(out, Splay.ar([sig, DelayN.ar(sig, 0.05, 0.03)]) * amp);
}).add;
SynthDef(\quake_sub, { |out=0, freq=40, amp=0.7, dur=2|
var env = EnvGen.kr(Env.perc(0.05, dur, 1, -2), doneAction: 2);
var sig = SinOsc.ar(freq) * env;
sig = sig + (LFTri.ar(freq * 0.5) * env * 0.3);
sig = sig.tanh * amp;
Out.ar(out, Pan2.ar(sig, 0));
}).add;
s.sync;
~geo = Synth(\geo_bus, [\amp, 0.25]);
~geoKpR = Routine({
inf.do {
var kp = ~feedGet.(\swpc_kp, 2);
// Kp 0..9 -> ouvre 200..3000 Hz
~geo.set(\freq, kp.linexp(0, 9, 200, 3000));
~geo.set(\bw, kp.linlin(0, 9, 0.5, 0.05));
5.0.wait;
};
}).play(AppClock);
~flareSub = ~feedSub.(\swpc_flare_norm, { |val|
if(val > 0.5) {
// X-class flare : choc
~geo.set(\amp, 0.6);
AppClock.sched(3.0, { ~geo.set(\amp, 0.25); nil });
"*** flare ***".postln;
};
});
~quakeSub = ~feedSub.(\usgs_last_mag, { |val|
Synth(\quake_sub, [
\freq, val.linexp(2, 7, 30, 120),
\dur, val.linlin(2, 7, 1, 5),
\amp, val.linlin(2, 7, 0.4, 0.9),
]);
});
"[geo] up. arret : ~geoStop.value".postln;
~geoStop = {
~geo.release(2);
~geoKpR.stop;
~feedUnsub.(\swpc_flare_norm, ~flareSub);
~feedUnsub.(\usgs_last_mag, ~quakeSub);
"[geo] off".postln;
};
)
// ---------------------------------------------------------------------
// [9] ARRET TOTAL
// ---------------------------------------------------------------------
(
~cavityStop !? { ~cavityStop.value };
~mixStop !? { ~mixStop.value };
~geoStop !? { ~geoStop.value };
)