feat(audio): T-0.3 spike lolos — engine oto v3.5.1 async + fallback anggun (F-024 awal)
This commit is contained in:
1 parent
3b4bbf09bc
commit
b8d2595ca9
20 files changed
+497
-10
No files matched your search
@@ -3,7 +3,7 @@ BINARY := tia-love
|
||||
.PHONY: build run test vet fmt clean
|
||||
|
||||
build:
|
||||
go build -o $(BINARY) ./cmd/tia-love
|
||||
CGO_ENABLED=0 go build -o $(BINARY) ./cmd/tia-love
|
||||
|
||||
run: build
|
||||
./$(BINARY)
|
||||
|
||||
@@ -20,6 +20,9 @@ Repo referensi hanya ada di `reference/` (git-ignored).
|
||||
|---|---|---|
|
||||
| bubbletea | v2.1.0 | MIT |
|
||||
| lipgloss | v2.0.6 | MIT |
|
||||
| oto | v3.5.1 | Apache-2.0 |
|
||||
| purego *(transitif oto — driver ALSA)* | v0.11.0 | Apache-2.0 |
|
||||
| jfreymuth/pulse *(transitif oto — driver PulseAudio pure-Go)* | v0.1.3 | MIT |
|
||||
|
||||
*(Daftar bertambah setiap dependency baru diadopsi; wajib selaras dengan kebijakan aset PRD Appendix C.)*
|
||||
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ tersedia atribusinya dari menu **Tentang** di aplikasi.
|
||||
| Aset | Sumber | Lisensi | Tanggal | Status | File target |
|
||||
|---|---|---|---|---|---|
|
||||
| Palet Catppuccin (Mocha/Latte) | https://github.com/catppuccin/catppuccin | MIT | — | planned (Phase 1) | `internal/ui/theme/catppuccin.go` |
|
||||
| Efek suara UI (click, modal, notif, error) | Kenney Interface Sounds — https://kenney.nl/assets/interface-sounds | CC0 1.0 | — | pending (T-0.6) | `internal/audio/assets/*.wav` |
|
||||
| Efek suara UI — placeholder (8 WAV, generated) | buatan sendiri (script sinus, no external) | ikut lisensi tia | 2026-10-15 | dipakai sementara (T-0.3) | `internal/audio/assets/*.wav` |
|
||||
| Efek suara UI final (click, modal, notif, error) | Kenney Interface Sounds — https://kenney.nl/assets/interface-sounds | CC0 1.0 | — | pending (T-0.6) | `internal/audio/assets/*.wav` |
|
||||
| Logo ASCII `tia` | buatan sendiri | ikut lisensi tia | 2026-10 | planned (Phase 1) | `internal/ui/animation/logo.go` |
|
||||
| Ikon/glyph | Unicode standar (●, ○, ▶, ⟳, ✔, ⚠, ✕) | bebas | 2026-10 | dipakai | di kode UI |
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Spike T-0.3 — Audio (oto v3.5.1, CGO_ENABLED=0)
|
||||
|
||||
> Status: ✅ **LOLOS** · Tanggal: 2026-10-15 · Mesin: Linux (PipeWire + pipewire-pulse aktif, `/dev/snd` tersedia)
|
||||
|
||||
## Tujuan (PRD Phase 0)
|
||||
|
||||
Buktikan: suara bisa diputar dari binary `CGO_ENABLED=0`; init audio tidak memblok startup;
|
||||
degradasi anggun tanpa audio device; ukur latensi.
|
||||
|
||||
## Cara menjalankan ulang
|
||||
|
||||
```bash
|
||||
# Skenario 1 — live (butuh PulseAudio/PipeWire)
|
||||
TIA_AUDIO_SPIKE=1 go test ./internal/audio/ -run TestEngineSpike -v -count=1
|
||||
|
||||
# Skenario 2 — pulse rusak → fallback ALSA
|
||||
TIA_AUDIO_SPIKE=1 PULSE_SERVER=unix:/nonexistent go test ./internal/audio/ -run TestEngineSpike -v -count=1
|
||||
|
||||
# Skenario 3 — pulse + ALSA rusak → degradasi total (tidak boleh panic)
|
||||
TIA_AUDIO_SPIKE=1 PULSE_SERVER=unix:/nonexistent ALSA_CONFIG_PATH=/nonexistent/asound.conf \
|
||||
go test ./internal/audio/ -run TestEngineSpike -v -count=1
|
||||
```
|
||||
|
||||
## Hasil
|
||||
|
||||
| # | Skenario | `New()` return | Init + ready | `Play()` return | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Live (PipeWire/pulse socket) | **1.5 µs** | **13.3 ms** | **5.7 – 15.7 µs** | `siap` ✅ |
|
||||
| 2 | Pulse rusak → fallback ALSA | 3.1 µs | 26.1 ms | 7 – 12 µs | `siap` (via ALSA) ✅ |
|
||||
| 3 | Pulse + ALSA rusak | 2.1 µs | 0.4 ms (gagal cepat) | no-op | `gagal: ...` tanpa panic ✅ |
|
||||
|
||||
- Binary **6.7 MB** (`CGO_ENABLED=0`), aset 8 WAV embedded = **128 KB** (budget PRD < 2 MB ✓).
|
||||
- Target PRD: startup < 500 ms → audio init async hanya **1.5 µs** di jalur startup ✓;
|
||||
latensi suara < 100 ms → pemanggilan `Play()` ~10 µs, latency terdengar mengikuti buffer device.
|
||||
- TUI menampilkan status engine live: `audio: menyiapkan…` → `siap` setelah redraw pertama.
|
||||
|
||||
## Temuan penting
|
||||
|
||||
1. **`purego` (dipakai driver ALSA oto) membuat binary link dinamis ke glibc.**
|
||||
Uji kontrol: hello-world `CGO_ENABLED=0` = `statically linked` murni; begitu oto ikut
|
||||
di-compile muncul `libc.so.6`, `libdl`, `libpthread`. Artinya: tetap **satu file & tanpa
|
||||
cgo**, tapi belum `statically linked` penuh. **Alpine/musl belum terverifikasi** → catat
|
||||
sebagai risiko; jalur keluar bila perlu: build varian tanpa driver ALSA (pulse-only).
|
||||
2. **Fallback berantai terbukti**: PulseAudio (pure-Go) → ALSA (purego dlopen `libasound.so.2`)
|
||||
→ error jelas. Pesan error oto ditampilkan apa adanya (sesuai F-023; penjelasan i18n menyusul).
|
||||
3. Auto-mute SSH diuji di unit test (`SSH_CONNECTION` → `muted`), belum diuji di sesi SSH asli.
|
||||
|
||||
## Batasan (jujur)
|
||||
|
||||
- Latensi *terdengar* tidak diukur programatik (butuh loopback/ears) — yang diukur waktu init
|
||||
& waktu `Play()` kembali. Buffer device oto masih default; bila klik terasa delay di hardware
|
||||
nyata, set `BufferSize` (Phase 1).
|
||||
- Belum diuji di macOS, WSL2, dan sesi SSH asli.
|
||||
|
||||
## Status follow-up
|
||||
|
||||
- [ ] Ganti aset placeholder dengan Kenney CC0 (T-0.6)
|
||||
- [ ] Toggle suara/volume langsung dari layar Pengaturan tanpa restart (Phase 1, F-024)
|
||||
- [ ] `tia-love doctor` menampilkan status audio (Phase 1)
|
||||
@@ -5,6 +5,7 @@ go 1.27.1
|
||||
require (
|
||||
charm.land/bubbletea/v2 v2.1.0
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/ebitengine/oto/v3 v3.5.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -16,6 +17,8 @@ require (
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/ebitengine/purego v0.11.0 // indirect
|
||||
github.com/jfreymuth/pulse v0.1.3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
|
||||
@@ -22,6 +22,12 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/ebitengine/oto/v3 v3.5.1 h1:7gL5DxxSQp8S1Me2jDSp+gSAyondYxpjM5RPBBqLT0c=
|
||||
github.com/ebitengine/oto/v3 v3.5.1/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o=
|
||||
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
||||
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
|
||||
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
|
||||
+18
-8
@@ -5,19 +5,25 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/oxpoe/tia/internal/audio"
|
||||
)
|
||||
|
||||
// Model root sementara (T-0.2, Phase 0).
|
||||
// Tujuannya membuktikan Bubble Tea v2 (View struct, mouse, resize) sebelum
|
||||
// layout penuh Phase 1 menggantikannya.
|
||||
// Model root sementara (T-0.2/T-0.3, Phase 0).
|
||||
// Membuktikan Bubble Tea v2 (View struct, mouse, resize) + audio engine
|
||||
// sebelum layout penuh Phase 1 menggantikannya.
|
||||
type Model struct {
|
||||
width, height int
|
||||
clicks int
|
||||
lastClick string
|
||||
sound *audio.Engine
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
return Model{lastClick: "belum ada klik"}
|
||||
return Model{
|
||||
lastClick: "belum ada klik",
|
||||
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
@@ -30,13 +36,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c", "esc":
|
||||
m.sound.Play(audio.ModalClose)
|
||||
return m, tea.Quit
|
||||
case "j", "down", "k", "up":
|
||||
m.sound.Play(audio.Navigate)
|
||||
}
|
||||
|
||||
case tea.MouseClickMsg:
|
||||
if msg.Button == tea.MouseLeft {
|
||||
m.clicks++
|
||||
m.lastClick = fmt.Sprintf("klik #%d di (%d, %d)", m.clicks, msg.X, msg.Y)
|
||||
m.sound.Play(audio.Click)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -49,15 +59,15 @@ func (m Model) View() tea.View {
|
||||
Render("tia — Terminal Is A My Way")
|
||||
tagline := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#a6adc8")).
|
||||
Render("Just Work · Phase 0 · skeleton TUI")
|
||||
Render("Just Work · Phase 0 · skeleton TUI + audio")
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#6c7086")).
|
||||
Padding(0, 2).
|
||||
Render(m.lastClick + "\n\nklik mouse di mana saja · tekan q untuk keluar")
|
||||
Render(m.lastClick + "\n\nklik / j / k = bunyi · q = keluar")
|
||||
|
||||
content := fmt.Sprintf("%s\n%s\n\n%s\n\nukuran jendela: %dx%d",
|
||||
title, tagline, box, m.width, m.height)
|
||||
content := fmt.Sprintf("%s\n%s\n\n%s\n\nukuran jendela: %dx%d · audio: %s",
|
||||
title, tagline, box, m.width, m.height, m.sound.Status())
|
||||
|
||||
v := tea.NewView(content)
|
||||
v.AltScreen = true
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,181 @@
|
||||
// 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 {
|
||||
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.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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInSSHSession(t *testing.T) {
|
||||
t.Setenv("SSH_CONNECTION", "")
|
||||
t.Setenv("SSH_CLIENT", "")
|
||||
t.Setenv("SSH_TTY", "")
|
||||
if InSSHSession() {
|
||||
t.Fatal("harusnya false tanpa env SSH")
|
||||
}
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.1 22")
|
||||
if !InSSHSession() {
|
||||
t.Fatal("harusnya true dengan SSH_CONNECTION")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineDisabledGraceful(t *testing.T) {
|
||||
e := New(Options{Enabled: false})
|
||||
if e.Ready() {
|
||||
t.Fatal("engine disabled tidak boleh ready")
|
||||
}
|
||||
if got := e.Status(); got != "nonaktif" {
|
||||
t.Fatalf("status: %q", got)
|
||||
}
|
||||
e.Play(Click) // tidak boleh panic
|
||||
e.Play(Navigate)
|
||||
}
|
||||
|
||||
func TestEngineSSHMute(t *testing.T) {
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.1 22")
|
||||
e := New(Options{Enabled: true, MuteOnSSH: true})
|
||||
if !e.Muted() {
|
||||
t.Fatal("harusnya muted di sesi SSH")
|
||||
}
|
||||
if got := e.Status(); got != "SSH auto-mute" {
|
||||
t.Fatalf("status: %q", got)
|
||||
}
|
||||
e.Play(Click) // no-op
|
||||
}
|
||||
|
||||
// TestEngineSpike — bukti Phase 0 (T-0.3). Jalankan manual:
|
||||
//
|
||||
// TIA_AUDIO_SPIKE=1 go test ./internal/audio/ -run TestEngineSpike -v -count=1
|
||||
//
|
||||
// Mengukur init (harus non-blocking) dan memastikan Play() cepat + tidak panic.
|
||||
// Tanpa audio device, test tetap lulus sebagai bukti degradasi anggun.
|
||||
func TestEngineSpike(t *testing.T) {
|
||||
if os.Getenv("TIA_AUDIO_SPIKE") != "1" {
|
||||
t.Skip("set TIA_AUDIO_SPIKE=1 untuk menjalankan spike audio")
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
e := New(Options{Enabled: true, Volume: 70})
|
||||
t.Logf("New() kembali dalam %v (non-blocking)", time.Since(t0))
|
||||
|
||||
t1 := time.Now()
|
||||
ok := e.WaitReady(5 * time.Second)
|
||||
t.Logf("init+ready selesai dalam %v (ready=%v, status=%q, err=%v)", time.Since(t1), ok, e.Status(), e.Err())
|
||||
|
||||
if !ok {
|
||||
// Skenario tanpa audio device: cukup buktikan tidak panic.
|
||||
e.Play(Click)
|
||||
return
|
||||
}
|
||||
for _, ev := range []Event{Click, Navigate, NotifySuccess} {
|
||||
t2 := time.Now()
|
||||
e.Play(ev)
|
||||
t.Logf("Play(%s) kembali dalam %v", ev, time.Since(t2))
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed assets/*.wav
|
||||
var assets embed.FS
|
||||
|
||||
// Event — jenis efek suara (F-024).
|
||||
type Event string
|
||||
|
||||
const (
|
||||
Click Event = "click"
|
||||
Navigate Event = "navigate"
|
||||
ModalOpen Event = "modal_open"
|
||||
ModalClose Event = "modal_close"
|
||||
NotifySuccess Event = "notify_success"
|
||||
NotifyWarning Event = "notify_warning"
|
||||
NotifyError Event = "notify_error"
|
||||
Exec Event = "exec"
|
||||
)
|
||||
|
||||
var allEvents = []Event{Click, Navigate, ModalOpen, ModalClose, NotifySuccess, NotifyWarning, NotifyError, Exec}
|
||||
|
||||
// loadSounds membaca + memvalidasi semua aset sekali di awal; PCM di-cache
|
||||
// supaya Play() tidak menyentuh disk/embed FS.
|
||||
func loadSounds() (map[Event][]byte, error) {
|
||||
out := make(map[Event][]byte, len(allEvents))
|
||||
for _, ev := range allEvents {
|
||||
raw, err := assets.ReadFile("assets/" + string(ev) + ".wav")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pcm, err := ParsePCM(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s.wav: %w", ev, err)
|
||||
}
|
||||
out[ev] = pcm
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ParsePCM memvalidasi file WAV (RIFF) dan mengembalikan isi chunk "data" (PCM).
|
||||
// Standar aset tia (PRD Appendix C): 44.1 kHz, mono, 16-bit — selain itu ditolak
|
||||
// agar kesalahan aset ketahuan saat dev, bukan saat runtime.
|
||||
func ParsePCM(b []byte) ([]byte, error) {
|
||||
if len(b) < 44 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
|
||||
return nil, errors.New("bukan file RIFF/WAVE")
|
||||
}
|
||||
var (
|
||||
format, channels, bits, rate int
|
||||
data []byte
|
||||
)
|
||||
for off := 12; off+8 <= len(b); {
|
||||
id := string(b[off : off+4])
|
||||
size := int(binary.LittleEndian.Uint32(b[off+4 : off+8]))
|
||||
off += 8
|
||||
if size < 0 || off+size > len(b) {
|
||||
return nil, fmt.Errorf("chunk %q terpotong", id)
|
||||
}
|
||||
switch id {
|
||||
case "fmt ":
|
||||
if size < 16 {
|
||||
return nil, errors.New("chunk fmt terlalu pendek")
|
||||
}
|
||||
format = int(binary.LittleEndian.Uint16(b[off : off+2]))
|
||||
channels = int(binary.LittleEndian.Uint16(b[off+2 : off+4]))
|
||||
rate = int(binary.LittleEndian.Uint32(b[off+4 : off+8]))
|
||||
bits = int(binary.LittleEndian.Uint16(b[off+14 : off+16]))
|
||||
case "data":
|
||||
data = b[off : off+size]
|
||||
}
|
||||
off += size + size%2 // chunk berukuran ganjil dipad 1 byte
|
||||
}
|
||||
if format != 1 {
|
||||
return nil, fmt.Errorf("format %d bukan PCM", format)
|
||||
}
|
||||
if channels != 1 || bits != 16 || rate != 44100 {
|
||||
return nil, fmt.Errorf("hanya mono/16-bit/44100Hz didukung (dapat %dch/%dbit/%dHz)", channels, bits, rate)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("chunk data kosong")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// wavBytes merakit WAV minimal (RIFF/WAVE + fmt + data) untuk pengujian parser.
|
||||
func wavBytes(channels, bits, rate int, data []byte) []byte {
|
||||
b := make([]byte, 0, 44+len(data))
|
||||
b = append(b, "RIFF"...)
|
||||
b = binary.LittleEndian.AppendUint32(b, uint32(36+len(data)))
|
||||
b = append(b, "WAVE"...)
|
||||
b = append(b, "fmt "...)
|
||||
b = binary.LittleEndian.AppendUint32(b, 16) // ukuran fmt
|
||||
b = binary.LittleEndian.AppendUint16(b, 1) // PCM
|
||||
b = binary.LittleEndian.AppendUint16(b, uint16(channels)) // kanal
|
||||
b = binary.LittleEndian.AppendUint32(b, uint32(rate)) // sample rate
|
||||
b = binary.LittleEndian.AppendUint32(b, uint32(rate*channels*bits/8)) // byte rate
|
||||
b = binary.LittleEndian.AppendUint16(b, uint16(channels*bits/8)) // block align
|
||||
b = binary.LittleEndian.AppendUint16(b, uint16(bits)) // bit/sample
|
||||
b = append(b, "data"...)
|
||||
b = binary.LittleEndian.AppendUint32(b, uint32(len(data)))
|
||||
return append(b, data...)
|
||||
}
|
||||
|
||||
func TestParsePCM(t *testing.T) {
|
||||
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
|
||||
|
||||
got, err := ParsePCM(wavBytes(1, 16, 44100, pcm))
|
||||
if err != nil {
|
||||
t.Fatalf("wav valid ditolak: %v", err)
|
||||
}
|
||||
if string(got) != string(pcm) {
|
||||
t.Fatalf("PCM tidak sama: %v", got)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
b []byte
|
||||
}{
|
||||
{"bukan RIFF", []byte("NOPE-nope-nope-nope-nope-nope-nope-nope-nope")},
|
||||
{"stereo", wavBytes(2, 16, 44100, pcm)},
|
||||
{"sample rate salah", wavBytes(1, 16, 48000, pcm)},
|
||||
{"bit depth salah", wavBytes(1, 8, 44100, pcm)},
|
||||
{"data kosong", wavBytes(1, 16, 44100, nil)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := ParsePCM(c.b); err == nil {
|
||||
t.Errorf("%s: seharusnya error", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user