From 3ac927bc5f3757165e9de73fb97e364547051657 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Sat, 18 Apr 2026 05:41:54 +0000 Subject: [PATCH] slab: location-aware zones for ambient tuning + session metadata Each lid-close now queries Core Location and resolves the user's current position against geofenced zones defined in ~/.local/share/slab/config/ zones.json. The matching zone overrides the reactive listener's scale, pitch-map divisor, and amp; coords + zone name are written to the session JSONL and appended to the session WAV filename. - slab-zone CLI for pinning/removing/listing zones and showing current location with the resolved zone - 8 built-in scale options (major/minor pentatonic, blues, modes, whole-tone, chromatic); configurable per zone - last-location.json cache for offline fallback - Session JSONL listener_start event carries zone, scale, div_factor, arp_amp, coords, and distance to zone centre Co-Authored-By: Claude Opus 4.7 (1M context) --- slab/README.md | 44 +++++++++++++++++++++++++++++++++++--------- slab/bin/lid-reactive.py | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- slab/bin/slab-zone | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 242 insertion(s)(+), 18 deletion(s)(-) diff --git a/slab/README.md b/slab/README.md --- a/slab/README.md +++ b/slab/README.md @@ -66,7 +66,8 @@ │ ├── claude-stop.sh # Stop-hook entry │ ├── claude-ping-repeat.sh # repeating 'done' pings │ ├── claude-sleep-schedule.sh # delayed auto-sleep │ ├── claude-prompt-log.sh # UserPromptSubmit hook -│ └── slab-monitor.sh # resource sampler +│ ├── slab-monitor.sh # resource sampler +│ └── slab-zone # location-zone manager ├── sounds/ # WAV assets (lid chimes, pings, beeps) ├── launchd/ │ └── computer.slab.daemon.plist.template @@ -80,22 +81,47 @@ Runtime state lives under `~/.local/share/slab/`: ``` -sessions/.wav per-lid-close recording of Python-generated output -sessions/.jsonl trigger events for that session -logs/lidalive.log daemon transitions -logs/reactive.log reactive listener triggers -logs/resources.jsonl CPU/RSS samples every 15s -logs/claude-stop.log Stop-hook activity -venv/ Python venv for the reactive listener +sessions/-.wav per-lid-close recording of Python-generated output +sessions/-.jsonl trigger events + location metadata +logs/lidalive.log daemon transitions +logs/reactive.log reactive listener triggers +logs/resources.jsonl CPU/RSS samples every 15s +logs/claude-stop.log Stop-hook activity +config/zones.json geofenced zones + per-zone scale/dynamics +state/last-location.json cached coords (if Location Services unreachable) +venv/ Python venv for the reactive listener sounds/ installed sound assets (+ regenerated ambient.wav) ``` +## Zones (location-aware ambient) + +Each lid-close queries Core Location and picks the closest zone in +`~/.local/share/slab/config/zones.json`. The matching zone overrides the +reactive listener's scale, pitch mapping (`div_factor`), and amp. Coords + +zone name are written into the session JSONL and appended to the WAV +filename. + +```sh +brew install corelocationcli # one-time (install.sh can run it) +# grant Location Services permission once +slab-zone where # show current coords + zone +slab-zone add home 150 # pin current spot as 'home', 150m radius +slab-zone add studio 60 +slab-zone list # dump zones.json +slab-zone remove home +``` + +Available scales: `major_pentatonic`, `minor_pentatonic`, `blues`, +`dorian`, `phrygian`, `lydian`, `whole_tone`, `chromatic`. Edit +`zones.json` directly to tweak a zone's `scale`, `div_factor`, or +`arp_amp`. + ## Tuning Most knobs live at the top of each script: - **Ambient** — `bin/lid-ambient-generate.py`: scale, note-gap range, duration/fade ranges, detuning, drone frequency. Run `python3 bin/lid-ambient-generate.py [seed]` to regenerate a variation. -- **Reactive listener** — `bin/lid-reactive.py`: `HIGH_BAND`, `TRIGGER_RATIO`, `MIN_GAP`, `DIV_FACTOR`, `NOTE_DUR`, `PLUCK_TAIL`, `ARP_NOTES`, `ARP_AMP`, `PENT_MIDI`. +- **Reactive listener** — `bin/lid-reactive.py`: `HIGH_BAND`, `TRIGGER_RATIO`, `MIN_GAP`, `DIV_FACTOR`, `NOTE_DUR`, `PLUCK_TAIL`, `ARP_NOTES`, `ARP_AMP`. Scales live in `SCALE_INTERVALS`. - **Claude ping interval** — `bin/claude-ping-repeat.sh`: `INTERVAL` (default 30s). - **Auto-sleep delay** — `bin/claude-stop.sh`: the `120` argument to `claude-sleep-schedule.sh`. - **Resource poll** — `bin/slab-monitor.sh`: `INTERVAL` (default 15s). diff --git a/slab/bin/lid-reactive.py b/slab/bin/lid-reactive.py --- a/slab/bin/lid-reactive.py +++ b/slab/bin/lid-reactive.py @@ -15,7 +15,9 @@ import os import time import json import wave +import shutil import signal +import subprocess import threading import math from datetime import datetime @@ -26,10 +28,14 @@ SLAB_HOME = os.environ.get('SLAB_HOME', os.path.expanduser('~/.local/share/slab')) LOG_PATH = os.path.join(SLAB_HOME, 'logs', 'reactive.log') SESSION_DIR = os.path.join(SLAB_HOME, 'sessions') +CONFIG_DIR = os.path.join(SLAB_HOME, 'config') +ZONES_PATH = os.path.join(CONFIG_DIR, 'zones.json') +LAST_LOC_PATH = os.path.join(SLAB_HOME, 'state', 'last-location.json') os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) os.makedirs(SESSION_DIR, exist_ok=True) +os.makedirs(os.path.dirname(LAST_LOC_PATH), exist_ok=True) -# -------- config (tuneable) -------- +# -------- defaults (overridden per-zone at startup) -------- SR = 44100 BLOCK = 1024 HIGH_BAND = (2000.0, 8000.0) @@ -45,16 +51,109 @@ PLUCK_TAIL = 0.22 ARP_NOTES = 4 ARP_AMP = 0.22 -# C major pentatonic, C3..C6 -PENT_MIDI = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84] -PENT_HZ = [440.0 * 2 ** ((m - 69) / 12) for m in PENT_MIDI] - RECENT_SEC = 0.20 RECENT_N = int(SR * RECENT_SEC) +# -------- scales (MIDI offsets from tonic, C3..C6 across 3 octaves) -------- +SCALE_INTERVALS = { + 'major_pentatonic': [0, 2, 4, 7, 9], # C D E G A + 'minor_pentatonic': [0, 3, 5, 7, 10], # C Eb F G Bb + 'blues': [0, 3, 5, 6, 7, 10], # C Eb F F# G Bb + 'dorian': [0, 2, 3, 5, 7, 9, 10], + 'phrygian': [0, 1, 3, 5, 7, 8, 10], + 'lydian': [0, 2, 4, 6, 7, 9, 11], + 'whole_tone': [0, 2, 4, 6, 8, 10], + 'chromatic': list(range(12)), +} + + +def build_scale(name, tonic_midi=48, octaves=3): + intervals = SCALE_INTERVALS.get(name, SCALE_INTERVALS['major_pentatonic']) + notes = [] + for o in range(octaves + 1): + for iv in intervals: + m = tonic_midi + o * 12 + iv + if m <= tonic_midi + octaves * 12: + notes.append(m) + return [440.0 * 2 ** ((m - 69) / 12) for m in notes] + + +# -------- location + zone resolution -------- +def read_location(timeout_s=3.0): + """Return (lat, lon) via CoreLocationCLI or None on failure.""" + cli = shutil.which('CoreLocationCLI') + if not cli: + return None + try: + out = subprocess.run([cli], capture_output=True, text=True, + timeout=timeout_s).stdout.strip() + parts = out.split() + if len(parts) >= 2: + lat, lon = float(parts[0]), float(parts[1]) + with open(LAST_LOC_PATH, 'w') as f: + json.dump({'lat': lat, 'lon': lon, 'ts': time.time()}, f) + return lat, lon + except Exception: + pass + # fallback to cached + try: + with open(LAST_LOC_PATH) as f: + d = json.load(f) + return d['lat'], d['lon'] + except Exception: + return None + + +def haversine_m(lat1, lon1, lat2, lon2): + R = 6371000.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp/2)**2 + math.cos(p1) * math.cos(p2) * math.sin(dl/2)**2 + return 2 * R * math.asin(math.sqrt(a)) + + +def resolve_zone(coords): + """Return (zone_dict, distance_m_or_None).""" + try: + with open(ZONES_PATH) as f: + cfg = json.load(f) + except Exception: + cfg = {'default': {}, 'zones': []} + defaults = { + 'name': 'default', + 'scale': 'major_pentatonic', + 'div_factor': 5.0, + 'arp_amp': 0.22, + } + defaults.update(cfg.get('default', {})) + if not coords: + return defaults, None + lat, lon = coords + matches = [] + for z in cfg.get('zones', []): + d = haversine_m(lat, lon, z['lat'], z['lon']) + if d <= z.get('radius_m', 100): + matches.append((d, z)) + if not matches: + return defaults, None + d, z = min(matches, key=lambda x: x[0]) + out = dict(defaults); out.update(z) + return out, d + + +# -------- zone applies to runtime config -------- +_coords = read_location() +_zone, _zone_dist = resolve_zone(_coords) +DIV_FACTOR = float(_zone.get('div_factor', DIV_FACTOR)) +ARP_AMP = float(_zone.get('arp_amp', ARP_AMP)) +PENT_HZ = build_scale(_zone.get('scale', 'major_pentatonic')) + +# -------- session paths (zone-tagged) -------- _stamp = datetime.now().strftime('%Y%m%d-%H%M%S') -WAV_PATH = os.path.join(SESSION_DIR, f'{_stamp}.wav') -JSONL_PATH = os.path.join(SESSION_DIR, f'{_stamp}.jsonl') +_tag = f"-{_zone['name']}" if _zone.get('name') else '' +WAV_PATH = os.path.join(SESSION_DIR, f'{_stamp}{_tag}.wav') +JSONL_PATH = os.path.join(SESSION_DIR, f'{_stamp}{_tag}.jsonl') def log(msg): @@ -236,8 +335,16 @@ signal.signal(signal.SIGINT, shutdown) def main(): - log(f"listener starting (arp mode) session={_stamp}") - session_event('listener_start', wav=WAV_PATH, jsonl=JSONL_PATH) + log(f"listener starting (arp mode) session={_stamp} zone={_zone.get('name')} " + f"coords={_coords} dist={_zone_dist}") + session_event('listener_start', + wav=WAV_PATH, jsonl=JSONL_PATH, + zone=_zone.get('name'), + zone_scale=_zone.get('scale'), + zone_div_factor=DIV_FACTOR, + zone_arp_amp=ARP_AMP, + coords=_coords, + zone_distance_m=_zone_dist) try: out_stream = sd.OutputStream( samplerate=SR, channels=1, blocksize=BLOCK, diff --git a/slab/bin/slab-zone b/slab/bin/slab-zone new file mode 100644 --- /dev/null +++ b/slab/bin/slab-zone @@ -0,0 +1,91 @@ +#!/bin/bash +# slab-zone — manage geofenced slab zones. +# +# slab-zone list # show all zones + default +# slab-zone add [radius] # pin current location as +# slab-zone remove # remove a zone +# slab-zone where # print current coords + matching zone +# +# Zones are stored in $SLAB_HOME/config/zones.json. Each zone picks a +# scale / DIV_FACTOR / ARP_AMP preset that overrides lid-reactive.py +# defaults when the daemon detects you're inside that zone's radius. + +set -u +SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} +CONFIG_DIR="$SLAB_HOME/config" +ZONES="$CONFIG_DIR/zones.json" +CORELOC=$(command -v CoreLocationCLI || true) + +mkdir -p "$CONFIG_DIR" +if [[ ! -f "$ZONES" ]]; then + cat > "$ZONES" <<'JSON' +{ + "default": { + "scale": "major_pentatonic", + "div_factor": 5.0, + "arp_amp": 0.22 + }, + "zones": [] +} +JSON +fi + +die() { echo "$*" >&2; exit 1; } + +current_coords() { + [[ -n "$CORELOC" ]] || die "CoreLocationCLI not installed (brew install corelocationcli)" + local out + out=$("$CORELOC" 2>&1) + if [[ "$out" == *"denied"* || "$out" == *"disabled"* ]]; then + die "Location Services disabled — grant access in System Settings" + fi + echo "$out" | head -1 +} + +case "${1:-}" in + list) + jq . "$ZONES" + ;; + add) + name=${2:-}; radius=${3:-100} + [[ -n "$name" ]] || die "usage: slab-zone add [radius_m]" + read -r lat lon <<< "$(current_coords)" + [[ -n "$lat" && -n "$lon" ]] || die "could not obtain coords" + tmp=$(mktemp) + jq --arg n "$name" --argjson lat "$lat" --argjson lon "$lon" \ + --argjson r "$radius" ' + .zones |= map(select(.name != $n)) | + .zones += [{name: $n, lat: $lat, lon: $lon, radius_m: $r, + scale: "major_pentatonic", + div_factor: 5.0, arp_amp: 0.22}]' \ + "$ZONES" > "$tmp" && mv "$tmp" "$ZONES" + echo "pinned '$name' at $lat,$lon (r=${radius}m)" + echo "edit $ZONES to tune the zone's scale/dynamics" + ;; + remove|rm) + name=${2:-} + [[ -n "$name" ]] || die "usage: slab-zone remove " + tmp=$(mktemp) + jq --arg n "$name" '.zones |= map(select(.name != $n))' "$ZONES" > "$tmp" && mv "$tmp" "$ZONES" + echo "removed '$name'" + ;; + where) + read -r lat lon <<< "$(current_coords)" + echo "coords: $lat, $lon" + jq --argjson lat "$lat" --argjson lon "$lon" ' + def hav($la; $lo; $la2; $lo2): + 6371000 * 2 * ( (((($la2 - $la) * 3.14159 / 180)/2 | sin) | .*.) + + (($la * 3.14159/180) | cos) * (($la2 * 3.14159/180) | cos) + * (((($lo2 - $lo) * 3.14159 / 180)/2 | sin) | .*.) | sqrt | asin ); + .zones + | map(. + {dist_m: (hav(.lat; .lon; $lat; $lon) | floor)}) + | sort_by(.dist_m) + | map(select(.dist_m <= .radius_m)) + | if length == 0 then "default (outside all zones)" + else .[0].name + " (" + (.[0].dist_m | tostring) + "m)" end + ' "$ZONES" + ;; + *) + sed -n '1,12p' "$0" | sed 's/^# \{0,1\}//' + ;; +esac -- tangled.sh