From 06e4a7c56bfa6ad910b1c1fc2eb584a188c52ab4 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Mon, 20 Apr 2026 22:57:29 +0000 Subject: [PATCH] slab: live AU ambient synth + per-close timestamped recordings Replace the static pre-rendered ambient.wav loop with a Swift binary that drives an AVAudioEngine graph (source → delay → reverb → mainMixer) and taps the live mix to sessions/ambient-.wav. The Python listener loses its ambient bed (now noise + plucks only), the daemon SIGTERMs both processes on lid open, and install.sh compiles the Swift helper instead of pregenerating ambient.wav. Co-Authored-By: Claude Opus 4.7 (1M context) --- slab/README.md | 13 +++++++------ slab/bin/lid-ambient-generate.py | 104 -------------------------------------------------------------------------------------------------------- slab/bin/lid-ambient-synth.swift | 322 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/bin/lid-ambient.sh | 32 +++++++++++++++++++++++++++++--- slab/bin/lid-reactive.py | 50 ++++++-------------------------------------------- slab/install.sh | 17 +++++++++-------- 6 file(s) changed, 373 insertion(s)(+), 165 deletion(s)(-) diff --git a/slab/README.md b/slab/README.md --- a/slab/README.md +++ b/slab/README.md @@ -86,8 +86,8 @@ ``` slab/ ├── bin/ # scripts (symlinked into ~/.local/bin/) │ ├── lid-ambient.sh # launchd daemon, polls lid + active prompts -│ ├── lid-reactive.py # mic → pluck-arp synth (Python) -│ ├── lid-ambient-generate.py # generate ambient.wav +│ ├── lid-reactive.py # mic → pluck-arp synth + noise voice (Python) +│ ├── lid-ambient-synth.swift # live AVAudioEngine ambient drone + capture (Swift) │ ├── lid-return-generate.py # generate lid-return.wav (smooth descending arp) │ ├── slab-menubar.py # rumps menu bar status item (no Dock icon) │ ├── claude-sleep # sleep-state toggle @@ -113,7 +113,8 @@ Runtime state lives under `~/.local/share/slab/`: ``` -sessions/-.wav per-lid-close recording of Python-generated output +sessions/-.wav per-lid-close mix of Python listener output (noise + plucks) +sessions/ambient-.wav per-lid-close recording of the live Swift ambient drone sessions/-.jsonl trigger events + location metadata logs/lidalive.log daemon transitions logs/reactive.log reactive listener triggers @@ -124,7 +125,7 @@ state/active-prompts/ one file per Claude session with a prompt in flight state/active-subagents/ one file per in-flight Task-tool subagent state/last-location.json cached coords (if Location Services unreachable) venv/ Python venv for the reactive listener -sounds/ installed sound assets (+ regenerated ambient.wav) +sounds/ installed sound assets (lid chimes, pings, beeps) ``` ## Zones (location-aware ambient) @@ -154,7 +155,7 @@ ## 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. +- **Ambient** — `bin/lid-ambient-synth.swift`: scale, note-gap range, duration/fade ranges, detuning, drone frequency, AU effects (reverb preset, delay time/feedback). Recompile via `swiftc -O -o ~/.local/bin/lid-ambient-synth bin/lid-ambient-synth.swift` (install.sh does this automatically). - **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`. - **Lid-poll interval** — `bin/lid-ambient.sh`: `POLL` (default 0.5s). - **Resource poll** — `bin/slab-monitor.sh`: `INTERVAL` (default 15s). @@ -164,7 +165,7 @@ ## Requirements - macOS (Apple Silicon tested). Intel should work but the display-sleep-on-lid-close path depends on `pmset displaysleepnow`. -- Homebrew, Python 3.11+, `jq`. +- Homebrew, Python 3.11+, `jq`, `swiftc` (Xcode Command Line Tools). - Microphone permission for the reactive listener (prompted on first run). ## Notes diff --git a/slab/bin/lid-ambient-generate.py b/slab/bin/lid-ambient-generate.py deleted file mode 100644 --- a/slab/bin/lid-ambient-generate.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the ambient WAV used as the base layer of the lid-closed soundscape. - -Tweak knobs at top; re-run to regenerate. -Usage: lid-ambient-generate.py [seed] -""" -import math -import wave -import random -import array -import os -import sys -import time - -# --- knobs --- -TOTAL_SECONDS = 300.0 -NOTE_GAP_RANGE = (4.0, 10.0) -NOTE_DUR_RANGE = (18.0, 45.0) -FADE_IN_RANGE = (3.5, 8.0) -FADE_OUT_RANGE = (8.0, 18.0) -AMP_RANGE = (0.07, 0.13) -DETUNE_CENTS = 7.0 -DRONE_EVERY = (30.0, 60.0) - -# C major pentatonic, C3..E5 -SCALE_MIDI = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76] - -SLAB_HOME = os.environ.get('SLAB_HOME', os.path.expanduser('~/.local/share/slab')) -OUT = os.path.join(SLAB_HOME, 'sounds', 'ambient.wav') -SR = 44100 - - -def main(): - seed = int(sys.argv[1]) if len(sys.argv) > 1 else random.randint(0, 99999) - random.seed(seed) - t0 = time.time() - - N = int(SR * TOTAL_SECONDS) - pitches = [440.0 * 2**((m - 69) / 12) for m in SCALE_MIDI] - buf = array.array('d', [0.0]) * N - - def add_note(freq, start_t, dur, amp, fin, fout, det_c=0.0): - si = int(SR * start_t) - ns = int(SR * dur) - w1 = 2 * math.pi * freq - w2 = 2 * math.pi * freq * 2**(det_c / 1200) - for i in range(ns): - idx = si + i - if idx >= N: break - lt = i / SR - if lt < fin: - env = lt / fin - elif lt > dur - fout: - env = max(0.0, (dur - lt) / fout) - else: - env = 1.0 - buf[idx] += amp * env * (0.6 * math.sin(w1 * lt) + 0.4 * math.sin(w2 * lt)) - - count = 0 - t = 0.0 - while t < TOTAL_SECONDS - 10: - add_note( - random.choice(pitches), t, - random.uniform(*NOTE_DUR_RANGE), - random.uniform(*AMP_RANGE), - random.uniform(*FADE_IN_RANGE), - random.uniform(*FADE_OUT_RANGE), - random.uniform(-DETUNE_CENTS, DETUNE_CENTS), - ) - count += 1 - t += random.uniform(*NOTE_GAP_RANGE) - - td = 0.0 - while td < TOTAL_SECONDS - 30: - add_note( - random.choice(pitches[:4]) / 2.0, td, - random.uniform(40, 80), - random.uniform(0.05, 0.09), - random.uniform(6, 12), - random.uniform(15, 25), - random.uniform(-5, 5), - ) - td += random.uniform(*DRONE_EVERY) - - peak = max(abs(s) for s in buf) or 1.0 - scale = (0.85 / peak) * 32767 - out = array.array('h', [0]) * N - for i in range(N): - v = int(buf[i] * scale) - if v > 32767: v = 32767 - elif v < -32768: v = -32768 - out[i] = v - - os.makedirs(os.path.dirname(OUT), exist_ok=True) - with wave.open(OUT, 'wb') as w: - w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR) - w.writeframes(out.tobytes()) - - print(f'wrote {OUT}') - print(f'seed={seed} notes={count} peak={peak:.3f} elapsed={time.time()-t0:.1f}s') - - -if __name__ == '__main__': - main() diff --git a/slab/bin/lid-ambient-synth.swift b/slab/bin/lid-ambient-synth.swift new file mode 100644 --- /dev/null +++ b/slab/bin/lid-ambient-synth.swift @@ -0,0 +1,322 @@ +// lid-ambient-synth — live ambient drone generator and recorder. +// +// Owns the lid-closed ambient bed: instead of looping a pre-rendered +// ambient.wav, this binary synthesises pentatonic notes in real time and +// pipes them through built-in Audio Units (delay + reverb), then taps the +// final mix back to a timestamped wav at $SLAB_HOME/sessions/ambient-*.wav. +// +// Build: +// swiftc -O -o lid-ambient-synth lid-ambient-synth.swift +// +// Run: +// lid-ambient-synth # plays + records until SIGTERM/SIGINT +// +// Lifecycle: +// start — engine.start, write header to ambient-.wav +// SIGTERM — fade master gain to 0 over FADE_DUR seconds, then exit 0 +// (giving the lid-open return chime a soft bed to land on) + +import AVFoundation +import Foundation +import Darwin + +// ---------- knobs ---------- +// SAMPLE_RATE is overridden at startup to match the output device, so the +// synth's notion of time matches the audio engine's render rate. +var SAMPLE_RATE: Double = 44100.0 +let FADE_DUR: Double = 2.0 + +// C major pentatonic, C3..E5 (matches the previous python generator). +let SCALE_MIDI: [Double] = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76] + +let NOTE_GAP_RANGE: ClosedRange = 4.0...10.0 +let NOTE_DUR_RANGE: ClosedRange = 18.0...45.0 +let NOTE_AMP_RANGE: ClosedRange = 0.07...0.13 +let NOTE_FIN_RANGE: ClosedRange = 3.5...8.0 +let NOTE_FOUT_RANGE: ClosedRange = 8.0...18.0 +let NOTE_DETUNE_CENTS: ClosedRange = -7.0...7.0 + +let DRONE_GAP_RANGE: ClosedRange = 30.0...60.0 +let DRONE_DUR_RANGE: ClosedRange = 40.0...80.0 +let DRONE_AMP_RANGE: ClosedRange = 0.05...0.09 +let DRONE_FIN_RANGE: ClosedRange = 6.0...12.0 +let DRONE_FOUT_RANGE: ClosedRange = 15.0...25.0 +let DRONE_DETUNE_CENTS: ClosedRange = -5.0...5.0 + +let REVERB_PRESET: AVAudioUnitReverbPreset = .largeHall +let REVERB_WET: Float = 40.0 +let DELAY_TIME: TimeInterval = 0.5 +let DELAY_FEEDBACK: Float = 25.0 +let DELAY_WET: Float = 20.0 +let DELAY_LP: Float = 3000.0 + +// ---------- voice ---------- +final class Voice { + let freq: Double + let freq2: Double + let startSample: Int64 + let durSamples: Int64 + let amp: Double + let fadeInS: Double + let fadeOutS: Double + let durS: Double + + init(freq: Double, detuneCents: Double, startSample: Int64, + dur: Double, amp: Double, fadeIn: Double, fadeOut: Double) { + self.freq = freq + self.freq2 = freq * pow(2.0, detuneCents / 1200.0) + self.startSample = startSample + self.durSamples = Int64(dur * SAMPLE_RATE) + self.amp = amp + self.fadeInS = max(fadeIn, 1e-9) + self.fadeOutS = max(fadeOut, 1e-9) + self.durS = dur + } + + func render(into buf: UnsafeMutablePointer, count: Int, startAt: Int64) { + let endLocal = durSamples + for i in 0..= endLocal { continue } + let t = Double(local) / SAMPLE_RATE + let env = min(min(t / fadeInS, (durS - t) / fadeOutS), 1.0) + if env <= 0 { continue } + let s1 = sin(2 * .pi * freq * t) + let s2 = sin(2 * .pi * freq2 * t) + buf[i] += Float(amp * env * (0.6 * s1 + 0.4 * s2)) + } + } + + func expired(at sample: Int64) -> Bool { + return sample > startSample + durSamples + } +} + +// ---------- synth ---------- +final class AmbientSynth { + private var voices: [Voice] = [] + private var currentSample: Int64 = 0 + private var nextNoteSample: Int64 + private var nextDroneSample: Int64 + private let pitches: [Double] + private let bassPitches: [Double] + + private var masterGain: Double = 1.0 + private var fadePerSample: Double = 0.0 + + init() { + pitches = SCALE_MIDI.map { 440.0 * pow(2.0, ($0 - 69) / 12.0) } + bassPitches = pitches.prefix(4).map { $0 / 2.0 } + nextNoteSample = Int64(Double.random(in: 0.5...3.0) * SAMPLE_RATE) + nextDroneSample = Int64(Double.random(in: 5.0...15.0) * SAMPLE_RATE) + } + + func render(into buf: UnsafeMutablePointer, count: Int) { + for i in 0.. OSStatus in + let abl = UnsafeMutableAudioBufferListPointer(audioBufferList) + let n = Int(frameCount) + if abl.count >= 2, + let lRaw = abl[0].mData, + let rRaw = abl[1].mData { + let l = lRaw.assumingMemoryBound(to: Float.self) + let r = rRaw.assumingMemoryBound(to: Float.self) + synth.render(into: l, count: n) + memcpy(r, l, n * MemoryLayout.size) + } else if abl.count >= 1, let raw = abl[0].mData { + // Interleaved fallback: write mono into stride-2 buffer. + let buf = raw.assumingMemoryBound(to: Float.self) + var mono = [Float](repeating: 0, count: n) + mono.withUnsafeMutableBufferPointer { mb in + synth.render(into: mb.baseAddress!, count: n) + } + for i in 0..> "$log"; } +# Start the AVAudioEngine-backed ambient synth (Swift binary). It records +# its own output to $SLAB_HOME/sessions/ambient-.wav. +start_synth() { + if [[ -x "$synth_bin" ]]; then + nohup "$synth_bin" > /dev/null 2>&1 & + echo $! > "$synth_pid_file" + log_msg "started ambient synth pid $!" + fi +} + +# Ask the synth to fade and exit (SIGTERM → graceful fade-to-silence). +fade_synth() { + local pid + if [[ -f "$synth_pid_file" ]]; then + pid=$(cat "$synth_pid_file" 2>/dev/null) + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null + rm -f "$synth_pid_file" + fi +} + start_reactive() { + start_synth if [[ -x "$reactive_py" && -f "$reactive_script" ]]; then nohup "$reactive_py" "$reactive_script" > /dev/null 2>&1 & echo $! > "$reactive_pid_file" @@ -45,9 +68,10 @@ log_msg "started reactive listener pid $!" fi } -# Ask the listener to fade out and exit (SIGTERM → graceful fade). -# Returns immediately; the listener takes ~FADE_DUR seconds to actually exit. +# Ask the listener AND synth to fade out and exit (SIGTERM → graceful fade). +# Returns immediately; both take ~FADE_DUR seconds to actually exit. fade_reactive() { + fade_synth local pid if [[ -f "$reactive_pid_file" ]]; then pid=$(cat "$reactive_pid_file" 2>/dev/null) @@ -61,6 +85,7 @@ # Hard-stop (safety net for cleanup paths). stop_reactive() { fade_reactive pkill -f lid-reactive.py 2>/dev/null + pkill -f lid-ambient-synth 2>/dev/null } start_monitor() { @@ -145,8 +170,9 @@ /usr/bin/afplay "$return_wav" 2>/dev/null & (sleep "$return_dur" cur=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') if [[ "$cur" == "No" ]]; then - # Safety net — listener should have already exited by now. + # Safety net — listener + synth should have already exited. pkill -f lid-reactive.py 2>/dev/null + pkill -f lid-ambient-synth 2>/dev/null log_msg "ambient + reactive finalized after return stinger" fi ) & 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 @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Slab reactive listener and ambient engine. +"""Slab reactive listener. -Owns all lid-closed audio: - - AMBIENT — ambient.wav looped at AMBIENT_GAIN +Owns the mic-reactive lid-closed audio layer (ambient drone is now handled +separately by the AVAudioEngine-backed `lid-ambient-synth` Swift binary): - NOISE — continuous soft low-pass-filtered noise whose amplitude tracks the smoothed mic RMS (asymmetric EMA: quick rise, slow fall). Gives a gentle, always-on signal of what the @@ -42,7 +42,6 @@ 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') -AMBIENT_PATH = os.path.join(SLAB_HOME, 'sounds', 'ambient.wav') 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) @@ -63,8 +62,7 @@ PLUCK_TAIL = 0.22 ARP_NOTES = 4 ARP_AMP = 0.22 -# ambient + noise bed + fade -AMBIENT_GAIN = 0.85 +# noise bed + fade NOISE_GAIN_MAX = 0.10 # cap on noise voice amplitude NOISE_MIC_SCALE = 2.2 # multiplier on smoothed mic RMS → gain target NOISE_RISE_ALPHA = 0.30 # EMA alpha when rising (fast) @@ -204,25 +202,6 @@ except Exception: pass -# -------- ambient load -------- -def load_wav_f32(path): - with wave.open(path, 'rb') as w: - sr = w.getframerate() - n = w.getnframes() - data = w.readframes(n) - arr = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32767.0 - return arr, sr - - -try: - AMBIENT, _amb_sr = load_wav_f32(AMBIENT_PATH) - if _amb_sr != SR: - log(f"warning: ambient sr={_amb_sr} != {SR}") -except Exception as e: - log(f"ambient load failed: {e!r} — silent ambient") - AMBIENT = np.zeros(SR, dtype=np.float32) - - # -------- synthesis -------- def make_pluck(freq, tail=PLUCK_TAIL, amp=ARP_AMP): n = int(SR * tail) @@ -296,7 +275,6 @@ mic_rms = 0.0 mic_rms_smooth = 0.0 noise_gain_cur = 0.0 -ambient_pos = 0 fading = False fade_start_ts = 0.0 @@ -370,24 +348,10 @@ floor = max(floor, high_energy * 0.5) def output_callback(outdata, frames, time_info, status): - global ambient_pos, noise_gain_cur + global noise_gain_cur outdata.fill(0) fade = current_fade() - # ambient bed (looped) - if AMBIENT.size > 0: - end = ambient_pos + frames - if end <= AMBIENT.size: - chunk = AMBIENT[ambient_pos:end] - ambient_pos = end - else: - first = AMBIENT.size - ambient_pos - chunk = np.empty(frames, dtype=np.float32) - chunk[:first] = AMBIENT[ambient_pos:] - chunk[first:] = AMBIENT[:frames - first] - ambient_pos = frames - first - outdata[:, 0] += chunk * AMBIENT_GAIN * fade - # noise voice (soft low-passed, amp tracks smoothed mic rms) target = min(NOISE_GAIN_MAX, mic_rms_smooth * NOISE_MIC_SCALE) noise_gain_cur = 0.85 * noise_gain_cur + 0.15 * target @@ -477,8 +441,7 @@ def main(): log(f"listener starting session={_stamp} zone={_zone.get('name')} " - f"coords={_coords} dist={_zone_dist} " - f"ambient_len={AMBIENT.size / SR:.1f}s") + f"coords={_coords} dist={_zone_dist}") session_event('listener_start', wav=WAV_PATH, jsonl=JSONL_PATH, zone=_zone.get('name'), @@ -487,7 +450,6 @@ zone_div_factor=DIV_FACTOR, zone_arp_amp=ARP_AMP, coords=_coords, zone_distance_m=_zone_dist, - ambient_seconds=round(AMBIENT.size / SR, 2), trigger_ratio=TRIGGER_RATIO, min_gap=MIN_GAP, fade_dur=FADE_DUR) diff --git a/slab/install.sh b/slab/install.sh --- a/slab/install.sh +++ b/slab/install.sh @@ -40,11 +40,12 @@ err() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; } # ------------ prereqs ------------ say "checking prerequisites" -for cmd in brew python3 jq ioreg pmset afplay osascript; do +for cmd in brew python3 jq ioreg pmset afplay osascript swiftc; do if ! command -v "$cmd" >/dev/null 2>&1; then err "missing: $cmd" [[ "$cmd" == "brew" ]] && echo " install Homebrew first: https://brew.sh" [[ "$cmd" == "jq" ]] && echo " brew install jq" + [[ "$cmd" == "swiftc" ]] && echo " install Xcode Command Line Tools: xcode-select --install" exit 1 fi done @@ -57,22 +58,22 @@ # ------------ scripts (symlinked from repo) ------------ say "symlinking scripts into $SLAB_BIN" for f in "$SLAB_REPO/bin/"*; do base=$(basename "$f") + case "$base" in + *.swift) continue ;; # Swift sources are compiled below, not symlinked + esac dest="$SLAB_BIN/$base" rm -f "$dest" ln -s "$f" "$dest" chmod +x "$f" done +# ------------ Swift binary: live ambient synth ------------ +say "compiling lid-ambient-synth → $SLAB_BIN/lid-ambient-synth" +swiftc -O -o "$SLAB_BIN/lid-ambient-synth" "$SLAB_REPO/bin/lid-ambient-synth.swift" + # ------------ sounds ------------ say "copying sounds to $SLAB_HOME/sounds" cp -f "$SLAB_REPO/sounds/"*.wav "$SLAB_HOME/sounds/" - -# regenerate ambient.wav if missing or flagged -if [[ ! -f "$SLAB_HOME/sounds/ambient.wav" ]]; then - say "generating ambient.wav (~17s)" - SLAB_HOME="$SLAB_HOME" python3 "$SLAB_REPO/bin/lid-ambient-generate.py" || \ - warn "ambient.wav generation failed — rerun manually later" -fi # ------------ python venv (numpy + sounddevice for reactive listener) ------------ if [[ ! -x "$SLAB_HOME/venv/bin/python3" ]]; then -- tangled.sh