The room as an instrument. Streams environmental sensors (light/temp/humidity/ pressure/ToF-hand/IMU-shake+tilt/mic-energy + crowd.hit events) to a hub over OSC. Confirms + documents the hardware-agnostic ingest contract: OSC /gs/<group>/<name> <float> -> a live source (already handled by hub._osc_handler; send RAW, the hub auto-scales). Runs with zero sensors (smooth simulated fallbacks) so the pipeline works before any hardware exists; real driver lines (BH1750/BME280/VL53L1X/MPU6050/mic) are stubbed to uncomment. Verified: --selfcheck passes; a live burst to the running hub created sources room.*/hand.height/booth.*/crowd.* end-to-end over the websocket. Any SBC/lang that speaks the OSC contract works identically — Pi4B ready today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
198 lines
8.9 KiB
Python
198 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
local_source.py — the "local source" device firmware for Godstrument.
|
|
|
|
The room becomes an instrument. Run this on a Raspberry Pi (or any laptop) wired
|
|
to environmental sensors; it streams their readings to a Godstrument hub, where
|
|
each one appears as a live SOURCE you can wire to any voice, in any skin.
|
|
|
|
────────────────────────────────────────────────────────────────────────────
|
|
THE CONTRACT (this is the whole thing — any hardware that speaks it works):
|
|
|
|
OSC over UDP → hub osc_in_port (default 9000)
|
|
|
|
address: /gs/<group>/<name> value: one float
|
|
e.g. /gs/room/temp 21.4 → source "room.temp"
|
|
/gs/hand/height 0.62 → source "hand.height"
|
|
|
|
A key ending in ".event" is an IMPULSE (a hit) carrying a magnitude:
|
|
/gs/crowd/hit 0.8 → a triggered burst, magnitude 0.8
|
|
|
|
Send RAW values, not pre-normalized ones. The hub auto-scales each source
|
|
with an adaptive min/max window (add `"norm": {"mode":"minmax"}` in the
|
|
hub's config `signals` for a feed if you want to tune it), so the SAME
|
|
firmware calibrates itself to a dark club or a bright festival tent.
|
|
|
|
That's it. Pick any SBC, any sensors, any language — if it emits that OSC, the
|
|
planet-instrument receives it. This file is just a convenient reference sender.
|
|
────────────────────────────────────────────────────────────────────────────
|
|
|
|
RUN IT (works with NO sensors wired — everything falls back to gentle
|
|
simulated values, so you can prove the pipeline before you own the hardware):
|
|
|
|
python3 local_source.py --host 127.0.0.1 # send to a local hub
|
|
python3 local_source.py --host 192.168.1.42 # send to the laptop at the booth
|
|
python3 local_source.py --rate 30 --once # one burst, then exit (self-check)
|
|
|
|
To receive from a SEPARATE device on the LAN, run the hub with
|
|
`"osc_in_host": "0.0.0.0"` in config.json (so it listens on the network, not
|
|
just localhost), and point --host at the hub machine's IP.
|
|
|
|
WIRING (all I2C unless noted; the readers below are stubbed — uncomment the
|
|
driver lines for the sensors you actually solder on):
|
|
BH1750 lux / light → room.light
|
|
BME280 temp+humidity+press → room.temp, room.humidity, room.pressure
|
|
VL53L1X time-of-flight → hand.height (the theremin over the desk)
|
|
MPU6050 6-DOF IMU → booth.shake (vibration), booth.tilt
|
|
I2S/USB mic amplitude → crowd.energy + crowd.hit (transients)
|
|
"""
|
|
import argparse
|
|
import math
|
|
import time
|
|
|
|
try:
|
|
from pythonosc.udp_client import SimpleUDPClient
|
|
except ImportError:
|
|
raise SystemExit("pip install python-osc (the hub already depends on it)")
|
|
|
|
|
|
# ── sensor readers ──────────────────────────────────────────────────────────
|
|
# Each reader returns a raw float, or None if unavailable. Keep them fast and
|
|
# non-blocking. When the hardware isn't present they raise/return None and we
|
|
# fall back to a smooth simulated value so the pipeline still runs and sings.
|
|
|
|
class Sensors:
|
|
def __init__(self):
|
|
self.i2c = None
|
|
# ── real drivers: uncomment what you wire (needs the matching pip pkgs)
|
|
# import board, busio
|
|
# self.i2c = busio.I2C(board.SCL, board.SDA)
|
|
# import adafruit_bh1750; self.bh1750 = adafruit_bh1750.BH1750(self.i2c)
|
|
# import adafruit_bme280.basic as bme; self.bme = bme.Adafruit_BME280_I2C(self.i2c)
|
|
# import adafruit_vl53l1x; self.tof = adafruit_vl53l1x.VL53L1X(self.i2c); self.tof.start_ranging()
|
|
# import adafruit_mpu6050; self.imu = adafruit_mpu6050.MPU6050(self.i2c)
|
|
# import sounddevice as sd; self.stream = sd.InputStream(channels=1); self.stream.start()
|
|
|
|
def light(self): # lux → keep raw; hub auto-scales the venue's range
|
|
try: return self.bh1750.lux
|
|
except Exception: return None
|
|
|
|
def temp(self):
|
|
try: return self.bme.temperature
|
|
except Exception: return None
|
|
|
|
def humidity(self):
|
|
try: return self.bme.relative_humidity
|
|
except Exception: return None
|
|
|
|
def pressure(self):
|
|
try: return self.bme.pressure
|
|
except Exception: return None
|
|
|
|
def hand_height(self): # ToF distance in mm; near = hand over the desk
|
|
try: return self.tof.distance # cm on adafruit driver
|
|
except Exception: return None
|
|
|
|
def shake(self): # IMU: magnitude of acceleration minus 1g = vibration
|
|
try:
|
|
x, y, z = self.imu.acceleration
|
|
return abs(math.sqrt(x * x + y * y + z * z) - 9.81)
|
|
except Exception: return None
|
|
|
|
def tilt(self):
|
|
try:
|
|
x, y, z = self.imu.acceleration
|
|
return math.degrees(math.atan2(x, z))
|
|
except Exception: return None
|
|
|
|
def crowd_energy(self): # RMS of a short mic block
|
|
try:
|
|
block, _ = self.stream.read(512)
|
|
return float((block.astype("float64") ** 2).mean() ** 0.5)
|
|
except Exception: return None
|
|
|
|
|
|
# ── the source map: (osc address, reader, simulated-fallback shape) ──────────
|
|
# The fallback lets the whole rig run with zero sensors — a living demo. `shape`
|
|
# picks a distinct simulated motion so you can tell the feeds apart on screen.
|
|
FEEDS = [
|
|
("/gs/room/light", "light", "drift"),
|
|
("/gs/room/temp", "temp", "slowrise"),
|
|
("/gs/room/humidity", "humidity", "slowrise"),
|
|
("/gs/room/pressure", "pressure", "drift"),
|
|
("/gs/hand/height", "hand_height", "flux"),
|
|
("/gs/booth/shake", "shake", "flux"),
|
|
("/gs/booth/tilt", "tilt", "drift"),
|
|
("/gs/crowd/energy", "crowd_energy", "pulse"),
|
|
]
|
|
|
|
|
|
def simulate(shape, t, seed):
|
|
"""Gentle stand-in values so the device 'works' before any sensor exists."""
|
|
p = t * 0.5 + seed
|
|
if shape == "drift": return 0.5 + 0.4 * math.sin(p * 0.3)
|
|
if shape == "slowrise": return 0.3 + 0.5 * (0.5 + 0.5 * math.sin(p * 0.08))
|
|
if shape == "flux": return abs(math.sin(p * 1.7) * math.sin(p * 0.9))
|
|
if shape == "pulse": return max(0.0, math.sin(p * 2.3)) ** 3
|
|
return 0.5
|
|
|
|
|
|
def run(host, port, rate, once):
|
|
client = SimpleUDPClient(host, port)
|
|
sensors = Sensors()
|
|
period = 1.0 / max(1, rate)
|
|
t0 = time.monotonic()
|
|
last_energy = 0.0
|
|
live_keys = set()
|
|
print(f"local source → OSC {host}:{port} @ {rate} Hz (Ctrl-C to stop)")
|
|
while True:
|
|
t = time.monotonic() - t0
|
|
energy = None
|
|
for i, (addr, reader, shape) in enumerate(FEEDS):
|
|
val = getattr(sensors, reader)()
|
|
simulated = val is None
|
|
if simulated:
|
|
val = simulate(shape, t, i * 1.37)
|
|
client.send_message(addr, float(val))
|
|
if not simulated:
|
|
live_keys.add(addr)
|
|
if reader == "crowd_energy":
|
|
energy = val
|
|
# transient detector → a hit event when the room's energy jumps
|
|
if energy is not None:
|
|
if energy - last_energy > 0.18:
|
|
client.send_message("/gs/crowd/hit", float(min(1.0, (energy - last_energy) * 3)))
|
|
last_energy = last_energy * 0.85 + energy * 0.15
|
|
if once:
|
|
print(f"sent {len(FEEDS)} feeds once. live sensors: {sorted(live_keys) or 'none (all simulated)'}")
|
|
return
|
|
time.sleep(period)
|
|
|
|
|
|
def _selfcheck():
|
|
"""No hub needed: prove the OSC packets serialize & the sim values are sane."""
|
|
for shape in ("drift", "slowrise", "flux", "pulse"):
|
|
for tt in (0.0, 1.0, 5.0, 20.0):
|
|
v = simulate(shape, tt, 0.0)
|
|
assert 0.0 <= v <= 1.0, f"{shape}@{tt} out of range: {v}"
|
|
# a real client to a dead port must still build a valid packet without error
|
|
SimpleUDPClient("127.0.0.1", 59999).send_message("/gs/room/light", 0.5)
|
|
print("local_source self-check: OK (sim values bounded, OSC packet builds)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ap = argparse.ArgumentParser(description="stream local room sensors to a Godstrument hub over OSC")
|
|
ap.add_argument("--host", default="127.0.0.1", help="hub IP (the machine running Godstrument)")
|
|
ap.add_argument("--port", type=int, default=9000, help="hub osc_in_port")
|
|
ap.add_argument("--rate", type=int, default=30, help="updates per second")
|
|
ap.add_argument("--once", action="store_true", help="send one burst and exit")
|
|
ap.add_argument("--selfcheck", action="store_true", help="validate without a hub")
|
|
a = ap.parse_args()
|
|
if a.selfcheck:
|
|
_selfcheck()
|
|
else:
|
|
try:
|
|
run(a.host, a.port, a.rate, a.once)
|
|
except KeyboardInterrupt:
|
|
print("\nstopped.")
|