diff --git a/Makefile b/Makefile index 6c0eedb..9383bcd 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 62d234e..9cfca3d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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.)* diff --git a/docs/ASSETS.md b/docs/ASSETS.md index ec303bf..029a36b 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 | diff --git a/docs/spikes/T-0.3-audio.md b/docs/spikes/T-0.3-audio.md new file mode 100644 index 0000000..ed05d88 --- /dev/null +++ b/docs/spikes/T-0.3-audio.md @@ -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) diff --git a/go.mod b/go.mod index a8821af..37260a4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 55205f3..e1b8a4c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go index 840fee4..9257dde 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/audio/assets/click.wav b/internal/audio/assets/click.wav new file mode 100644 index 0000000..5bf7057 Binary files /dev/null and b/internal/audio/assets/click.wav differ diff --git a/internal/audio/assets/exec.wav b/internal/audio/assets/exec.wav new file mode 100644 index 0000000..433002a Binary files /dev/null and b/internal/audio/assets/exec.wav differ diff --git a/internal/audio/assets/modal_close.wav b/internal/audio/assets/modal_close.wav new file mode 100644 index 0000000..a57d5d2 Binary files /dev/null and b/internal/audio/assets/modal_close.wav differ diff --git a/internal/audio/assets/modal_open.wav b/internal/audio/assets/modal_open.wav new file mode 100644 index 0000000..3a949c1 Binary files /dev/null and b/internal/audio/assets/modal_open.wav differ diff --git a/internal/audio/assets/navigate.wav b/internal/audio/assets/navigate.wav new file mode 100644 index 0000000..9bc9703 Binary files /dev/null and b/internal/audio/assets/navigate.wav differ diff --git a/internal/audio/assets/notify_error.wav b/internal/audio/assets/notify_error.wav new file mode 100644 index 0000000..fb27bae Binary files /dev/null and b/internal/audio/assets/notify_error.wav differ diff --git a/internal/audio/assets/notify_success.wav b/internal/audio/assets/notify_success.wav new file mode 100644 index 0000000..a7a573d Binary files /dev/null and b/internal/audio/assets/notify_success.wav differ diff --git a/internal/audio/assets/notify_warning.wav b/internal/audio/assets/notify_warning.wav new file mode 100644 index 0000000..41f31b6 Binary files /dev/null and b/internal/audio/assets/notify_warning.wav differ diff --git a/internal/audio/audio.go b/internal/audio/audio.go new file mode 100644 index 0000000..4207166 --- /dev/null +++ b/internal/audio/audio.go @@ -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 +} diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go new file mode 100644 index 0000000..e475d57 --- /dev/null +++ b/internal/audio/audio_test.go @@ -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) +} diff --git a/internal/audio/sounds.go b/internal/audio/sounds.go new file mode 100644 index 0000000..77656bf --- /dev/null +++ b/internal/audio/sounds.go @@ -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 +} diff --git a/internal/audio/wav.go b/internal/audio/wav.go new file mode 100644 index 0000000..853bfcb --- /dev/null +++ b/internal/audio/wav.go @@ -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 +} diff --git a/internal/audio/wav_test.go b/internal/audio/wav_test.go new file mode 100644 index 0000000..722e93f --- /dev/null +++ b/internal/audio/wav_test.go @@ -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) + } + } +}