-
Notifications
You must be signed in to change notification settings - Fork 241
Expand file tree
/
Copy pathmain.go
More file actions
646 lines (568 loc) · 17.4 KB
/
main.go
File metadata and controls
646 lines (568 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
// 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/effects"
"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
//go:embed audio/lizard/*.mp3
var lizardAudio embed.FS
var (
sexyMode bool
haloMode bool
lizardMode bool
customPath string
customFiles []string
fastMode bool
minAmplitude float64
cooldownMs int
stdioMode bool
volumeScaling bool
paused bool
pausedMu sync.RWMutex
speedRatio float64
)
// 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
// defaultMinAmplitude is the default detection threshold.
defaultMinAmplitude = 0.05
// defaultCooldownMs is the default cooldown between audio responses.
defaultCooldownMs = 750
// defaultSpeedRatio is the default playback speed (1.0 = normal).
defaultSpeedRatio = 1.0
// defaultSensorPollInterval is how often we check for new accelerometer data.
defaultSensorPollInterval = 10 * time.Millisecond
// defaultMaxSampleBatch caps the number of accelerometer samples processed
// per tick to avoid falling behind.
defaultMaxSampleBatch = 200
// sensorStartupDelay gives the sensor time to start producing data.
sensorStartupDelay = 100 * time.Millisecond
)
type runtimeTuning struct {
minAmplitude float64
cooldown time.Duration
pollInterval time.Duration
maxBatch int
}
func defaultTuning() runtimeTuning {
return runtimeTuning{
minAmplitude: defaultMinAmplitude,
cooldown: time.Duration(defaultCooldownMs) * time.Millisecond,
pollInterval: defaultSensorPollInterval,
maxBatch: defaultMaxSampleBatch,
}
}
func applyFastOverlay(base runtimeTuning) runtimeTuning {
base.pollInterval = 4 * time.Millisecond
base.cooldown = 350 * time.Millisecond
if base.minAmplitude > 0.18 {
base.minAmplitude = 0.18
}
if base.maxBatch < 320 {
base.maxBatch = 320
}
return base
}
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 := min(int(float64(len(st.pack.files)) * (1.0 - math.Exp(-(score-1)/st.scale))), 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.
Use --lizard for lizard mode. Like sexy mode, the more you slap
within a minute, the more intense the sounds become.`,
Version: version,
RunE: func(cmd *cobra.Command, args []string) error {
tuning := defaultTuning()
if fastMode {
tuning = applyFastOverlay(tuning)
}
// Explicit flags override fast preset defaults
if cmd.Flags().Changed("min-amplitude") {
tuning.minAmplitude = minAmplitude
}
if cmd.Flags().Changed("cooldown") {
tuning.cooldown = time.Duration(cooldownMs) * time.Millisecond
}
return run(cmd.Context(), tuning)
},
SilenceUsage: true,
}
cmd.Flags().BoolVarP(&sexyMode, "sexy", "s", false, "Enable sexy mode")
cmd.Flags().BoolVarP(&haloMode, "halo", "H", false, "Enable halo mode")
cmd.Flags().BoolVarP(&lizardMode, "lizard", "l", false, "Enable lizard mode (escalating intensity)")
cmd.Flags().StringVarP(&customPath, "custom", "c", "", "Path to custom MP3 audio directory")
cmd.Flags().BoolVar(&fastMode, "fast", false, "Enable faster detection tuning (shorter cooldown, higher sensitivity)")
cmd.Flags().StringSliceVar(&customFiles, "custom-files", nil, "Comma-separated list of custom MP3 files")
cmd.Flags().Float64Var(&minAmplitude, "min-amplitude", defaultMinAmplitude, "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)")
cmd.Flags().BoolVar(&volumeScaling, "volume-scaling", false, "Scale playback volume by slap amplitude (harder hits = louder)")
cmd.Flags().Float64Var(&speedRatio, "speed", defaultSpeedRatio, "Playback speed multiplier (0.5 = half speed, 2.0 = double speed)")
if err := fang.Execute(context.Background(), cmd); err != nil {
os.Exit(1)
}
}
func run(ctx context.Context, tuning runtimeTuning) 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 lizardMode {
modeCount++
}
if customPath != "" || len(customFiles) > 0 {
modeCount++
}
if modeCount > 1 {
return fmt.Errorf("--sexy, --halo, --lizard, and --custom/--custom-files are mutually exclusive; pick one")
}
if tuning.minAmplitude < 0 || tuning.minAmplitude > 1 {
return fmt.Errorf("--min-amplitude must be between 0.0 and 1.0")
}
if tuning.cooldown <= 0 {
return fmt.Errorf("--cooldown must be greater than 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}
case lizardMode:
pack = &soundPack{name: "lizard", fs: lizardAudio, dir: "audio/lizard", mode: modeEscalation}
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)
return listenForSlaps(ctx, pack, accelRing, tuning)
}
func listenForSlaps(ctx context.Context, pack *soundPack, accelRing *shm.RingBuffer, tuning runtimeTuning) error {
tracker := newSlapTracker(pack, tuning.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()
}
presetLabel := "default"
if fastMode {
presetLabel = "fast"
}
fmt.Printf("spank: listening for slaps in %s mode with %s tuning... (ctrl+c to quit)\n", pack.name, presetLabel)
if stdioMode {
fmt.Println(`{"status":"ready"}`)
}
ticker := time.NewTicker(tuning.pollInterval)
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) > tuning.maxBatch {
samples = samples[len(samples)-tuning.maxBatch:]
}
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.Equal(lastEventTime) {
continue
}
lastEventTime = ev.Time
if time.Since(lastYell) <= time.Duration(cooldownMs)*time.Millisecond {
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, ev.Amplitude, &speakerInit)
}
}
var speakerMu sync.Mutex
// amplitudeToVolume maps a detected amplitude to a beep/effects.Volume
// level. Amplitude typically ranges from ~0.05 (light tap) to ~1.0+
// (hard slap). The mapping uses a logarithmic curve so that light taps
// are noticeably quieter and hard hits play near full volume.
//
// Returns a value in the range [-3.0, 0.0] for use with effects.Volume
// (base 2): -3.0 is ~1/8 volume, 0.0 is full volume.
func amplitudeToVolume(amplitude float64) float64 {
const (
minAmp = 0.05 // softest detectable
maxAmp = 0.80 // treat anything above this as max
minVol = -3.0 // quietest playback (1/8 volume with base 2)
maxVol = 0.0 // full volume
)
// Clamp
if amplitude <= minAmp {
return minVol
}
if amplitude >= maxAmp {
return maxVol
}
// Normalize to [0, 1]
t := (amplitude - minAmp) / (maxAmp - minAmp)
// Log curve for more natural volume scaling
// log(1 + t*99) / log(100) maps [0,1] -> [0,1] with a log curve
t = math.Log(1+t*99) / math.Log(100)
return minVol + t*(maxVol-minVol)
}
func playAudio(pack *soundPack, path string, amplitude float64, 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()
// Optionally scale volume based on slap amplitude
var source beep.Streamer = streamer
if volumeScaling {
source = &effects.Volume{
Streamer: streamer,
Base: 2,
Volume: amplitudeToVolume(amplitude),
Silent: false,
}
}
// Apply speed change via resampling trick:
// Claiming the audio is at rate*speed and resampling back to rate
// makes the speaker consume samples faster/slower.
if speedRatio != 1.0 && speedRatio > 0 {
fakeRate := beep.SampleRate(int(float64(format.SampleRate) * speedRatio))
source = beep.Resample(4, fakeRate, format.SampleRate, source)
}
done := make(chan bool)
speaker.Play(beep.Seq(source, 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"`
Speed float64 `json:"speed,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 cmd.Speed > 0 {
speedRatio = cmd.Speed
}
if stdioMode {
fmt.Fprintf(w, `{"status":"settings_updated","amplitude":%.4f,"cooldown":%d,"speed":%.2f}%s`, minAmplitude, cooldownMs, speedRatio, "\n")
}
case "volume-scaling":
volumeScaling = !volumeScaling
if stdioMode {
fmt.Fprintf(w, `{"status":"volume_scaling_toggled","volume_scaling":%t}%s`, volumeScaling, "\n")
}
case "status":
pausedMu.RLock()
isPaused := paused
pausedMu.RUnlock()
if stdioMode {
fmt.Fprintf(w, `{"status":"ok","paused":%t,"amplitude":%.4f,"cooldown":%d,"volume_scaling":%t,"speed":%.2f}%s`, isPaused, minAmplitude, cooldownMs, volumeScaling, speedRatio, "\n")
}
default:
if stdioMode {
fmt.Fprintf(w, `{"error":"unknown command: %s"}%s`, cmd.Cmd, "\n")
}
}
}
}