]> git.giacomolli.uk - onechat/blob - voice_transcriber.go
Order model and chat declarations
[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 command.Stdout = io.Discard
84 command.Stderr = io.Discard
85 command.WaitDelay = voiceProcessExitTimeout
86 err = command.Run()
87 if ctx.Err() != nil {
88 return "", ctx.Err()
89 }
90
91 if err != nil {
92 return "", errVoiceTranscription
93 }
94
95 file, err := os.Open(output + ".txt")
96 if err != nil {
97 return "", errVoiceTranscription
98 }
99
100 data, err := io.ReadAll(io.LimitReader(file, voiceTranscriptMaxBytes+1))
101 err = errors.Join(err, file.Close())
102 if err != nil || len(data) > voiceTranscriptMaxBytes || !utf8.Valid(data) {
103 return "", errVoiceTranscription
104 }
105
106 text = strings.TrimSpace(string(data))
107 if text == "" {
108 return "", errVoiceTranscription
109 }
110
111 return text, nil
112 }
113
114 func voiceLookup(config VoiceConfig, builtProgram, builtModel string) (string, string, error) {
115 program := config.Transcriber
116 if program == "" {
117 program = builtProgram
118 }
119
120 if program == "" {
121 program = "whisper-cli"
122 }
123
124 program, err := exec.LookPath(program)
125 if err != nil {
126 return "", "", errVoiceUnavailable
127 }
128
129 model := config.Model
130 if model == "" {
131 model = builtModel
132 }
133
134 info, err := os.Stat(model)
135 if err != nil || !info.Mode().IsRegular() || info.Size() == 0 {
136 return "", "", errVoiceUnavailable
137 }
138
139 return program, model, nil
140 }