16 // voiceTranscriptionTimeout bounds one local transcription process.
17 voiceTranscriptionTimeout = 2 * time.Minute
18 voiceProcessExitTimeout = time.Second
19 voiceTranscriptMaxBytes = 1 << 20
22 // Nix sets these paths with linker flags. Other builds use config or PATH.
24 voiceTranscriberPath string
26 errVoiceUnavailable = errors.New("transcriber or model is not available")
27 errVoiceTranscription = errors.New("local transcription failed")
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"`
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 {
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)
49 return "(voice not stored)"
52 text, err := v.transcribe(ctx, filepath.Join(store.dir, name))
54 text = "(voice not transcribed: " + err.Error() + ")"
57 return "[voice " + name + "]\n" + text
60 func (v *VoiceTranscriber) transcribe(ctx context.Context, path string) (text string, err error) {
61 program, model, err := voiceLookup(v.config, voiceTranscriberPath, voiceModelPath)
63 return "", errVoiceUnavailable
66 ctx, cancel := context.WithTimeout(ctx, voiceTranscriptionTimeout)
69 dir, err := os.MkdirTemp(filepath.Dir(path), ".transcript-*")
71 return "", errVoiceTranscription
75 if os.RemoveAll(dir) != nil {
76 err = errors.Join(err, errVoiceTranscription)
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
92 return "", errVoiceTranscription
95 file, err := os.Open(output + ".txt")
97 return "", errVoiceTranscription
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
106 text = strings.TrimSpace(string(data))
108 return "", errVoiceTranscription
114 func voiceLookup(config VoiceConfig, builtProgram, builtModel string) (string, string, error) {
115 program := config.Transcriber
117 program = builtProgram
121 program = "whisper-cli"
124 program, err := exec.LookPath(program)
126 return "", "", errVoiceUnavailable
129 model := config.Model
134 info, err := os.Stat(model)
135 if err != nil || !info.Mode().IsRegular() || info.Size() == 0 {
136 return "", "", errVoiceUnavailable
139 return program, model, nil