]> 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.
 
    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
 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,
   buildGoModule,
   src,
   version,
+  callPackage,
+  whisper-cpp,
 }:
 }:
+let
+  model = (callPackage ./voice-model.nix { }).small;
+in
 buildGoModule {
   pname = "onechat";
   inherit src version;
 
   subPackages = [ "." ];
 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.
   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
 }
 
 // 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
 //
 // 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
 
        // 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.
        telegramFileDownloadTimeout = 30 * time.Second
 
        // telegramTypingTimeout is the longest wait to show the typing action.
@@ -423,7 +424,7 @@ type TelegramAnswer struct {
        } `json:"parameters"`
 }
 
        } `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"`
 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   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) {
        }
        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)
 
        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) {
 }
 
 func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) {
@@ -178,8 +190,11 @@ func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) {
                        available: true,
                },
                {
                        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,
                },
                {
                        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
 
 // 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,
 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)
 // 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
 }
 
 // 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()
 func whatsappText(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) {
        info := event.Info
        text := event.Message.GetConversation()