Files
tia-love/internal/audio/audio.go
T
oxpoe 5450a89a80
CI / test (push) Failing after 38s
layout: ikuti mock SVG — kolom tengah dashboard, toolbar workspace, kolom Aksi Tab
- Beranda: kolom tengah CTA 'masuk workspace' (klikable), strip Disclaimer
  dan Info aplikasi (versi/repo/config); geometri satu sumber (homeGeom).
- Workspace: baris toolbar — pill Cari (klik atau '/' → cari-lompat daftar)
  + tombol [+ stack]/[konversi] klikable (sementara keduanya jalur konverter).
- Detail kontainer: kolom kanan 'Aksi Tab' (mock TAB ACTION) berisi aksi tab
  aktif yang bisa diklik; disembunyikan di layar sempit agar tab bar utuh.
- Footer: hint aksi diprioritaskan; status diagnostik dibuang bila tak muat.
- internal/version: versi build dipakai CLI & strip Info aplikasi.
- audio: Play/Status aman untuk Engine nil (Model zero-value di tes).
- Tes: 3 skenario E2E baru (CTA, cari toolbar, kolom aksi) + tes layout.
2026-10-16 03:16:32 +07:00

185 lines
3.7 KiB
Go

// 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
}