// Package audio — efek suara interaktif (F-024). // // Inisialisasi asynchronous: New() tidak pernah memblok startup. // Bila tidak ada audio device/server, engine tetap hidup dan Play() menjadi // no-op (degradasi anggun); Status() dipakai status bar & `tia-love doctor`. package audio import ( "bytes" "errors" "os" "sync" "time" "github.com/ebitengine/oto/v3" ) type Options struct { Enabled bool Volume int // 0-100 MuteOnSSH bool } type Engine struct { opts Options muted bool ready chan struct{} mu sync.RWMutex ctx *oto.Context sounds map[Event][]byte initErr error } const initTimeout = 3 * time.Second // New membuat Engine dan — bila aktif — memulai init audio di goroutine // terpisah sehingga waktu startup aplikasi tidak terpengaruh. // // shortcut: engine yang disabled/muted tidak dapat diaktifkan ulang tanpa // restart; upgrade saat layar Pengaturan (Phase 1, F-024) butuh toggle langsung. func New(opts Options) *Engine { e := &Engine{ opts: opts, ready: make(chan struct{}), muted: opts.MuteOnSSH && InSSHSession(), } switch { case !opts.Enabled: e.setErr(errors.New("efek suara dimatikan")) close(e.ready) case e.muted: e.setErr(errors.New("auto-mute: sesi SSH")) close(e.ready) default: go e.init() } return e } func (e *Engine) init() { defer close(e.ready) sounds, err := loadSounds() if err != nil { e.setErr(err) return } ctx, readyCh, err := oto.NewContext(&oto.NewContextOptions{ SampleRate: 44100, ChannelCount: 1, Format: oto.FormatSignedInt16LE, }) if err != nil { e.setErr(err) return } select { case <-readyCh: case <-time.After(initTimeout): e.setErr(errors.New("audio: device tidak siap dalam 3 detik")) return } if err := ctx.Err(); err != nil { e.setErr(err) return } e.mu.Lock() e.ctx, e.sounds = ctx, sounds e.mu.Unlock() } func (e *Engine) setErr(err error) { e.mu.Lock() e.initErr = err e.mu.Unlock() } // Ready melaporkan apakah audio benar-benar siap memutar suara. func (e *Engine) Ready() bool { e.mu.RLock() defer e.mu.RUnlock() return e.ctx != nil } // Err mengembalikan penyebab engine tidak siap (nil bila siap/ masih init). func (e *Engine) Err() error { e.mu.RLock() defer e.mu.RUnlock() return e.initErr } // Muted: auto-mute karena sesi SSH. func (e *Engine) Muted() bool { return e.muted } // WaitReady menunggu init selesai (untuk tes & `doctor`). func (e *Engine) WaitReady(d time.Duration) bool { select { case <-e.ready: case <-time.After(d): return false } return e.Ready() } // Status ringkas untuk status bar / `tia-love doctor`. func (e *Engine) Status() string { if e == nil { // Engine zero-value aman (Model tanpa audio.New) return "nonaktif" } e.mu.RLock() defer e.mu.RUnlock() switch { case e.muted: return "SSH auto-mute" case !e.opts.Enabled: return "nonaktif" case e.ctx != nil: return "siap" case e.initErr != nil: return "gagal: " + e.initErr.Error() default: return "menyiapkan…" } } // Play memutar efek; fire-and-forget, aman dipanggil dari goroutine mana pun. // No-op bila engine belum siap / dimatikan / muted. func (e *Engine) Play(ev Event) { if e == nil || e.muted || !e.opts.Enabled { return } e.mu.RLock() ctx, pcm := e.ctx, e.sounds[ev] e.mu.RUnlock() if ctx == nil || len(pcm) == 0 { return } vol := float64(e.opts.Volume) / 100 if vol < 0 { vol = 0 } else if vol > 1 { vol = 1 } p := ctx.NewPlayer(bytes.NewReader(pcm)) p.SetVolume(vol) p.Play() go func() { for p.IsPlaying() { time.Sleep(5 * time.Millisecond) } _ = p.Close() }() } // InSSHSession mendeteksi sesi SSH lewat env standar. func InSSHSession() bool { for _, k := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { if os.Getenv(k) != "" { return true } } return false }