]> git.giacomolli.uk - onechat/commitdiff
Include the small model in the Nix package
authorAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 03:45:39 +0000 (00:45 -0300)
committerAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 09:51:58 +0000 (06:51 -0300)
The Nix package supplies pinned small-model and Whisper executable paths
for local voice transcription. The user instructions describe the local
model and executable overrides for other builds. Chat media comments
cover voice-note input, and voice test cases keep their fields explicit.

README.rst
nix/package.nix
telegram.go
telegram_api.go
voice_transcriber_test.go
whatsapp_inbox.go

index bbb4475d3cc89d416bc62dbf898ca00b7bc3e8bc..0148495daa28f76c6fc9ec8327c0acc6e535c769 100644 (file)
@@ -198,6 +198,13 @@ Images
    Its memory keeps what the image shows, and it can look at an older
    image again. Ask it to show an image or send one to a WhatsApp chat.
 
+Voice notes
+   The agent hears Telegram and WhatsApp voice notes through local
+   Whisper. Nix includes the small model. Outside Nix, set
+   ``voice.model`` in ``config.json`` and install ``whisper-cli``.
+   ``voice.transcriber`` selects another executable. No audio goes to a
+   transcription service.
+
 Schedules
    Ask for reminders and timed tasks, such as "every weekday at 8, tell
    me what my groups talked about". Each run uses the quota, so runs are
index 41cd24f8e0782767d6b8515b51776f4ed7fc2120..c3fa562a93c7f3975000e66b5848a2328d30fd0b 100644 (file)
@@ -3,13 +3,22 @@
   buildGoModule,
   src,
   version,
+  callPackage,
+  whisper-cpp,
 }:
