]> git.giacomolli.uk - onechat/blob - voice_transcriber.go
Require consent and review for autosquash
[onechat] / voice_transcriber.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "time"
12 "unicode/utf8"
13 )
14
15 const (
16 // voiceTranscriptionTimeout bounds one local transcription process.
17 voiceTranscriptionTimeout = 2 * time.Minute
18 voiceProcessExitTimeout = time.Second
19 voiceTranscriptMaxBytes = 1 << 20
20 )
21
22 // Nix sets these paths with linker flags. Other builds use config or PATH.
23 var (
24 voiceTranscriberPath string
25 voiceModelPath string
26 errVoiceUnavailable = errors.New("transcriber or model is not available")
27 errVoiceTranscription = errors.New("local transcription failed")
28 )
29
30 // VoiceConfig selects a local executable and model. Explicit settings take
31 // precedence over Nix paths and do not silently fall back when invalid.
32 type VoiceConfig struct {
33 Transcriber string `json:"transcriber"`
34 Model string `json:"model"`
35 }
36
37 // VoiceTranscriber runs a local Whisper process. Process output and errors can
38 // contain speech and private paths, so only fixed failure causes leave it.
39 type VoiceTranscriber struct {
40 config VoiceConfig
41 }
42
43 // Text stores audio before decoding it. A failed decode keeps its voice marker,
44 // so a missing model or bad audio does not stop the next message. A temporary
45 // transcript stays in the private voice directory and is removed after use.
46 func (v *VoiceTranscriber) Text(ctx context.Context, store *VoiceStore, data []byte) string {
47 name, err := store.Save(data)
48 if err != nil {
49 return "(voice not stored)"
50 }
51
52 text, err := v.transcribe(ctx, filepath.Join(store.dir, name))
53 if err != nil {
54 text = "(voice not transcribed: " + err.Error() + ")"
55 }
56
57 return "[voice " + name + "]\n" + text
58 }
59
60 func (v *VoiceTranscriber) transcribe(ctx context.Context, path string) (text string, err error) {
61 program, model, err := voiceLookup(v.config, voiceTranscriberPath, voiceModelPath)
62 if err != nil {
63 return "", errVoiceUnavailable
64 }
65
66 ctx, cancel := context.WithTimeout(ctx, voiceTranscriptionTimeout)
67 defer cancel()
68
69 dir, err := os.MkdirTemp(filepath.Dir(path), ".transcript-*")
70 if err != nil {
71 return "", errVoiceTranscription
72 }
73
74 defer func() {
75 if os.RemoveAll(dir) != nil {
76 err = errors.Join(err, errVoiceTranscription)
77 }
78 }()
79
80 output := filepath.Join(dir, "text")
81 command := exec.CommandContext(ctx, program, "--model", model, "--file", path,
82 "--language", "auto", "--max-context", "0", "--output-txt", "--output-file", output)
83
84 command.Stdout = io.Discard
85 command.Stderr = io.Discard
86 command.WaitDelay = voiceProcessExitTimeout
87 err = command.Run()
88 if ctx.Err() != nil {
89 return "", ctx.Err()
90 }
91
92 if err != nil {
93 return "", errVoiceTranscription
94 }
95
96 file, err := os.Open(output + ".txt")
97 if err != nil {
98 return "", errVoiceTranscription
99 }
100
101 data, err := io.ReadAll(io.LimitReader(file, voiceTranscriptMaxBytes+1))
102 err = errors.Join(err, file.Close())
103 if err != nil || len(data) > voiceTranscriptMaxBytes || !utf8.Valid(data) {
104 return "", errVoiceTranscription
105 }
106
107 text = strings.TrimSpace(string(data))
108 if text == "" {
109 return "", errVoiceTranscription
110 }
111
112 return text, nil
113 }
114
115 func voiceLookup(config VoiceConfig, builtProgram, builtModel string) (string, string, error) {
116 program := config.Transcriber
117 if program == "" {
118 program = builtProgram
119 }
120
121 if program == "" {
122 program = "whisper-cli"
123 }
124
125 program, err := exec.LookPath(program)
126 if err != nil {
127 return "", "", errVoiceUnavailable
128 }
129
130 model := config.Model
131 if model == "" {
132 model = builtModel
133 }
134
135 info, err := os.Stat(model)
136 if err != nil || !info.Mode().IsRegular() || info.Size() == 0 {
137 return "", "", errVoiceUnavailable
138 }
139
140 return program, model, nil
141 }