package main import ( "context" "errors" "io" "os" "os/exec" "path/filepath" "strings" "time" "unicode/utf8" ) const ( // voiceTranscriptionTimeout bounds one local transcription process. voiceTranscriptionTimeout = 2 * time.Minute voiceProcessExitTimeout = time.Second voiceTranscriptMaxBytes = 1 << 20 ) // Nix sets these paths with linker flags. Other builds use config or PATH. var ( voiceTranscriberPath string voiceModelPath string errVoiceUnavailable = errors.New("transcriber or model is not available") errVoiceTranscription = errors.New("local transcription failed") ) // VoiceConfig selects a local executable and model. Explicit settings take // precedence over Nix paths and do not silently fall back when invalid. type VoiceConfig struct { Transcriber string `json:"transcriber"` Model string `json:"model"` } // VoiceTranscriber runs a local Whisper process. Process output and errors can // contain speech and private paths, so only fixed failure causes leave it. type VoiceTranscriber struct { config VoiceConfig } // Text stores audio before decoding it. A failed decode keeps its voice marker, // so a missing model or bad audio does not stop the next message. A temporary // transcript stays in the private voice directory and is removed after use. func (v *VoiceTranscriber) Text(ctx context.Context, store *VoiceStore, data []byte) string { name, err := store.Save(data) if err != nil { return "(voice not stored)" } text, err := v.transcribe(ctx, filepath.Join(store.dir, name)) if err != nil { text = "(voice not transcribed: " + err.Error() + ")" } return "[voice " + name + "]\n" + text } func (v *VoiceTranscriber) transcribe(ctx context.Context, path string) (text string, err error) { program, model, err := voiceLookup(v.config, voiceTranscriberPath, voiceModelPath) if err != nil { return "", errVoiceUnavailable } ctx, cancel := context.WithTimeout(ctx, voiceTranscriptionTimeout) defer cancel() dir, err := os.MkdirTemp(filepath.Dir(path), ".transcript-*") if err != nil { return "", errVoiceTranscription } defer func() { if os.RemoveAll(dir) != nil { err = errors.Join(err, errVoiceTranscription) } }() output := filepath.Join(dir, "text") command := exec.CommandContext(ctx, program, "--model", model, "--file", path, "--language", "auto", "--max-context", "0", "--output-txt", "--output-file", output) command.Stdout = io.Discard command.Stderr = io.Discard command.WaitDelay = voiceProcessExitTimeout err = command.Run() if ctx.Err() != nil { return "", ctx.Err() } if err != nil { return "", errVoiceTranscription } file, err := os.Open(output + ".txt") if err != nil { return "", errVoiceTranscription } data, err := io.ReadAll(io.LimitReader(file, voiceTranscriptMaxBytes+1)) err = errors.Join(err, file.Close()) if err != nil || len(data) > voiceTranscriptMaxBytes || !utf8.Valid(data) { return "", errVoiceTranscription } text = strings.TrimSpace(string(data)) if text == "" { return "", errVoiceTranscription } return text, nil } func voiceLookup(config VoiceConfig, builtProgram, builtModel string) (string, string, error) { program := config.Transcriber if program == "" { program = builtProgram } if program == "" { program = "whisper-cli" } program, err := exec.LookPath(program) if err != nil { return "", "", errVoiceUnavailable } model := config.Model if model == "" { model = builtModel } info, err := os.Stat(model) if err != nil || !info.Mode().IsRegular() || info.Size() == 0 { return "", "", errVoiceUnavailable } return program, model, nil }