+let
+  model = (callPackage ./voice-model.nix { }).small;
+in
 buildGoModule {
   pname = "onechat";
   inherit src version;
 
   subPackages = [ "." ];
-  ldflags = [ "-X main.version=${version}" ];
+  ldflags = [
+    "-X main.version=${version}"
+    "-X main.voiceTranscriberPath=${whisper-cpp}/bin/whisper-cli"
+    "-X main.voiceModelPath=${model}"
+  ];
   vendorHash = "sha256-nRDUAGSXAK5/L2xyfDh3NbMOZmrGGEIm4vtgfWsxHYU=";
 
   # The flake check runs the full replay.
index 9494c0a577511e5dff53322b2f9146ad3961079f..4ed08614c84945ba13edc3dd83fa8cabc5b26397 100644 (file)
@@ -140,13 +140,13 @@ func (c TelegramConfig) validate() error {
 }
 
 // TelegramBot connects the private chat of the owner with the agent. It polls
-// the Bot API for new messages and sends owner text and images to the agent. It
-// ignores every other update. Its sender sends the replies, the errors, the
-// notices of the bot, and the user messages of the other clients to the chat,
-// and shows the typing action while a turn runs. Only the replies have a
-// format: their Markdown goes as rich messages, which Telegram renders itself,
-// and as message entities only when Telegram refuses a rich message. The rest
-// is plain text. Tool calls do not go to the chat.
+// the Bot API for new messages and sends owner text, images, and voice notes to
+// the agent. It ignores every other update. Its sender sends the replies, the
+// errors, the notices of the bot, and the user messages of the other clients to
+// the chat, and shows the typing action while a turn runs. Only the replies
+// have a format: their Markdown goes as rich messages, which Telegram renders
+// itself, and as message entities only when Telegram refuses a rich message.
+// The rest is plain text. Tool calls do not go to the chat.
 //
 // The offset file holds the ID of the next update. It moves past an update
 // only after the message of the update is durable in the log, so a restart
index f53d8d521223b1c4074ce90745a61601c4e9baab..6967c99d33b4898962b0b05718a263cf90a875a5 100644 (file)
@@ -38,7 +38,8 @@ const (
        // telegramFileLookupTimeout is the longest wait for media file details.
        telegramFileLookupTimeout = 30 * time.Second
 
-       // telegramFileDownloadTimeout is the longest wait for image bytes.
+       // telegramFileDownloadTimeout is the longest wait for downloaded media
+       // bytes.
        telegramFileDownloadTimeout = 30 * time.Second
 
        // telegramTypingTimeout is the longest wait to show the typing action.
@@ -423,7 +424,7 @@ type TelegramAnswer struct {
        } `json:"parameters"`
 }
 
-// TelegramMessage holds the text, caption, and image sizes of a bot message.
+// TelegramMessage holds text and supported media metadata of a bot message.
 type TelegramMessage struct {
        Chat     TelegramChat      `json:"chat"`
        From     *TelegramUser     `json:"from"`
index 48732f7a76691c468450223d089a87f98f728b3d..c9e24bc5903c51fdd12fbcb670917952da010fe8 100644 (file)
@@ -57,9 +57,18 @@ func TestVoiceFailuresKeepStoredAudioAndSafeMarkers(t *testing.T) {
                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\""},
+               {
+                       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) {
@@ -144,7 +153,10 @@ func TestVoiceConfigReadsExplicitLocalPaths(t *testing.T) {
 
        config, err := loadConfig(dir)
        require.NoError(t, err)
-       require.Equal(t, VoiceConfig{Transcriber: "local-whisper", Model: "model.bin"}, config.Voice)
+       require.Equal(t, VoiceConfig{
+               Transcriber: "local-whisper",
+               Model:       "model.bin",
+       }, config.Voice)
 }
 
 func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) {
@@ -178,8 +190,11 @@ func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) {
                        available: true,
                },
                {
-                       name:    "invalid explicit executable does not fall back",
-                       config:  VoiceConfig{Transcriber: "missing", Model: valid.Model},
+                       name: "invalid explicit executable does not fall back",
+                       config: VoiceConfig{
+                               Transcriber: "missing",
+                               Model:       valid.Model,
+                       },
                        program: valid.Transcriber,
                },
                {
index 7d4e9e4c7b3920a303d35628b290a60676df9e7a..c9ca81b666148d4a43795a1b9a3d38fdb8cde0dd 100644 (file)
@@ -34,7 +34,8 @@ const (
 
 // newWhatsAppInbox returns the inbox of the batches in state, with clock for
 // the times of the batch texts and groups for the names of the groups. images
-// holds shared images, and logger gets image failures.
+// holds shared images, and logger gets image failures. Audio shares the data
+// directory of state.
 func newWhatsAppInbox(state *WhatsAppState, clock func() time.Time, groups *WhatsAppGroups, images *ImageStore, logger *slog.Logger) *WhatsAppInbox {
        return &WhatsAppInbox{
                state:    state,
@@ -229,10 +230,10 @@ func (i *WhatsAppInbox) startCatchup() {
        })
 }
 
-// receive stores an image before its batch and wakes Run. link gives the device
-// and the own IDs. It returns false when the state file cannot hold the
-// message, so that the library does not acknowledge it, and Run stops the
-// daemon. An image that cannot be downloaded or stored gets a text marker. A
+// receive stores images and transcribed voice notes before their batches and
+// wakes Run. link gives the device and the own IDs. It returns false when the
+// state file cannot hold the message, so that the library does not acknowledge
+// it, and Run stops the daemon. Failed media input gets a text marker. A
 // message that the batches do not take is handled with nothing to do.
 func (i *WhatsAppInbox) receive(ctx context.Context, link WhatsAppLink, event *events.Message) bool {
        lookupCtx, cancel := context.WithTimeout(ctx, whatsappContactLookupTimeout)
@@ -374,7 +375,8 @@ func whatsappMessage(ctx context.Context, device *store.Device, own []string, ev
 }
 
 // whatsappText returns the chat and the batch message of event, a message that
-// the batches take. An image returns its caption until receive stores it.
+// the batches take. An image returns its caption until receive stores it. A
+// voice note returns a marker until receive transcribes it.
 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
        info := event.Info
        text := event.Message.GetConversation()