Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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-<ts>.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) <noreply@anthropic.com>

+373 -165
+7 -6
slab/README.md
··· 86 86 slab/ 87 87 ├── bin/ # scripts (symlinked into ~/.local/bin/) 88 88 │ ├── lid-ambient.sh # launchd daemon, polls lid + active prompts 89 - │ ├── lid-reactive.py # mic → pluck-arp synth (Python) 90 - │ ├── lid-ambient-generate.py # generate ambient.wav 89 + │ ├── lid-reactive.py # mic → pluck-arp synth + noise voice (Python) 90 + │ ├── lid-ambient-synth.swift # live AVAudioEngine ambient drone + capture (Swift) 91 91 │ ├── lid-return-generate.py # generate lid-return.wav (smooth descending arp) 92 92 │ ├── slab-menubar.py # rumps menu bar status item (no Dock icon) 93 93 │ ├── claude-sleep # sleep-state toggle ··· 113 113 Runtime state lives under `~/.local/share/slab/`: 114 114 115 115 ``` 116 - sessions/<stamp>-<zone>.wav per-lid-close recording of Python-generated output 116 + sessions/<stamp>-<zone>.wav per-lid-close mix of Python listener output (noise + plucks) 117 + sessions/ambient-<stamp>.wav per-lid-close recording of the live Swift ambient drone 117 118 sessions/<stamp>-<zone>.jsonl trigger events + location metadata 118 119 logs/lidalive.log daemon transitions 119 120 logs/reactive.log reactive listener triggers ··· 124 125 state/active-subagents/<id> one file per in-flight Task-tool subagent 125 126 state/last-location.json cached coords (if Location Services unreachable) 126 127 venv/ Python venv for the reactive listener 127 - sounds/ installed sound assets (+ regenerated ambient.wav) 128 + sounds/ installed sound assets (lid chimes, pings, beeps) 128 129 ``` 129 130 130 131 ## Zones (location-aware ambient) ··· 154 155 155 156 Most knobs live at the top of each script: 156 157 157 - - **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. 158 + - **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). 158 159 - **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`. 159 160 - **Lid-poll interval** — `bin/lid-ambient.sh`: `POLL` (default 0.5s). 160 161 - **Resource poll** — `bin/slab-monitor.sh`: `INTERVAL` (default 15s). ··· 164 165 ## Requirements 165 166 166 167 - macOS (Apple Silicon tested). Intel should work but the display-sleep-on-lid-close path depends on `pmset displaysleepnow`. 167 - - Homebrew, Python 3.11+, `jq`. 168 + - Homebrew, Python 3.11+, `jq`, `swiftc` (Xcode Command Line Tools). 168 169 - Microphone permission for the reactive listener (prompted on first run). 169 170 170 171 ## Notes
-104
slab/bin/lid-ambient-generate.py
··· 1 - #!/usr/bin/env python3 2 - """Generate the ambient WAV used as the base layer of the lid-closed soundscape. 3 - 4 - Tweak knobs at top; re-run to regenerate. 5 - Usage: lid-ambient-generate.py [seed] 6 - """ 7 - import math 8 - import wave 9 - import random 10 - import array 11 - import os 12 - import sys 13 - import time 14 - 15 - # --- knobs --- 16 - TOTAL_SECONDS = 300.0 17 - NOTE_GAP_RANGE = (4.0, 10.0) 18 - NOTE_DUR_RANGE = (18.0, 45.0) 19 - FADE_IN_RANGE = (3.5, 8.0) 20 - FADE_OUT_RANGE = (8.0, 18.0) 21 - AMP_RANGE = (0.07, 0.13) 22 - DETUNE_CENTS = 7.0 23 - DRONE_EVERY = (30.0, 60.0) 24 - 25 - # C major pentatonic, C3..E5 26 - SCALE_MIDI = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76] 27 - 28 - SLAB_HOME = os.environ.get('SLAB_HOME', os.path.expanduser('~/.local/share/slab')) 29 - OUT = os.path.join(SLAB_HOME, 'sounds', 'ambient.wav') 30 - SR = 44100 31 - 32 - 33 - def main(): 34 - seed = int(sys.argv[1]) if len(sys.argv) > 1 else random.randint(0, 99999) 35 - random.seed(seed) 36 - t0 = time.time() 37 - 38 - N = int(SR * TOTAL_SECONDS) 39 - pitches = [440.0 * 2**((m - 69) / 12) for m in SCALE_MIDI] 40 - buf = array.array('d', [0.0]) * N 41 - 42 - def add_note(freq, start_t, dur, amp, fin, fout, det_c=0.0): 43 - si = int(SR * start_t) 44 - ns = int(SR * dur) 45 - w1 = 2 * math.pi * freq 46 - w2 = 2 * math.pi * freq * 2**(det_c / 1200) 47 - for i in range(ns): 48 - idx = si + i 49 - if idx >= N: break 50 - lt = i / SR 51 - if lt < fin: 52 - env = lt / fin 53 - elif lt > dur - fout: 54 - env = max(0.0, (dur - lt) / fout) 55 - else: 56 - env = 1.0 57 - buf[idx] += amp * env * (0.6 * math.sin(w1 * lt) + 0.4 * math.sin(w2 * lt)) 58 - 59 - count = 0 60 - t = 0.0 61 - while t < TOTAL_SECONDS - 10: 62 - add_note( 63 - random.choice(pitches), t, 64 - random.uniform(*NOTE_DUR_RANGE), 65 - random.uniform(*AMP_RANGE), 66 - random.uniform(*FADE_IN_RANGE), 67 - random.uniform(*FADE_OUT_RANGE), 68 - random.uniform(-DETUNE_CENTS, DETUNE_CENTS), 69 - ) 70 - count += 1 71 - t += random.uniform(*NOTE_GAP_RANGE) 72 - 73 - td = 0.0 74 - while td < TOTAL_SECONDS - 30: 75 - add_note( 76 - random.choice(pitches[:4]) / 2.0, td, 77 - random.uniform(40, 80), 78 - random.uniform(0.05, 0.09), 79 - random.uniform(6, 12), 80 - random.uniform(15, 25), 81 - random.uniform(-5, 5), 82 - ) 83 - td += random.uniform(*DRONE_EVERY) 84 - 85 - peak = max(abs(s) for s in buf) or 1.0 86 - scale = (0.85 / peak) * 32767 87 - out = array.array('h', [0]) * N 88 - for i in range(N): 89 - v = int(buf[i] * scale) 90 - if v > 32767: v = 32767 91 - elif v < -32768: v = -32768 92 - out[i] = v 93 - 94 - os.makedirs(os.path.dirname(OUT), exist_ok=True) 95 - with wave.open(OUT, 'wb') as w: 96 - w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR) 97 - w.writeframes(out.tobytes()) 98 - 99 - print(f'wrote {OUT}') 100 - print(f'seed={seed} notes={count} peak={peak:.3f} elapsed={time.time()-t0:.1f}s') 101 - 102 - 103 - if __name__ == '__main__': 104 - main()
+322
slab/bin/lid-ambient-synth.swift
··· 1 + // lid-ambient-synth — live ambient drone generator and recorder. 2 + // 3 + // Owns the lid-closed ambient bed: instead of looping a pre-rendered 4 + // ambient.wav, this binary synthesises pentatonic notes in real time and 5 + // pipes them through built-in Audio Units (delay + reverb), then taps the 6 + // final mix back to a timestamped wav at $SLAB_HOME/sessions/ambient-*.wav. 7 + // 8 + // Build: 9 + // swiftc -O -o lid-ambient-synth lid-ambient-synth.swift 10 + // 11 + // Run: 12 + // lid-ambient-synth # plays + records until SIGTERM/SIGINT 13 + // 14 + // Lifecycle: 15 + // start — engine.start, write header to ambient-<ts>.wav 16 + // SIGTERM — fade master gain to 0 over FADE_DUR seconds, then exit 0 17 + // (giving the lid-open return chime a soft bed to land on) 18 + 19 + import AVFoundation 20 + import Foundation 21 + import Darwin 22 + 23 + // ---------- knobs ---------- 24 + // SAMPLE_RATE is overridden at startup to match the output device, so the 25 + // synth's notion of time matches the audio engine's render rate. 26 + var SAMPLE_RATE: Double = 44100.0 27 + let FADE_DUR: Double = 2.0 28 + 29 + // C major pentatonic, C3..E5 (matches the previous python generator). 30 + let SCALE_MIDI: [Double] = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76] 31 + 32 + let NOTE_GAP_RANGE: ClosedRange<Double> = 4.0...10.0 33 + let NOTE_DUR_RANGE: ClosedRange<Double> = 18.0...45.0 34 + let NOTE_AMP_RANGE: ClosedRange<Double> = 0.07...0.13 35 + let NOTE_FIN_RANGE: ClosedRange<Double> = 3.5...8.0 36 + let NOTE_FOUT_RANGE: ClosedRange<Double> = 8.0...18.0 37 + let NOTE_DETUNE_CENTS: ClosedRange<Double> = -7.0...7.0 38 + 39 + let DRONE_GAP_RANGE: ClosedRange<Double> = 30.0...60.0 40 + let DRONE_DUR_RANGE: ClosedRange<Double> = 40.0...80.0 41 + let DRONE_AMP_RANGE: ClosedRange<Double> = 0.05...0.09 42 + let DRONE_FIN_RANGE: ClosedRange<Double> = 6.0...12.0 43 + let DRONE_FOUT_RANGE: ClosedRange<Double> = 15.0...25.0 44 + let DRONE_DETUNE_CENTS: ClosedRange<Double> = -5.0...5.0 45 + 46 + let REVERB_PRESET: AVAudioUnitReverbPreset = .largeHall 47 + let REVERB_WET: Float = 40.0 48 + let DELAY_TIME: TimeInterval = 0.5 49 + let DELAY_FEEDBACK: Float = 25.0 50 + let DELAY_WET: Float = 20.0 51 + let DELAY_LP: Float = 3000.0 52 + 53 + // ---------- voice ---------- 54 + final class Voice { 55 + let freq: Double 56 + let freq2: Double 57 + let startSample: Int64 58 + let durSamples: Int64 59 + let amp: Double 60 + let fadeInS: Double 61 + let fadeOutS: Double 62 + let durS: Double 63 + 64 + init(freq: Double, detuneCents: Double, startSample: Int64, 65 + dur: Double, amp: Double, fadeIn: Double, fadeOut: Double) { 66 + self.freq = freq 67 + self.freq2 = freq * pow(2.0, detuneCents / 1200.0) 68 + self.startSample = startSample 69 + self.durSamples = Int64(dur * SAMPLE_RATE) 70 + self.amp = amp 71 + self.fadeInS = max(fadeIn, 1e-9) 72 + self.fadeOutS = max(fadeOut, 1e-9) 73 + self.durS = dur 74 + } 75 + 76 + func render(into buf: UnsafeMutablePointer<Float>, count: Int, startAt: Int64) { 77 + let endLocal = durSamples 78 + for i in 0..<count { 79 + let local = startAt + Int64(i) - startSample 80 + if local < 0 || local >= endLocal { continue } 81 + let t = Double(local) / SAMPLE_RATE 82 + let env = min(min(t / fadeInS, (durS - t) / fadeOutS), 1.0) 83 + if env <= 0 { continue } 84 + let s1 = sin(2 * .pi * freq * t) 85 + let s2 = sin(2 * .pi * freq2 * t) 86 + buf[i] += Float(amp * env * (0.6 * s1 + 0.4 * s2)) 87 + } 88 + } 89 + 90 + func expired(at sample: Int64) -> Bool { 91 + return sample > startSample + durSamples 92 + } 93 + } 94 + 95 + // ---------- synth ---------- 96 + final class AmbientSynth { 97 + private var voices: [Voice] = [] 98 + private var currentSample: Int64 = 0 99 + private var nextNoteSample: Int64 100 + private var nextDroneSample: Int64 101 + private let pitches: [Double] 102 + private let bassPitches: [Double] 103 + 104 + private var masterGain: Double = 1.0 105 + private var fadePerSample: Double = 0.0 106 + 107 + init() { 108 + pitches = SCALE_MIDI.map { 440.0 * pow(2.0, ($0 - 69) / 12.0) } 109 + bassPitches = pitches.prefix(4).map { $0 / 2.0 } 110 + nextNoteSample = Int64(Double.random(in: 0.5...3.0) * SAMPLE_RATE) 111 + nextDroneSample = Int64(Double.random(in: 5.0...15.0) * SAMPLE_RATE) 112 + } 113 + 114 + func render(into buf: UnsafeMutablePointer<Float>, count: Int) { 115 + for i in 0..<count { buf[i] = 0 } 116 + 117 + let endSample = currentSample + Int64(count) 118 + 119 + while nextNoteSample <= endSample { 120 + voices.append(Voice( 121 + freq: pitches.randomElement()!, 122 + detuneCents: Double.random(in: NOTE_DETUNE_CENTS), 123 + startSample: nextNoteSample, 124 + dur: Double.random(in: NOTE_DUR_RANGE), 125 + amp: Double.random(in: NOTE_AMP_RANGE), 126 + fadeIn: Double.random(in: NOTE_FIN_RANGE), 127 + fadeOut: Double.random(in: NOTE_FOUT_RANGE) 128 + )) 129 + nextNoteSample += Int64(Double.random(in: NOTE_GAP_RANGE) * SAMPLE_RATE) 130 + } 131 + 132 + while nextDroneSample <= endSample { 133 + voices.append(Voice( 134 + freq: bassPitches.randomElement()!, 135 + detuneCents: Double.random(in: DRONE_DETUNE_CENTS), 136 + startSample: nextDroneSample, 137 + dur: Double.random(in: DRONE_DUR_RANGE), 138 + amp: Double.random(in: DRONE_AMP_RANGE), 139 + fadeIn: Double.random(in: DRONE_FIN_RANGE), 140 + fadeOut: Double.random(in: DRONE_FOUT_RANGE) 141 + )) 142 + nextDroneSample += Int64(Double.random(in: DRONE_GAP_RANGE) * SAMPLE_RATE) 143 + } 144 + 145 + for v in voices { 146 + v.render(into: buf, count: count, startAt: currentSample) 147 + } 148 + 149 + if fadePerSample != 0 { 150 + for i in 0..<count { 151 + masterGain = max(0.0, min(1.0, masterGain + fadePerSample)) 152 + buf[i] *= Float(masterGain) 153 + } 154 + } else if masterGain != 1.0 { 155 + let g = Float(masterGain) 156 + for i in 0..<count { buf[i] *= g } 157 + } 158 + 159 + voices.removeAll { $0.expired(at: endSample) } 160 + currentSample = endSample 161 + } 162 + 163 + func startFade(seconds: Double) { 164 + fadePerSample = -1.0 / (seconds * SAMPLE_RATE) 165 + } 166 + } 167 + 168 + // ---------- engine ---------- 169 + let engine = AVAudioEngine() 170 + 171 + // Drive the synth at the device's native rate so AVAudioEngine doesn't have 172 + // to resample our mono output (which is the real source of -10868 mismatches 173 + // when piping a fixed-rate source through built-in AUs into mainMixerNode). 174 + let deviceFormat = engine.outputNode.inputFormat(forBus: 0) 175 + SAMPLE_RATE = deviceFormat.sampleRate 176 + 177 + // Stereo throughout — AVAudioUnitDelay/Reverb fail to set a mono format on 178 + // their input/output buses (-10868), so the synth renders mono into the L 179 + // channel and we duplicate to R before the AU chain. 180 + guard let synthFormat = AVAudioFormat( 181 + commonFormat: deviceFormat.commonFormat, 182 + sampleRate: deviceFormat.sampleRate, 183 + channels: 2, 184 + interleaved: deviceFormat.isInterleaved 185 + ) else { 186 + FileHandle.standardError.write("failed to build synthFormat\n".data(using: .utf8)!) 187 + exit(1) 188 + } 189 + 190 + let synth = AmbientSynth() 191 + 192 + let sourceNode = AVAudioSourceNode(format: synthFormat) { 193 + _, _, frameCount, audioBufferList -> OSStatus in 194 + let abl = UnsafeMutableAudioBufferListPointer(audioBufferList) 195 + let n = Int(frameCount) 196 + if abl.count >= 2, 197 + let lRaw = abl[0].mData, 198 + let rRaw = abl[1].mData { 199 + let l = lRaw.assumingMemoryBound(to: Float.self) 200 + let r = rRaw.assumingMemoryBound(to: Float.self) 201 + synth.render(into: l, count: n) 202 + memcpy(r, l, n * MemoryLayout<Float>.size) 203 + } else if abl.count >= 1, let raw = abl[0].mData { 204 + // Interleaved fallback: write mono into stride-2 buffer. 205 + let buf = raw.assumingMemoryBound(to: Float.self) 206 + var mono = [Float](repeating: 0, count: n) 207 + mono.withUnsafeMutableBufferPointer { mb in 208 + synth.render(into: mb.baseAddress!, count: n) 209 + } 210 + for i in 0..<n { 211 + buf[2 * i] = mono[i] 212 + buf[2 * i + 1] = mono[i] 213 + } 214 + } 215 + return noErr 216 + } 217 + 218 + let delay = AVAudioUnitDelay() 219 + delay.delayTime = DELAY_TIME 220 + delay.feedback = DELAY_FEEDBACK 221 + delay.wetDryMix = DELAY_WET 222 + delay.lowPassCutoff = DELAY_LP 223 + 224 + let reverb = AVAudioUnitReverb() 225 + reverb.loadFactoryPreset(REVERB_PRESET) 226 + reverb.wetDryMix = REVERB_WET 227 + 228 + engine.attach(sourceNode) 229 + engine.attach(delay) 230 + engine.attach(reverb) 231 + 232 + engine.connect(sourceNode, to: delay, format: synthFormat) 233 + engine.connect(delay, to: reverb, format: synthFormat) 234 + engine.connect(reverb, to: engine.mainMixerNode, format: synthFormat) 235 + 236 + // ---------- recording tap ---------- 237 + let slabHome = ProcessInfo.processInfo.environment["SLAB_HOME"] 238 + ?? NSString(string: "~/.local/share/slab").expandingTildeInPath 239 + let sessionDir = "\(slabHome)/sessions" 240 + try? FileManager.default.createDirectory( 241 + atPath: sessionDir, withIntermediateDirectories: true) 242 + 243 + let formatter = DateFormatter() 244 + formatter.dateFormat = "yyyyMMdd-HHmmss" 245 + let timestamp = formatter.string(from: Date()) 246 + let outPath = "\(sessionDir)/ambient-\(timestamp).wav" 247 + let outURL = URL(fileURLWithPath: outPath) 248 + 249 + let tapFormat = engine.mainMixerNode.outputFormat(forBus: 0) 250 + 251 + let fileSettings: [String: Any] = [ 252 + AVFormatIDKey: kAudioFormatLinearPCM, 253 + AVSampleRateKey: tapFormat.sampleRate, 254 + AVNumberOfChannelsKey: tapFormat.channelCount, 255 + AVLinearPCMBitDepthKey: 16, 256 + AVLinearPCMIsFloatKey: false, 257 + AVLinearPCMIsBigEndianKey: false, 258 + ] 259 + 260 + // `var` (not `let`) so we can drop the reference before exit() — AVAudioFile 261 + // only finalises its WAV header (data-chunk length, RIFF size) when the 262 + // instance deallocates, so without this the file looks empty to afinfo. 263 + var outFile: AVAudioFile? 264 + do { 265 + outFile = try AVAudioFile( 266 + forWriting: outURL, 267 + settings: fileSettings, 268 + commonFormat: tapFormat.commonFormat, 269 + interleaved: tapFormat.isInterleaved) 270 + } catch { 271 + FileHandle.standardError.write( 272 + "open output file failed: \(error)\n".data(using: .utf8)!) 273 + exit(1) 274 + } 275 + 276 + engine.mainMixerNode.installTap( 277 + onBus: 0, bufferSize: 4096, format: tapFormat 278 + ) { buffer, _ in 279 + do { 280 + try outFile?.write(from: buffer) 281 + } catch { 282 + FileHandle.standardError.write( 283 + "tap write failed: \(error)\n".data(using: .utf8)!) 284 + } 285 + } 286 + 287 + do { 288 + try engine.start() 289 + } catch { 290 + FileHandle.standardError.write( 291 + "engine.start failed: \(error)\n".data(using: .utf8)!) 292 + exit(1) 293 + } 294 + 295 + print("ambient synth running → \(outPath)") 296 + fflush(stdout) 297 + 298 + // ---------- signal handling ---------- 299 + var exiting = false 300 + func gracefulExit() { 301 + if exiting { return } 302 + exiting = true 303 + synth.startFade(seconds: FADE_DUR) 304 + DispatchQueue.main.asyncAfter(deadline: .now() + FADE_DUR + 0.3) { 305 + engine.mainMixerNode.removeTap(onBus: 0) 306 + engine.stop() 307 + outFile = nil // drops the file's writer → finalises WAV header 308 + exit(0) 309 + } 310 + } 311 + 312 + let termSrc = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) 313 + termSrc.setEventHandler { gracefulExit() } 314 + termSrc.resume() 315 + signal(SIGTERM, SIG_IGN) 316 + 317 + let intSrc = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) 318 + intSrc.setEventHandler { gracefulExit() } 319 + intSrc.resume() 320 + signal(SIGINT, SIG_IGN) 321 + 322 + RunLoop.main.run()
+29 -3
slab/bin/lid-ambient.sh
··· 27 27 28 28 PID_DIR=/tmp 29 29 reactive_pid_file="$PID_DIR/lidreactive.pid" 30 + synth_pid_file="$PID_DIR/lidsynth.pid" 30 31 monitor_pid_file="$PID_DIR/slab-monitor.pid" 31 32 log="$SLAB_HOME/logs/lidalive.log" 32 33 mkdir -p "$(dirname "$log")" 33 34 34 35 reactive_py="$SLAB_HOME/venv/bin/python3" 35 36 reactive_script="$SLAB_BIN/lid-reactive.py" 37 + synth_bin="$SLAB_BIN/lid-ambient-synth" 36 38 37 39 log_msg() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$log"; } 38 40 41 + # Start the AVAudioEngine-backed ambient synth (Swift binary). It records 42 + # its own output to $SLAB_HOME/sessions/ambient-<timestamp>.wav. 43 + start_synth() { 44 + if [[ -x "$synth_bin" ]]; then 45 + nohup "$synth_bin" > /dev/null 2>&1 & 46 + echo $! > "$synth_pid_file" 47 + log_msg "started ambient synth pid $!" 48 + fi 49 + } 50 + 51 + # Ask the synth to fade and exit (SIGTERM → graceful fade-to-silence). 52 + fade_synth() { 53 + local pid 54 + if [[ -f "$synth_pid_file" ]]; then 55 + pid=$(cat "$synth_pid_file" 2>/dev/null) 56 + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null 57 + rm -f "$synth_pid_file" 58 + fi 59 + } 60 + 39 61 start_reactive() { 62 + start_synth 40 63 if [[ -x "$reactive_py" && -f "$reactive_script" ]]; then 41 64 nohup "$reactive_py" "$reactive_script" > /dev/null 2>&1 & 42 65 echo $! > "$reactive_pid_file" ··· 45 68 fi 46 69 } 47 70 48 - # Ask the listener to fade out and exit (SIGTERM → graceful fade). 49 - # Returns immediately; the listener takes ~FADE_DUR seconds to actually exit. 71 + # Ask the listener AND synth to fade out and exit (SIGTERM → graceful fade). 72 + # Returns immediately; both take ~FADE_DUR seconds to actually exit. 50 73 fade_reactive() { 74 + fade_synth 51 75 local pid 52 76 if [[ -f "$reactive_pid_file" ]]; then 53 77 pid=$(cat "$reactive_pid_file" 2>/dev/null) ··· 61 85 stop_reactive() { 62 86 fade_reactive 63 87 pkill -f lid-reactive.py 2>/dev/null 88 + pkill -f lid-ambient-synth 2>/dev/null 64 89 } 65 90 66 91 start_monitor() { ··· 145 170 (sleep "$return_dur" 146 171 cur=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') 147 172 if [[ "$cur" == "No" ]]; then 148 - # Safety net — listener should have already exited by now. 173 + # Safety net — listener + synth should have already exited. 149 174 pkill -f lid-reactive.py 2>/dev/null 175 + pkill -f lid-ambient-synth 2>/dev/null 150 176 log_msg "ambient + reactive finalized after return stinger" 151 177 fi 152 178 ) &
+6 -44
slab/bin/lid-reactive.py
··· 1 1 #!/usr/bin/env python3 2 - """Slab reactive listener and ambient engine. 2 + """Slab reactive listener. 3 3 4 - Owns all lid-closed audio: 5 - - AMBIENT — ambient.wav looped at AMBIENT_GAIN 4 + Owns the mic-reactive lid-closed audio layer (ambient drone is now handled 5 + separately by the AVAudioEngine-backed `lid-ambient-synth` Swift binary): 6 6 - NOISE — continuous soft low-pass-filtered noise whose amplitude 7 7 tracks the smoothed mic RMS (asymmetric EMA: quick rise, 8 8 slow fall). Gives a gentle, always-on signal of what the ··· 42 42 CONFIG_DIR = os.path.join(SLAB_HOME, 'config') 43 43 ZONES_PATH = os.path.join(CONFIG_DIR, 'zones.json') 44 44 LAST_LOC_PATH = os.path.join(SLAB_HOME, 'state', 'last-location.json') 45 - AMBIENT_PATH = os.path.join(SLAB_HOME, 'sounds', 'ambient.wav') 46 45 os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) 47 46 os.makedirs(SESSION_DIR, exist_ok=True) 48 47 os.makedirs(os.path.dirname(LAST_LOC_PATH), exist_ok=True) ··· 63 62 ARP_NOTES = 4 64 63 ARP_AMP = 0.22 65 64 66 - # ambient + noise bed + fade 67 - AMBIENT_GAIN = 0.85 65 + # noise bed + fade 68 66 NOISE_GAIN_MAX = 0.10 # cap on noise voice amplitude 69 67 NOISE_MIC_SCALE = 2.2 # multiplier on smoothed mic RMS → gain target 70 68 NOISE_RISE_ALPHA = 0.30 # EMA alpha when rising (fast) ··· 204 202 pass 205 203 206 204 207 - # -------- ambient load -------- 208 - def load_wav_f32(path): 209 - with wave.open(path, 'rb') as w: 210 - sr = w.getframerate() 211 - n = w.getnframes() 212 - data = w.readframes(n) 213 - arr = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32767.0 214 - return arr, sr 215 - 216 - 217 - try: 218 - AMBIENT, _amb_sr = load_wav_f32(AMBIENT_PATH) 219 - if _amb_sr != SR: 220 - log(f"warning: ambient sr={_amb_sr} != {SR}") 221 - except Exception as e: 222 - log(f"ambient load failed: {e!r} — silent ambient") 223 - AMBIENT = np.zeros(SR, dtype=np.float32) 224 - 225 - 226 205 # -------- synthesis -------- 227 206 def make_pluck(freq, tail=PLUCK_TAIL, amp=ARP_AMP): 228 207 n = int(SR * tail) ··· 296 275 mic_rms = 0.0 297 276 mic_rms_smooth = 0.0 298 277 noise_gain_cur = 0.0 299 - ambient_pos = 0 300 278 301 279 fading = False 302 280 fade_start_ts = 0.0 ··· 370 348 371 349 372 350 def output_callback(outdata, frames, time_info, status): 373 - global ambient_pos, noise_gain_cur 351 + global noise_gain_cur 374 352 outdata.fill(0) 375 353 fade = current_fade() 376 354 377 - # ambient bed (looped) 378 - if AMBIENT.size > 0: 379 - end = ambient_pos + frames 380 - if end <= AMBIENT.size: 381 - chunk = AMBIENT[ambient_pos:end] 382 - ambient_pos = end 383 - else: 384 - first = AMBIENT.size - ambient_pos 385 - chunk = np.empty(frames, dtype=np.float32) 386 - chunk[:first] = AMBIENT[ambient_pos:] 387 - chunk[first:] = AMBIENT[:frames - first] 388 - ambient_pos = frames - first 389 - outdata[:, 0] += chunk * AMBIENT_GAIN * fade 390 - 391 355 # noise voice (soft low-passed, amp tracks smoothed mic rms) 392 356 target = min(NOISE_GAIN_MAX, mic_rms_smooth * NOISE_MIC_SCALE) 393 357 noise_gain_cur = 0.85 * noise_gain_cur + 0.15 * target ··· 477 441 478 442 def main(): 479 443 log(f"listener starting session={_stamp} zone={_zone.get('name')} " 480 - f"coords={_coords} dist={_zone_dist} " 481 - f"ambient_len={AMBIENT.size / SR:.1f}s") 444 + f"coords={_coords} dist={_zone_dist}") 482 445 session_event('listener_start', 483 446 wav=WAV_PATH, jsonl=JSONL_PATH, 484 447 zone=_zone.get('name'), ··· 487 450 zone_arp_amp=ARP_AMP, 488 451 coords=_coords, 489 452 zone_distance_m=_zone_dist, 490 - ambient_seconds=round(AMBIENT.size / SR, 2), 491 453 trigger_ratio=TRIGGER_RATIO, 492 454 min_gap=MIN_GAP, 493 455 fade_dur=FADE_DUR)
+9 -8
slab/install.sh
··· 40 40 41 41 # ------------ prereqs ------------ 42 42 say "checking prerequisites" 43 - for cmd in brew python3 jq ioreg pmset afplay osascript; do 43 + for cmd in brew python3 jq ioreg pmset afplay osascript swiftc; do 44 44 if ! command -v "$cmd" >/dev/null 2>&1; then 45 45 err "missing: $cmd" 46 46 [[ "$cmd" == "brew" ]] && echo " install Homebrew first: https://brew.sh" 47 47 [[ "$cmd" == "jq" ]] && echo " brew install jq" 48 + [[ "$cmd" == "swiftc" ]] && echo " install Xcode Command Line Tools: xcode-select --install" 48 49 exit 1 49 50 fi 50 51 done ··· 57 58 say "symlinking scripts into $SLAB_BIN" 58 59 for f in "$SLAB_REPO/bin/"*; do 59 60 base=$(basename "$f") 61 + case "$base" in 62 + *.swift) continue ;; # Swift sources are compiled below, not symlinked 63 + esac 60 64 dest="$SLAB_BIN/$base" 61 65 rm -f "$dest" 62 66 ln -s "$f" "$dest" 63 67 chmod +x "$f" 64 68 done 65 69 70 + # ------------ Swift binary: live ambient synth ------------ 71 + say "compiling lid-ambient-synth → $SLAB_BIN/lid-ambient-synth" 72 + swiftc -O -o "$SLAB_BIN/lid-ambient-synth" "$SLAB_REPO/bin/lid-ambient-synth.swift" 73 + 66 74 # ------------ sounds ------------ 67 75 say "copying sounds to $SLAB_HOME/sounds" 68 76 cp -f "$SLAB_REPO/sounds/"*.wav "$SLAB_HOME/sounds/" 69 - 70 - # regenerate ambient.wav if missing or flagged 71 - if [[ ! -f "$SLAB_HOME/sounds/ambient.wav" ]]; then 72 - say "generating ambient.wav (~17s)" 73 - SLAB_HOME="$SLAB_HOME" python3 "$SLAB_REPO/bin/lid-ambient-generate.py" || \ 74 - warn "ambient.wav generation failed — rerun manually later" 75 - fi 76 77 77 78 # ------------ python venv (numpy + sounddevice for reactive listener) ------------ 78 79 if [[ ! -x "$SLAB_HOME/venv/bin/python3" ]]; then