]> git.giacomolli.uk - onechat/blob - voice_transcriber_test.go
Include the small model in the Nix package
[onechat] / voice_transcriber_test.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13 )
14
15 func TestVoiceStoresPrivateAudioAndUsesPlainTextWithInternalTimestamps(t *testing.T) {
16 store, transcriber := voiceTestInput(t, `
17 output=
18 context=
19 while [ "$#" -gt 0 ]; do
20 case "$1" in
21 --no-timestamps) exit 2 ;;
22 --max-context) shift; context=$1 ;;
23 --output-file) shift; output=$1 ;;
24 esac
25 shift
26 done
27 [ "$context" = 0 ]
28 printf ' A made-up voice transcript.\n' > "$output.txt"
29 printf 'private speech in process output'
30 printf 'private speech in process errors' >&2
31 `)
32 data := []byte("made-up audio")
33 text := transcriber.Text(t.Context(), store, data)
34 require.Contains(t, text, "[voice ")
35 require.True(t, strings.HasSuffix(text, "]\nA made-up voice transcript."))
36 require.Equal(t, text, transcriber.Text(t.Context(), store, data))
37
38 files, err := os.ReadDir(store.dir)
39 require.NoError(t, err)
40 require.Len(t, files, 1)
41
42 saved, err := os.ReadFile(filepath.Join(store.dir, files[0].Name()))
43 require.NoError(t, err)
44 require.Equal(t, data, saved)
45
46 info, err := os.Stat(filepath.Join(store.dir, files[0].Name()))
47 require.NoError(t, err)
48 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
49
50 info, err = os.Stat(store.dir)
51 require.NoError(t, err)
52 require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
53 }
54
55 func TestVoiceFailuresKeepStoredAudioAndSafeMarkers(t *testing.T) {
56 testCases := []struct {
57 name string
58 script string
59 }{
60 {
61 name: "failed process does not expose its errors",
62 script: "echo secret-speech >&2; exit 1",
63 },
64 {
65 name: "missing transcript gets a marker",
66 script: "exit 0",
67 },
68 {
69 name: "empty transcript gets a marker",
70 script: "while [ \"$1\" != --output-file ]; do shift; done; shift; touch \"$1.txt\"",
71 },
72 }
73 for _, tc := range testCases {
74 t.Run(tc.name, func(t *testing.T) {
75 store, transcriber := voiceTestInput(t, tc.script)
76 text := transcriber.Text(t.Context(), store, []byte("made-up audio"))
77 require.Contains(t, text, "[voice ")
78 require.Contains(t, text, "(voice not transcribed: local transcription failed)")
79 require.NotContains(t, text, "secret-speech")
80
81 files, err := os.ReadDir(store.dir)
82 require.NoError(t, err)
83 require.Len(t, files, 1)
84 })
85 }
86 }
87
88 func TestVoiceCancellationAndCallerDeadlineStopTheProcess(t *testing.T) {
89 store, transcriber := voiceTestInput(t, "exec sleep 60")
90 ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
91 defer cancel()
92
93 text := transcriber.Text(ctx, store, []byte("made-up audio"))
94 require.Contains(t, text, "context deadline exceeded")
95
96 ctx, cancel = context.WithCancel(t.Context())
97 cancel()
98
99 text = transcriber.Text(ctx, store, []byte("made-up audio"))
100 require.Contains(t, text, "context canceled")
101 }
102
103 func TestVoiceUnavailableAndStorageFailuresDoNotStopIntake(t *testing.T) {
104 store, transcriber := voiceTestInput(t, "exit 0")
105 transcriber.config.Model = filepath.Join(t.TempDir(), "missing")
106 text := transcriber.Text(t.Context(), store, []byte("made-up audio"))
107 require.Contains(t, text, "[voice ")
108 require.Contains(t, text, "transcriber or model is not available")
109 require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, nil))
110 require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, bytes.Repeat([]byte{0}, voiceMaxBytes+1)))
111
112 blocked := filepath.Join(t.TempDir(), "blocked")
113 require.NoError(t, os.WriteFile(blocked, nil, 0o600))
114
115 store.dir = filepath.Join(blocked, "child")
116 require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, []byte("audio")))
117 }
118
119 func TestVoiceBackupsKeepAudioButNotTemporaryTranscripts(t *testing.T) {
120 root := t.TempDir()
121 store := &VoiceStore{dir: filepath.Join(root, voiceDirName)}
122 name, err := store.Save([]byte("made-up backed-up audio"))
123 require.NoError(t, err)
124
125 temp := filepath.Join(store.dir, ".transcript-in-progress")
126 require.NoError(t, os.Mkdir(temp, 0o700))
127 require.NoError(t, os.WriteFile(filepath.Join(temp, "text.txt"), []byte("private temporary text"), 0o600))
128
129 target := t.TempDir()
130 require.NoError(t, backupCopy(t.Context(), root, target, filepath.Join(t.TempDir(), "backup.tar.gz")))
131 require.FileExists(t, filepath.Join(target, voiceDirName, name))
132 require.NoDirExists(t, filepath.Join(target, voiceDirName, ".transcript-in-progress"))
133
134 archive := filepath.Join(t.TempDir(), "voice.tar.gz")
135 _, err = backupArchive(t.Context(), target, archive)
136 require.NoError(t, err)
137
138 restored := t.TempDir()
139 require.NoError(t, backupExtract(t.Context(), archive, restored))
140
141 audio, err := os.ReadFile(filepath.Join(restored, voiceDirName, name))
142 require.NoError(t, err)
143 require.Equal(t, []byte("made-up backed-up audio"), audio)
144
145 info, err := os.Stat(filepath.Join(restored, voiceDirName, name))
146 require.NoError(t, err)
147 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
148 }
149
150 func TestVoiceConfigReadsExplicitLocalPaths(t *testing.T) {
151 dir := t.TempDir()
152 configWriteTest(t, dir, `{"voice":{"transcriber":"local-whisper","model":"model.bin"}}`)
153
154 config, err := loadConfig(dir)
155 require.NoError(t, err)
156 require.Equal(t, VoiceConfig{
157 Transcriber: "local-whisper",
158 Model: "model.bin",
159 }, config.Voice)
160 }
161
162 func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) {
163 _, transcriber := voiceTestInput(t, "exit 0")
164 valid := transcriber.config
165 t.Setenv("PATH", filepath.Dir(valid.Transcriber))
166
167 testCases := []struct {
168 name string
169 config VoiceConfig
170 program string
171 model string
172 available bool
173 }{
174 {
175 name: "explicit paths override invalid built paths",
176 config: valid,
177 program: "missing",
178 model: "missing",
179 available: true,
180 },
181 {
182 name: "built paths supply absent settings",
183 program: valid.Transcriber,
184 model: valid.Model,
185 available: true,
186 },
187 {
188 name: "PATH supplies absent executable",
189 config: VoiceConfig{Model: valid.Model},
190 available: true,
191 },
192 {
193 name: "invalid explicit executable does not fall back",
194 config: VoiceConfig{
195 Transcriber: "missing",
196 Model: valid.Model,
197 },
198 program: valid.Transcriber,
199 },
200 {
201 name: "invalid explicit model does not fall back",
202 config: VoiceConfig{Model: "missing"},
203 program: valid.Transcriber,
204 model: valid.Model,
205 },
206 {
207 name: "missing model leaves input unavailable",
208 program: valid.Transcriber,
209 },
210 }
211 for _, tc := range testCases {
212 t.Run(tc.name, func(t *testing.T) {
213 program, model, err := voiceLookup(tc.config, tc.program, tc.model)
214 if !tc.available {
215 require.ErrorIs(t, err, errVoiceUnavailable)
216
217 return
218 }
219
220 require.NoError(t, err)
221 require.Equal(t, valid.Transcriber, program)
222 require.Equal(t, valid.Model, model)
223 })
224 }
225 }