796e0b68d84a49c31df6672657206acdd2922aba

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

tts: add audio.cpp speech client, WAV validation, and oto playback

Diff

This diff is truncated to protect this page.

  1diff --git a/go.mod b/go.mod
  2index 27e15042f85fe11601d0e1adf661c36c5812c3ec..9814969f4c072eda6aeed5c4c425abfb95b96110 100644
  3--- a/go.mod
  4+++ b/go.mod
  5@@ -3,8 +3,14 @@ module japanese
  6 go 1.27.0
  7 
  8 require (
  9+	github.com/ebitengine/oto/v3 v3.5.0
 10 	github.com/ikawaha/kagome-dict/ipa v1.2.6
 11 	github.com/ikawaha/kagome/v2 v2.11.0
 12 )
 13 
 14-require github.com/ikawaha/kagome-dict v1.1.7 // indirect
 15+require (
 16+	github.com/ebitengine/purego v0.11.0 // indirect
 17+	github.com/ikawaha/kagome-dict v1.1.7 // indirect
 18+	github.com/jfreymuth/pulse v0.1.3 // indirect
 19+	golang.org/x/sys v0.47.0 // indirect
 20+)
 21diff --git a/go.sum b/go.sum
 22index 709824ae8827e345089568e8470bdca017a34cdf..a44f8585d3c71a8fb7db3a4edf6003102143ca71 100644
 23--- a/go.sum
 24+++ b/go.sum
 25@@ -1,6 +1,14 @@
 26+github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GPXs=
 27+github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o=
 28+github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
 29+github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
 30 github.com/ikawaha/kagome-dict v1.1.7 h1:O/uAL+WCGhp6kT0+szxBSPaSM4i+vdArSefFvJE4Nug=
 31 github.com/ikawaha/kagome-dict v1.1.7/go.mod h1:9tvk7/jZkvYt40foxkB9CqSAAknoQrIPfzqQd05UkFw=
 32 github.com/ikawaha/kagome-dict/ipa v1.2.6 h1:Bcvm4jgxAAnTIKb6ckqUKBiFDN0wuanFfycMuYt7xGQ=
 33 github.com/ikawaha/kagome-dict/ipa v1.2.6/go.mod h1:ONdTMUAKMCq9yx4s69QRtPcJLEMVM0BNNYQrMCJLWb0=
 34 github.com/ikawaha/kagome/v2 v2.11.0 h1:R914EkRzay9qtUbsFzEbcdZ3wHwwSPvbPkuBI1oIf78=
 35 github.com/ikawaha/kagome/v2 v2.11.0/go.mod h1:6mYPezBou+iNVnX9uNa00Sfu6S6t2zcM8Nv1EW9Y9so=
 36+github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
 37+github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
 38+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
 39+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 40diff --git a/internal/tts/.gitkeep b/internal/tts/.gitkeep
 41deleted file mode 100644
 42index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
 43--- a/internal/tts/.gitkeep
 44+++ /dev/null
 45diff --git a/internal/tts/client.go b/internal/tts/client.go
 46new file mode 100644
 47index 0000000000000000000000000000000000000000..c3bb07839f16c18485a9bff5ac451dc81d088424
 48--- /dev/null
 49+++ b/internal/tts/client.go
 50@@ -0,0 +1,86 @@
 51+package tts
 52+
 53+import (
 54+	"bytes"
 55+	"context"
 56+	"encoding/json"
 57+	"fmt"
 58+	"io"
 59+	"net/http"
 60+	"strings"
 61+)
 62+
 63+const (
 64+	speechPath        = "/audio/speech"
 65+	modelName         = "qwen3"
 66+	defaultVoice      = "Ono_Anna"
 67+	ttsLanguage       = "Japanese"
 68+	speakInstructions = "Speak at a natural, conversational pace with clear articulation and natural pauses between sentences."
 69+	speechSeed        = 1234
 70+)
 71+
 72+// Client is the audio.cpp Qwen3-TTS speech client. BaseURL is the configured
 73+// TTS base (for example http://127.0.0.1:8080/v1); Speech posts to
 74+// {BaseURL}/audio/speech.
 75+type Client struct {
 76+	BaseURL string
 77+	HTTP    *http.Client
 78+}
 79+
 80+func NewClient(baseURL string, hc *http.Client) *Client {
 81+	if hc == nil {
 82+		hc = &http.Client{}
 83+	}
 84+	return &Client{BaseURL: baseURL, HTTP: hc}
 85+}
 86+
 87+type speechRequest struct {
 88+	Model          string `json:"model"`
 89+	Input          string `json:"input"`
 90+	Voice          string `json:"voice"`
 91+	Language       string `json:"language"`
 92+	Instructions   string `json:"instructions"`
 93+	Seed           int    `json:"seed"`
 94+	ResponseFormat string `json:"response_format"`
 95+}
 96+
 97+// Speech posts kana to the speech endpoint and returns the WAV bytes.
 98+func (c *Client) Speech(ctx context.Context, kana string) ([]byte, error) {
 99+	body, err := json.Marshal(speechRequest{
100+		Model:          modelName,
101+		Input:          kana,
102+		Voice:          defaultVoice,
103+		Language:       ttsLanguage,
104+		Instructions:   speakInstructions,
105+		Seed:           speechSeed,
106+		ResponseFormat: "wav",
107+	})
108+	if err != nil {
109+		return nil, fmt.Errorf("tts: encode request: %w", err)
110+	}
111+
112+	url := c.BaseURL + speechPath
113+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
114+	if err != nil {
115+		return nil, fmt.Errorf("tts: build request: %w", err)
116+	}
117+	req.Header.Set("Content-Type", "application/json")
118+
119+	resp, err := c.HTTP.Do(req)
120+	if err != nil {
121+		return nil, fmt.Errorf("tts: request to %s: %w", url, err)
122+	}
123+	defer resp.Body.Close()
124+
125+	raw, err := io.ReadAll(resp.Body)
126+	if err != nil {
127+		return nil, fmt.Errorf("tts: read response from %s: %w", url, err)
128+	}
129+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
130+		return nil, fmt.Errorf("tts: %s returned HTTP %d: %s", url, resp.StatusCode, strings.TrimSpace(string(raw)))
131+	}
132+	if _, err := ParseWAV(raw); err != nil {
133+		return nil, fmt.Errorf("%s returned an invalid WAV response: %w", url, err)
134+	}
135+	return raw, nil
136+}
137diff --git a/internal/tts/client_test.go b/internal/tts/client_test.go
138new file mode 100644
139index 0000000000000000000000000000000000000000..3b664a86a85fae2527cfa1aa614eee0547d18dd7
140--- /dev/null
141+++ b/internal/tts/client_test.go
142@@ -0,0 +1,89 @@
143+package tts
144+
145+import (
146+	"context"
147+	"encoding/json"
148+	"io"
149+	"net/http"
150+	"net/http/httptest"
151+	"strings"
152+	"testing"
153+)
154+
155+func TestSpeechRequestAndResponse(t *testing.T) {
156+	respWAV := makeWAV(t, 24000, 1, 16, []byte{9, 8, 7})
157+
158+	var gotPath, gotContentType string
159+	var gotReq speechRequest
160+
161+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
162+		gotPath = r.URL.Path
163+		gotContentType = r.Header.Get("Content-Type")
164+		body, _ := io.ReadAll(r.Body)
165+		_ = json.Unmarshal(body, &gotReq)
166+		w.Write(respWAV)
167+	}))
168+	defer srv.Close()
169+
170+	c := NewClient(srv.URL, srv.Client())
171+	got, err := c.Speech(context.Background(), "こんにちは")
172+	if err != nil {
173+		t.Fatalf("Speech: %v", err)
174+	}
175+
176+	if string(got) != string(respWAV) {
177+		t.Errorf("response bytes = %d bytes, want the WAV (%d bytes)", len(got), len(respWAV))
178+	}
179+	if gotPath != "/audio/speech" {
180+		t.Errorf("path = %q, want /audio/speech", gotPath)
181+	}
182+	if !strings.Contains(gotContentType, "application/json") {
183+		t.Errorf("content-type = %q, want application/json", gotContentType)
184+	}
185+	if gotReq.Model != modelName {
186+		t.Errorf("model = %q, want %q", gotReq.Model, modelName)
187+	}
188+	if gotReq.Input != "こんにちは" {
189+		t.Errorf("input = %q, want こんにちは", gotReq.Input)
190+	}
191+	if gotReq.Voice != defaultVoice {
192+		t.Errorf("voice = %q, want %q", gotReq.Voice, defaultVoice)
193+	}
194+	if gotReq.Language != ttsLanguage {
195+		t.Errorf("language = %q, want %q", gotReq.Language, ttsLanguage)
196+	}
197+	if gotReq.Seed != speechSeed {
198+		t.Errorf("seed = %d, want %d", gotReq.Seed, speechSeed)
199+	}
200+	if gotReq.ResponseFormat != "wav" {
201+		t.Errorf("response_format = %q, want wav", gotReq.ResponseFormat)
202+	}
203+	if !strings.Contains(gotReq.Instructions, "natural") {
204+		t.Errorf("instructions = %q, want it to mention natural pace", gotReq.Instructions)
205+	}
206+}
207+
208+func TestSpeechHTTPError(t *testing.T) {
209+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
210+		http.Error(w, "boom", http.StatusInternalServerError)
211+	}))
212+	defer srv.Close()
213+
214+	c := NewClient(srv.URL, srv.Client())
215+	if _, err := c.Speech(context.Background(), "あ"); err == nil || !strings.Contains(err.Error(), "500") {
216+		t.Errorf("expected HTTP 500 error, got %v", err)
217+	}
218+}
219+
220+func TestSpeechRejectsNonWAV(t *testing.T) {
221+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222+		w.Write([]byte("definitely not a wav file"))
223+	}))
224+	defer srv.Close()
225+
226+	c := NewClient(srv.URL, srv.Client())
227+	_, err := c.Speech(context.Background(), "あ")
228+	if err == nil || !strings.Contains(err.Error(), srv.URL) {
229+		t.Errorf("expected a non-WAV error naming the URL %q, got %v", srv.URL, err)
230+	}
231+}
232diff --git a/internal/tts/player.go b/internal/tts/player.go
233new file mode 100644
234index 0000000000000000000000000000000000000000..227b23098c5b40e15eca7f4530ad592dc92a1092
235--- /dev/null
236+++ b/internal/tts/player.go
237@@ -0,0 +1,121 @@
238+package tts
239+
240+import (
241+	"bytes"
242+	"context"
243+	"sync"
244+	"sync/atomic"
245+	"time"
246+
247+	"github.com/ebitengine/oto/v3"
248+)
249+
250+// nullPlayDuration is how long a null-output playback runs. It is short so tests
251+// finish quickly while still being interruptible mid-flight.
252+const nullPlayDuration = 40 * time.Millisecond
253+
254+// Player plays synthesized WAV bytes with oto/v3. The audio context is created
255+// once, lazily, at the first clip's sample rate, channel count, and format; all
256+// later clips are played through that same context (oto supports a single
257+// context). Play validates the WAV synchronously and returns any error from
258+// that check; device work happens off the calling goroutine so the TUI event
259+// loop is never blocked. A new Play interrupts an in-flight playback, and Stop
260+// interrupts one explicitly. With nullOutput set, playback runs without opening
261+// an audio device.
262+type Player struct {
263+	mu         sync.Mutex
264+	ctx        *oto.Context
265+	stopCur    context.CancelFunc
266+	active     int32 // atomic count of in-flight playbacks
267+	nullOutput bool
268+}
269+
270+func NewPlayer() *Player { return &Player{} }
271+
272+// Play plays wav without blocking. It returns an error only if the WAV is
273+// invalid; audio-device failures are not surfaced (the clip simply does not
274+// play). Starting a new playback interrupts any in-flight one. The slice may be
275+// used briefly after Play returns, so do not reuse it.
276+func (p *Player) Play(wav []byte) error {
277+	spec, err := ParseWAV(wav)
278+	if err != nil {
279+		return err
280+	}
281+	ctx, cancel := context.WithCancel(context.Background())
282+	p.mu.Lock()
283+	prev := p.stopCur
284+	p.stopCur = cancel
285+	p.mu.Unlock()
286+	if prev != nil {
287+		prev() // interrupt any in-flight playback
288+	}
289+	atomic.AddInt32(&p.active, 1)
290+	go p.play(ctx, spec)
291+	return nil
292+}
293+
294+// Stop interrupts an in-flight playback. It is safe to call when idle.
295+func (p *Player) Stop() {
296+	p.mu.Lock()
297+	cancel := p.stopCur
298+	p.stopCur = nil
299+	p.mu.Unlock()
300+	if cancel != nil {
301+		cancel()
302+	}
303+}
304+
305+func (p *Player) play(ctx context.Context, spec *WAVSpec) {
306+	defer atomic.AddInt32(&p.active, -1)
307+
308+	data := make([]byte, len(spec.Data))
309+	copy(data, spec.Data)
310+
311+	if p.nullOutput {
312+		select {
313+		case <-ctx.Done():
314+		case <-time.After(nullPlayDuration):
315+		}
316+		return
317+	}
318+
319+	p.mu.Lock()
320+	if p.ctx == nil {
321+		opts := &oto.NewContextOptions{
322+			SampleRate:   spec.SampleRate,
323+			ChannelCount: spec.Channels,
324+			Format:       formatForBits(spec.BitsPerSample),
325+		}
326+		ctx0, ready, err := oto.NewContext(opts)
327+		if err != nil {
328+			p.mu.Unlock()
329+			return
330+		}
331+		<-ready
332+		p.ctx = ctx0
333+	}
334+	octx := p.ctx
335+	p.mu.Unlock()
336+
337diff --git a/internal/tts/player_test.go b/internal/tts/player_test.go
338new file mode 100644
339index 0000000000000000000000000000000000000000..2d194a9c5930ba55255e2b28e9495820744bd5b3
340--- /dev/null
341+++ b/internal/tts/player_test.go
342@@ -0,0 +1,55 @@
343+package tts
344+
345+import (
346+	"testing"
347+	"time"
348+)
349+
350+func TestPlayRejectsInvalidWAV(t *testing.T) {
351+	p := NewPlayer()
352+	if err := p.Play([]byte("not a wav")); err == nil {
353+		t.Error("expected an error for an invalid WAV, got nil")
354+	}
355+}
356+
357+func TestPlayNullOutputNoDevice(t *testing.T) {
358+	p := &Player{nullOutput: true}
359+	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
360+	if err := p.Play(wav); err != nil {
361+		t.Fatalf("Play: %v", err)
362+	}
363+	waitForIdle(t, p)
364+}
365+
366+func TestStopInterruptsPlayback(t *testing.T) {
367+	p := &Player{nullOutput: true}
368+	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
369+	if err := p.Play(wav); err != nil {
370+		t.Fatalf("Play: %v", err)
371+	}
372+	p.Stop()
373+	waitForIdle(t, p)
374+}
375+
376+func TestNewPlaybackInterruptsPrevious(t *testing.T) {
377+	p := &Player{nullOutput: true}
378+	wav := makeWAV(t, 24000, 1, 16, []byte{0, 1, 2, 3})
379+	if err := p.Play(wav); err != nil {
380+		t.Fatalf("Play first: %v", err)
381+	}
382+	if err := p.Play(wav); err != nil {
383+		t.Fatalf("Play second: %v", err)
384+	}
385+	waitForIdle(t, p)
386+}
387+
388+func waitForIdle(t *testing.T, p *Player) {
389+	t.Helper()
390+	deadline := time.Now().Add(2 * time.Second)
391+	for p.isPlaying() {
392+		if time.Now().After(deadline) {
393+			t.Fatal("timed out waiting for playback to finish")
394+		}
395+		time.Sleep(5 * time.Millisecond)
396+	}
397+}
398diff --git a/internal/tts/wav.go b/internal/tts/wav.go
399new file mode 100644
400index 0000000000000000000000000000000000000000..bc30347cbbfa5253b6c5315674f04f0c45048db8
401--- /dev/null
402+++ b/internal/tts/wav.go
403@@ -0,0 +1,101 @@
404+// Package tts is the audio.cpp speech client, a minimal WAV decoder, and
405+// non-blocking oto/v3 playback. It is an HTTP client only and never starts or
406+// manages any model service.
407+package tts
408+
409+import (
410+	"encoding/binary"
411+	"errors"
412+	"fmt"
413+)
414+
415+// WAVSpec describes the decoded PCM payload of a WAV file.
416+type WAVSpec struct {
417+	SampleRate    int
418+	Channels      int
419+	BitsPerSample int
420+	Data          []byte // raw little-endian PCM samples
421+}
422+
423+var errNotWAV = errors.New("tts: not a valid WAV file")
424+
425+// ParseWAV decodes a RIFF/WAVE file into its PCM payload. It supports 8- and
426+// 16-bit PCM only.
427+func ParseWAV(b []byte) (*WAVSpec, error) {
428+	if len(b) < 44 {
429+		return nil, fmt.Errorf("%w: too short (%d bytes)", errNotWAV, len(b))
430+	}
431+	if string(b[0:4]) != "RIFF" {
432+		return nil, fmt.Errorf("%w: bad RIFF magic", errNotWAV)
433+	}
434+	if string(b[8:12]) != "WAVE" {
435+		return nil, fmt.Errorf("%w: not a WAVE form", errNotWAV)
436+	}
437+
438+	var (
439+		sampleRate int
440+		channels   int
441+		bits       int
442+		dataOffset int
443+		dataSize   int
444+		foundFmt   bool
445+		foundData  bool
446+	)
447+
448+	pos := 12
449+	for pos+8 <= len(b) {
450+		chunkID := string(b[pos : pos+4])
451+		chunkSize := int(binary.LittleEndian.Uint32(b[pos+4 : pos+8]))
452+		body := pos + 8
453+		if body+chunkSize > len(b) {
454+			return nil, fmt.Errorf("%w: truncated chunk %q", errNotWAV, chunkID)
455+		}
456+		switch chunkID {
457+		case "fmt ":
458+			fb := b[body : body+chunkSize]
459+			if len(fb) < 16 {
460+				return nil, fmt.Errorf("%w: short fmt chunk", errNotWAV)
461+			}
462+			if binary.LittleEndian.Uint16(fb[0:2]) != 1 { // 1 = PCM
463+				return nil, fmt.Errorf("%w: not PCM format", errNotWAV)
464+			}
465+			channels = int(binary.LittleEndian.Uint16(fb[2:4]))
466+			sampleRate = int(binary.LittleEndian.Uint32(fb[4:8]))
467+			bits = int(binary.LittleEndian.Uint16(fb[14:16]))
468+			foundFmt = true
469+		case "data":
470+			dataOffset = body
471+			dataSize = chunkSize
472+			foundData = true
473+		}
474+		pos = body + chunkSize
475+		if chunkSize&1 == 1 { // RIFF chunks are word-aligned; skip the pad byte
476+			pos++
477+		}
478+	}
479+
480+	if !foundFmt || !foundData {
481+		return nil, fmt.Errorf("%w: missing fmt or data chunk", errNotWAV)
482+	}
483+	if channels < 1 || sampleRate <= 0 {
484+		return nil, fmt.Errorf("%w: invalid channel count or sample rate", errNotWAV)
485+	}
486+	switch bits {
487+	case 8, 16:
488+	default:
489+		return nil, fmt.Errorf("%w: unsupported bit depth %d (want 8 or 16)", errNotWAV, bits)
490+	}
491+
492+	return &WAVSpec{
493+		SampleRate:    sampleRate,
494+		Channels:      channels,
495+		BitsPerSample: bits,
496+		Data:          b[dataOffset : dataOffset+dataSize],
497+	}, nil
498+}
499+
500+// ValidateWAV reports whether b is a playable PCM WAV file.
501+func ValidateWAV(b []byte) error {
502+	_, err := ParseWAV(b)
503diff --git a/internal/tts/wav_test.go b/internal/tts/wav_test.go
504new file mode 100644
505index 0000000000000000000000000000000000000000..a187058028edb21743e0b70c81d3b6f5e7bc98c4
506--- /dev/null
507+++ b/internal/tts/wav_test.go
508@@ -0,0 +1,79 @@
509+package tts
510+
511+import (
512+	"bytes"
513+	"encoding/binary"
514+	"testing"
515+)
516+
517+// makeWAV builds a canonical 44-byte-header PCM WAV file.
518+func makeWAV(t *testing.T, sampleRate, channels, bits int, payload []byte) []byte {
519+	t.Helper()
520+	blockAlign := channels * (bits / 8)
521+	byteRate := sampleRate * blockAlign
522+
523+	var fmtBody [16]byte
524+	binary.LittleEndian.PutUint16(fmtBody[0:2], 1) // PCM
525+	binary.LittleEndian.PutUint16(fmtBody[2:4], uint16(channels))
526+	binary.LittleEndian.PutUint32(fmtBody[4:8], uint32(sampleRate))
527+	binary.LittleEndian.PutUint32(fmtBody[8:12], uint32(byteRate))
528+	binary.LittleEndian.PutUint16(fmtBody[12:14], uint16(blockAlign))
529+	binary.LittleEndian.PutUint16(fmtBody[14:16], uint16(bits))
530+
531+	var out bytes.Buffer
532+	out.WriteString("RIFF")
533+	var riffSize [4]byte
534+	binary.LittleEndian.PutUint32(riffSize[:], uint32(36+len(payload)))
535+	out.Write(riffSize[:])
536+	out.WriteString("WAVE")
537+	out.WriteString("fmt ")
538+	var fmtSize [4]byte
539+	binary.LittleEndian.PutUint32(fmtSize[:], 16)
540+	out.Write(fmtSize[:])
541+	out.Write(fmtBody[:])
542+	out.WriteString("data")
543+	var dataSize [4]byte
544+	binary.LittleEndian.PutUint32(dataSize[:], uint32(len(payload)))
545+	out.Write(dataSize[:])
546+	out.Write(payload)
547+	return out.Bytes()
548+}
549+
550+func TestParseWAVValid(t *testing.T) {
551+	payload := []byte{0x00, 0x64, 0x9C, 0xFF, 0x32, 0x00, 0xFE, 0xCA}
552+	wav := makeWAV(t, 24000, 1, 16, payload)
553+
554+	spec, err := ParseWAV(wav)
555+	if err != nil {
556+		t.Fatalf("ParseWAV: %v", err)
557+	}
558+	if spec.SampleRate != 24000 || spec.Channels != 1 || spec.BitsPerSample != 16 {
559+		t.Errorf("spec = rate=%d ch=%d bits=%d, want 24000/1/16", spec.SampleRate, spec.Channels, spec.BitsPerSample)
560+	}
561+	if !bytes.Equal(spec.Data, payload) {
562+		t.Errorf("Data = %v, want %v", spec.Data, payload)
563+	}
564+}
565+
566+func TestValidateWAVInvalid(t *testing.T) {
567+	notWaveForm := makeWAV(t, 24000, 1, 16, []byte{1, 2, 3, 4})
568+	notWaveForm[8] = 'X'
569+
570+	cases := map[string][]byte{
571+		"empty":         {},
572+		"too_short":     []byte("RIFFWAVE1234"),
573+		"bad_magic":     append([]byte("NOTARIFF"), make([]byte, 40)...),
574+		"not_wave_form": notWaveForm,
575+	}
576+
577+	for name, b := range cases {
578+		if err := ValidateWAV(b); err == nil {
579+			t.Errorf("%s: expected error, got nil", name)
580+		}
581+	}
582+
583+	// unsupported bit depth (24-bit) must be rejected
584+	if err := ValidateWAV(makeWAV(t, 24000, 1, 24, []byte{1})); err == nil {
585+		t.Error("24-bit: expected error, got nil")
586+	}
587+}