--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+ })
+ }
+}