From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 03:22:32 +0000 (-0300) Subject: Transcribe stored voice notes locally X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/5dbb2e42e88e78bfc79b473b5b1dc466d8dbbf62?ds=inline Transcribe stored voice notes locally Private content-addressed audio storage keeps voice notes before a local Whisper process decodes them. Configured executable and model paths override the Nix defaults. Missing tools and failed or cancelled transcriptions produce safe markers instead of stopping message intake. Plain text output keeps internal timestamps and disables carried text context. Temporary transcript files stay out of backups. --- diff --git a/backup_archive.go b/backup_archive.go index 2ec9352..0c3544f 100644 --- a/backup_archive.go +++ b/backup_archive.go @@ -270,7 +270,7 @@ func backupExcluded(name string) bool { return true } - if filepath.Base(name) == "lock" || (first == imageDirName && strings.HasPrefix(filepath.Base(name), ".image-")) { + if filepath.Base(name) == "lock" || (first == imageDirName && strings.HasPrefix(filepath.Base(name), ".image-")) || (first == voiceDirName && (strings.HasPrefix(filepath.Base(name), ".voice-") || strings.HasPrefix(filepath.Base(name), ".transcript-"))) { return true } diff --git a/config.go b/config.go index 3b5172d..1d68b67 100644 --- a/config.go +++ b/config.go @@ -55,6 +55,7 @@ func loadConfig(dir string) (Config, error) { // Config holds the settings of config.json in the data directory. type Config struct { Endpoint string `json:"endpoint"` + Voice VoiceConfig `json:"voice"` Compactor ModelConfig `json:"compactor"` } diff --git a/voice_helper_test.go b/voice_helper_test.go new file mode 100644 index 0000000..326d7a8 --- /dev/null +++ b/voice_helper_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func voiceTestInput(t *testing.T, script string) (*VoiceStore, *VoiceTranscriber) { + t.Helper() + + dir := t.TempDir() + program := filepath.Join(dir, "whisper-cli") + shell, err := exec.LookPath("sh") + require.NoError(t, err) + require.NoError(t, os.WriteFile(program, []byte("#!"+shell+"\nset -eu\n"+script+"\n"), 0o700)) + + model := filepath.Join(dir, "model.bin") + require.NoError(t, os.WriteFile(model, []byte("made-up model"), 0o600)) + + return &VoiceStore{dir: filepath.Join(dir, voiceDirName)}, &VoiceTranscriber{config: VoiceConfig{ + Transcriber: program, + Model: model, + }} +} diff --git a/voice_store.go b/voice_store.go new file mode 100644 index 0000000..16d02ed --- /dev/null +++ b/voice_store.go @@ -0,0 +1,64 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + voiceDirName = "voices" + voiceMaxBytes = 20 << 20 +) + +var errVoiceSize = errors.New("voice note is empty or exceeds 20 MB") + +// VoiceStore holds immutable audio shared by all agents. Names are hashes of +// the received bytes, not sender filenames. The decoder detects the format. +type VoiceStore struct { + dir string +} + +// Save publishes private audio atomically before its message is acknowledged. +// Only nonempty input up to voiceMaxBytes is stored. +func (s *VoiceStore) Save(data []byte) (name string, err error) { + if len(data) == 0 || len(data) > voiceMaxBytes { + return "", errVoiceSize + } + + sum := sha256.Sum256(data) + name = hex.EncodeToString(sum[:]) + ".audio" + err = os.MkdirAll(s.dir, 0o700) + if err != nil { + return "", fmt.Errorf("create voice directory: %w", err) + } + + file, err := os.CreateTemp(s.dir, ".voice-*") + if err != nil { + return "", fmt.Errorf("create voice file: %w", err) + } + + defer func() { + err = errors.Join(err, os.Remove(file.Name())) + }() + + _, err = file.Write(data) + if err == nil { + err = file.Sync() + } + + err = errors.Join(err, file.Close()) + if err != nil { + return "", fmt.Errorf("write voice: %w", err) + } + + err = os.Link(file.Name(), filepath.Join(s.dir, name)) + if err != nil && !errors.Is(err, os.ErrExist) { + return "", fmt.Errorf("publish voice: %w", err) + } + + return name, nil +} diff --git a/voice_transcriber.go b/voice_transcriber.go new file mode 100644 index 0000000..976d66b --- /dev/null +++ b/voice_transcriber.go @@ -0,0 +1,140 @@ +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 +} diff --git a/voice_transcriber_test.go b/voice_transcriber_test.go new file mode 100644 index 0000000..58df9ea --- /dev/null +++ b/voice_transcriber_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestVoiceStoresPrivateAudioAndUsesPlainTextWithInternalTimestamps(t *testing.T) { + store, transcriber := voiceTestInput(t, ` +output= +context= +while [ "$#" -gt 0 ]; do + case "$1" in + --no-timestamps) exit 2 ;; + --max-context) shift; context=$1 ;; + --output-file) shift; output=$1 ;; + esac + shift +done +[ "$context" = 0 ] +printf ' A made-up voice transcript.\n' > "$output.txt" +printf 'private speech in process output' +printf 'private speech in process errors' >&2 +`) + data := []byte("made-up audio") + text := transcriber.Text(t.Context(), store, data) + require.Contains(t, text, "[voice ") + require.True(t, strings.HasSuffix(text, "]\nA made-up voice transcript.")) + require.Equal(t, text, transcriber.Text(t.Context(), store, data)) + + files, err := os.ReadDir(store.dir) + require.NoError(t, err) + require.Len(t, files, 1) + + saved, err := os.ReadFile(filepath.Join(store.dir, files[0].Name())) + require.NoError(t, err) + require.Equal(t, data, saved) + + info, err := os.Stat(filepath.Join(store.dir, files[0].Name())) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + info, err = os.Stat(store.dir) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) +} + +func TestVoiceFailuresKeepStoredAudioAndSafeMarkers(t *testing.T) { + testCases := []struct { + name string + script string + }{ + {name: "failed process does not expose its errors", script: "echo secret-speech >&2; exit 1"}, + {name: "missing transcript gets a marker", script: "exit 0"}, + {name: "empty transcript gets a marker", script: "while [ \"$1\" != --output-file ]; do shift; done; shift; touch \"$1.txt\""}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + store, transcriber := voiceTestInput(t, tc.script) + text := transcriber.Text(t.Context(), store, []byte("made-up audio")) + require.Contains(t, text, "[voice ") + require.Contains(t, text, "(voice not transcribed: local transcription failed)") + require.NotContains(t, text, "secret-speech") + + files, err := os.ReadDir(store.dir) + require.NoError(t, err) + require.Len(t, files, 1) + }) + } +} + +func TestVoiceCancellationAndCallerDeadlineStopTheProcess(t *testing.T) { + store, transcriber := voiceTestInput(t, "exec sleep 60") + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + + text := transcriber.Text(ctx, store, []byte("made-up audio")) + require.Contains(t, text, "context deadline exceeded") + + ctx, cancel = context.WithCancel(t.Context()) + cancel() + + text = transcriber.Text(ctx, store, []byte("made-up audio")) + require.Contains(t, text, "context canceled") +} + +func TestVoiceUnavailableAndStorageFailuresDoNotStopIntake(t *testing.T) { + store, transcriber := voiceTestInput(t, "exit 0") + transcriber.config.Model = filepath.Join(t.TempDir(), "missing") + text := transcriber.Text(t.Context(), store, []byte("made-up audio")) + require.Contains(t, text, "[voice ") + require.Contains(t, text, "transcriber or model is not available") + require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, nil)) + require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, bytes.Repeat([]byte{0}, voiceMaxBytes+1))) + + blocked := filepath.Join(t.TempDir(), "blocked") + require.NoError(t, os.WriteFile(blocked, nil, 0o600)) + + store.dir = filepath.Join(blocked, "child") + require.Equal(t, "(voice not stored)", transcriber.Text(t.Context(), store, []byte("audio"))) +} + +func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) { + _, transcriber := voiceTestInput(t, "exit 0") + valid := transcriber.config + t.Setenv("PATH", filepath.Dir(valid.Transcriber)) + + testCases := []struct { + name string + config VoiceConfig + program string + model string + available bool + }{ + { + name: "explicit paths override invalid built paths", + config: valid, + program: "missing", + model: "missing", + available: true, + }, + { + name: "built paths supply absent settings", + program: valid.Transcriber, + model: valid.Model, + available: true, + }, + { + name: "PATH supplies absent executable", + config: VoiceConfig{Model: valid.Model}, + available: true, + }, + { + name: "invalid explicit executable does not fall back", + config: VoiceConfig{Transcriber: "missing", Model: valid.Model}, + program: valid.Transcriber, + }, + { + name: "invalid explicit model does not fall back", + config: VoiceConfig{Model: "missing"}, + program: valid.Transcriber, + model: valid.Model, + }, + { + name: "missing model leaves input unavailable", + program: valid.Transcriber, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + program, model, err := voiceLookup(tc.config, tc.program, tc.model) + if !tc.available { + require.ErrorIs(t, err, errVoiceUnavailable) + + return + } + + require.NoError(t, err) + require.Equal(t, valid.Transcriber, program) + require.Equal(t, valid.Model, model) + }) + } +}