796e0b68d84a49c31df6672657206acdd2922aba
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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+}