]> git.giacomolli.uk - onechat/commitdiff
Transcribe stored voice notes locally
authorAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 03:22:32 +0000 (00:22 -0300)
committerAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 03:22:32 +0000 (00:22 -0300)
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.

backup_archive.go
config.go
voice_helper_test.go [new file with mode: 0644]
voice_store.go [new file with mode: 0644]
voice_transcriber.go [new file with mode: 0644]
voice_transcriber_test.go [new file with mode: 0644]

index 2ec9352a11eb00e8a9b757f81673614a31f702ef..0c3544fdf4b7e4be3b0621e269e90bfbd6aa9817 100644 (file)
@@ -270,7 +270,7 @@ func backupExcluded(name string) bool {
                return true
        }
 
                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
        }
 
                return true
        }
 
index 3b5172d7d002360d22dbb3b49db2fcc8d8366bd1..1d68b67a39c28716ba4651fc8b8bafd34dfc7d14 100644 (file)
--- 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"`
 // 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"`
 }
 
        Compactor ModelConfig `json:"compactor"`
 }
 
diff --git a/voice_helper_test.go b/voice_helper_test.go
new file mode 100644 (file)
index 0000000..326d7a8
--- /dev/null
@@ -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 (file)
index 0000000..16d02ed
--- /dev/null
@@ -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 (file)
index 0000000..976d66b
--- /dev/null
@@ -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 (file)
index 0000000..58df9ea
--- /dev/null
@@ -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)
+               })
+       }
+}