// spank detects slaps/hits on the laptop and plays audio responses. // It reads the Apple Silicon accelerometer directly via IOKit HID — // no separate sensor daemon required. Needs sudo. package main import ( "bufio" "bytes" "context" "embed" "encoding/json" "fmt" "io" "math" "math/rand" "os" "os/signal" "sort" "strings" "sync" "syscall" "time" "github.com/charmbracelet/fang" "github.com/gopxl/beep/v2" "github.com/gopxl/beep/v2/mp3" "github.com/gopxl/beep/v2/speaker" "github.com/spf13/cobra" "github.com/taigrr/apple-silicon-accelerometer/detector" "github.com/taigrr/apple-silicon-accelerometer/sensor" "github.com/taigrr/apple-silicon-accelerometer/shm" ) var version = "dev" //go:embed audio/pain/*.mp3 var painAudio embed.FS //go:embed audio/sexy/*.mp3 var sexyAudio embed.FS //go:embed audio/halo/*.mp3 var haloAudio embed.FS var ( sexyMode bool haloMode bool customPath string customFiles []string minAmplitude float64 cooldownMs int stdioMode bool paused bool pausedMu sync.RWMutex ) // sensorReady is closed once shared memory is created and the sensor // worker is about to enter the CFRunLoop. var sensorReady = make(chan struct{}) // sensorErr receives any error from the sensor worker. var sensorErr = make(chan error, 1) type playMode int const ( modeRandom playMode = iota modeEscalation ) const ( // decayHalfLife is how many seconds of inactivity before intensity // halves. Controls how fast escalation fades. decayHalfLife = 30.0 // defaultCooldownMs is the default cooldown between audio responses. defaultCooldownMs = 750 // sensorPollInterval is how often we check for new accelerometer data. sensorPollInterval = 10 * time.Millisecond // maxSampleBatch caps the number of accelerometer samples processed // per tick to avoid falling behind. maxSampleBatch = 200 // sensorStartupDelay gives the sensor time to start producing data. sensorStartupDelay = 100 * time.Millisecond ) type soundPack struct { name string fs embed.FS dir string mode playMode files []string custom bool } func (sp *soundPack) loadFiles() error { if sp.custom { entries, err := os.ReadDir(sp.dir) if err != nil { return err } sp.files = make([]string, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { sp.files = append(sp.files, sp.dir+"/"+entry.Name()) } } } else { entries, err := sp.fs.ReadDir(sp.dir) if err != nil { return err } sp.files = make([]string, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { sp.files = append(sp.files, sp.dir+"/"+entry.Name()) } } } sort.Strings(sp.files) if len(sp.files) == 0 { return fmt.Errorf("no audio files found in %s", sp.dir) } return nil } type slapTracker struct { mu sync.Mutex score float64 lastTime time.Time total int halfLife float64 // seconds scale float64 // controls the escalation curve shape pack *soundPack } func newSlapTracker(pack *soundPack, cooldown time.Duration) *slapTracker { // scale maps the exponential curve so that sustained max-rate // slapping (one per cooldown) reaches the final file. At steady // state the score converges to ssMax; we set scale so that score // maps to the last index. cooldownSec := cooldown.Seconds() ssMax := 1.0 / (1.0 - math.Pow(0.5, cooldownSec/decayHalfLife)) scale := (ssMax - 1) / math.Log(float64(len(pack.files)+1)) return &slapTracker{ halfLife: decayHalfLife, scale: scale, pack: pack, } } func (st *slapTracker) record(now time.Time) (int, float64) { st.mu.Lock() defer st.mu.Unlock() if !st.lastTime.IsZero() { elapsed := now.Sub(st.lastTime).Seconds() st.score *= math.Pow(0.5, elapsed/st.halfLife) } st.score += 1.0 st.lastTime = now st.total++ return st.total, st.score } func (st *slapTracker) getFile(score float64) string { if st.pack.mode == modeRandom { return st.pack.files[rand.Intn(len(st.pack.files))] } // Escalation: 1-exp(-x) curve maps score to file index. // At sustained max slap rate, score reaches ssMax which maps // to the final file. maxIdx := len(st.pack.files) - 1 idx := int(float64(len(st.pack.files)) * (1.0 - math.Exp(-(score-1)/st.scale))) if idx > maxIdx { idx = maxIdx } return st.pack.files[idx] } func main() { cmd := &cobra.Command{ Use: "spank", Short: "Yells 'ow!' when you slap the laptop", Long: `spank reads the Apple Silicon accelerometer directly via IOKit HID and plays audio responses when a slap or hit is detected. Requires sudo (for IOKit HID access to the accelerometer). Use --sexy for a different experience. In sexy mode, the more you slap within a minute, the more intense the sounds become. Use --halo to play random audio clips from Halo soundtracks on each slap.`, Version: version, RunE: func(cmd *cobra.Command, args []string) error { return run(cmd.Context()) }, SilenceUsage: true, } cmd.Flags().BoolVarP(&sexyMode, "sexy", "s", false, "Enable sexy mode") cmd.Flags().BoolVarP(&haloMode, "halo", "H", false, "Enable halo mode") cmd.Flags().StringVarP(&customPath, "custom", "c", "", "Path to custom MP3 audio directory") cmd.Flags().StringSliceVar(&customFiles, "custom-files", nil, "Comma-separated list of custom MP3 files") cmd.Flags().Float64Var(&minAmplitude, "min-amplitude", 0.05, "Minimum amplitude threshold (0.0-1.0, lower = more sensitive)") cmd.Flags().IntVar(&cooldownMs, "cooldown", defaultCooldownMs, "Cooldown between responses in milliseconds") cmd.Flags().BoolVar(&stdioMode, "stdio", false, "Enable stdio mode: JSON output and stdin commands (for GUI integration)") if err := fang.Execute(context.Background(), cmd); err != nil { os.Exit(1) } } func run(ctx context.Context) error { if os.Geteuid() != 0 { return fmt.Errorf("spank requires root privileges for accelerometer access, run with: sudo spank") } modeCount := 0 if sexyMode { modeCount++ } if haloMode { modeCount++ } if customPath != "" || len(customFiles) > 0 { modeCount++ } if modeCount > 1 { return fmt.Errorf("--sexy, --halo, and --custom/--custom-files are mutually exclusive; pick one") } if minAmplitude < 0 || minAmplitude > 1 { return fmt.Errorf("--min-amplitude must be between 0.0 and 1.0") } var pack *soundPack switch { case len(customFiles) > 0: // Validate all files exist and are MP3s for _, f := range customFiles { if !strings.HasSuffix(strings.ToLower(f), ".mp3") { return fmt.Errorf("custom file must be MP3: %s", f) } if _, err := os.Stat(f); err != nil { return fmt.Errorf("custom file not found: %s", f) } } pack = &soundPack{name: "custom", mode: modeRandom, custom: true, files: customFiles} case customPath != "": pack = &soundPack{name: "custom", dir: customPath, mode: modeRandom, custom: true} case sexyMode: pack = &soundPack{name: "sexy", fs: sexyAudio, dir: "audio/sexy", mode: modeEscalation} case haloMode: pack = &soundPack{name: "halo", fs: haloAudio, dir: "audio/halo", mode: modeRandom} default: pack = &soundPack{name: "pain", fs: painAudio, dir: "audio/pain", mode: modeRandom} } // Only load files if not already set (customFiles case) if len(pack.files) == 0 { if err := pack.loadFiles(); err != nil { return fmt.Errorf("loading %s audio: %w", pack.name, err) } } ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) defer cancel() // Create shared memory for accelerometer data. accelRing, err := shm.CreateRing(shm.NameAccel) if err != nil { return fmt.Errorf("creating accel shm: %w", err) } defer accelRing.Close() defer accelRing.Unlink() // Start the sensor worker in a background goroutine. // sensor.Run() needs runtime.LockOSThread for CFRunLoop, which it // handles internally. We launch detection on the current goroutine. go func() { close(sensorReady) if err := sensor.Run(sensor.Config{ AccelRing: accelRing, Restarts: 0, }); err != nil { sensorErr <- err } }() // Wait for sensor to be ready. select { case <-sensorReady: case err := <-sensorErr: return fmt.Errorf("sensor worker failed: %w", err) case <-ctx.Done(): return nil } // Give the sensor a moment to start producing data. time.Sleep(sensorStartupDelay) cooldown := time.Duration(cooldownMs) * time.Millisecond return listenForSlaps(ctx, pack, accelRing, cooldown) } func listenForSlaps(ctx context.Context, pack *soundPack, accelRing *shm.RingBuffer, cooldown time.Duration) error { tracker := newSlapTracker(pack, cooldown) speakerInit := false det := detector.New() var lastAccelTotal uint64 var lastEventTime time.Time var lastYell time.Time // Start stdin command reader if in JSON mode if stdioMode { go readStdinCommands() } fmt.Printf("spank: listening for slaps in %s mode... (ctrl+c to quit)\n", pack.name) if stdioMode { fmt.Println(`{"status":"ready"}`) } ticker := time.NewTicker(sensorPollInterval) defer ticker.Stop() for { select { case <-ctx.Done(): fmt.Println("\nbye!") return nil case err := <-sensorErr: return fmt.Errorf("sensor worker failed: %w", err) case <-ticker.C: } // Check if paused pausedMu.RLock() isPaused := paused pausedMu.RUnlock() if isPaused { continue } now := time.Now() tNow := float64(now.UnixNano()) / 1e9 samples, newTotal := accelRing.ReadNew(lastAccelTotal, shm.AccelScale) lastAccelTotal = newTotal if len(samples) > maxSampleBatch { samples = samples[len(samples)-maxSampleBatch:] } nSamples := len(samples) for idx, sample := range samples { tSample := tNow - float64(nSamples-idx-1)/float64(det.FS) det.Process(sample.X, sample.Y, sample.Z, tSample) } if len(det.Events) == 0 { continue } ev := det.Events[len(det.Events)-1] if ev.Time == lastEventTime { continue } lastEventTime = ev.Time if time.Since(lastYell) <= cooldown { continue } if ev.Amplitude < minAmplitude { continue } lastYell = now num, score := tracker.record(now) file := tracker.getFile(score) if stdioMode { event := map[string]interface{}{ "timestamp": now.Format(time.RFC3339Nano), "slapNumber": num, "amplitude": ev.Amplitude, "severity": string(ev.Severity), "file": file, } if data, err := json.Marshal(event); err == nil { fmt.Println(string(data)) } } else { fmt.Printf("slap #%d [%s amp=%.5fg] -> %s\n", num, ev.Severity, ev.Amplitude, file) } go playAudio(pack, file, &speakerInit) } } var speakerMu sync.Mutex func playAudio(pack *soundPack, path string, speakerInit *bool) { var streamer beep.StreamSeekCloser var format beep.Format if pack.custom { file, err := os.Open(path) if err != nil { fmt.Fprintf(os.Stderr, "spank: open %s: %v\n", path, err) return } defer file.Close() streamer, format, err = mp3.Decode(file) if err != nil { fmt.Fprintf(os.Stderr, "spank: decode %s: %v\n", path, err) return } } else { data, err := pack.fs.ReadFile(path) if err != nil { fmt.Fprintf(os.Stderr, "spank: read %s: %v\n", path, err) return } streamer, format, err = mp3.Decode(io.NopCloser(bytes.NewReader(data))) if err != nil { fmt.Fprintf(os.Stderr, "spank: decode %s: %v\n", path, err) return } } defer streamer.Close() speakerMu.Lock() if !*speakerInit { speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) *speakerInit = true } speakerMu.Unlock() done := make(chan bool) speaker.Play(beep.Seq(streamer, beep.Callback(func() { done <- true }))) <-done } // stdinCommand represents a command received via stdin type stdinCommand struct { Cmd string `json:"cmd"` Amplitude float64 `json:"amplitude,omitempty"` Cooldown int `json:"cooldown,omitempty"` } // readStdinCommands reads JSON commands from stdin for live control func readStdinCommands() { processCommands(os.Stdin, os.Stdout) } // processCommands reads JSON commands from r and writes responses to w. // This is the testable core of the stdin command handler. func processCommands(r io.Reader, w io.Writer) { scanner := bufio.NewScanner(r) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var cmd stdinCommand if err := json.Unmarshal([]byte(line), &cmd); err != nil { if stdioMode { fmt.Fprintf(w, `{"error":"invalid command: %s"}%s`, err.Error(), "\n") } continue } switch cmd.Cmd { case "pause": pausedMu.Lock() paused = true pausedMu.Unlock() if stdioMode { fmt.Fprintln(w, `{"status":"paused"}`) } case "resume": pausedMu.Lock() paused = false pausedMu.Unlock() if stdioMode { fmt.Fprintln(w, `{"status":"resumed"}`) } case "set": if cmd.Amplitude > 0 && cmd.Amplitude <= 1 { minAmplitude = cmd.Amplitude } if cmd.Cooldown > 0 { cooldownMs = cmd.Cooldown } if stdioMode { fmt.Fprintf(w, `{"status":"settings_updated","amplitude":%.4f,"cooldown":%d}%s`, minAmplitude, cooldownMs, "\n") } case "status": pausedMu.RLock() isPaused := paused pausedMu.RUnlock() if stdioMode { fmt.Fprintf(w, `{"status":"ok","paused":%t,"amplitude":%.4f,"cooldown":%d}%s`, isPaused, minAmplitude, cooldownMs, "\n") } default: if stdioMode { fmt.Fprintf(w, `{"error":"unknown command: %s"}%s`, cmd.Cmd, "\n") } } } }