Browse Source

feat: add audio transcription support via OpenAI-compatible endpoint

- Introduced new configuration options for audio transcription including `transcribe_key`, `transcribe_url`, `transcribe_model`, and `transcribe_language`.
- Implemented `transcribe` module to handle audio transcription requests, including multipart form data handling and response parsing.
- Added `update_message_text` method in `History` to update stored message text with transcriptions.
- Enhanced `WaEvent` to support audio messages, including downloading and processing audio content.
- Added tests for transcription functionality, ensuring proper handling of audio data and configuration.
softword 15 hours ago
parent
commit
dc69f5fc7c
9 changed files with 1244 additions and 100 deletions
  1. 21 0
      README.md
  2. 7 0
      config.toml.global.template
  3. 4 0
      src/ai.rs
  4. 452 80
      src/app.rs
  5. 115 6
      src/config.rs
  6. 27 0
      src/history.rs
  7. 1 0
      src/main.rs
  8. 520 0
      src/transcribe.rs
  9. 97 14
      src/whatsapp.rs

+ 21 - 0
README.md

@@ -68,6 +68,8 @@ Settings live in `config.toml` in the data directory. Start from `config.toml.gl
 | `ai_retries` | `2` | retries of a failed AI request, 2s, 4s, ... backoff (0 = off; HTTP 4xx except 429 never retried) |
 | `web_port` | unset | port of the web control panel (1-65535); unset disables it |
 
+Voice-note transcription adds four optional `transcribe_*` keys, documented in [Voice notes](#voice-notes).
+
 ### Live reload
 
 `config.toml`, `SOUL.md` and `VISION.md` are checked every 2 seconds and reloaded when changed, without restarting `wh` (status `Configurazione ricaricata`). An invalid file keeps the current configuration (`Configurazione non ricaricata: ...`). Only `web_port` needs a restart.
@@ -104,6 +106,7 @@ Enable it per chat with `F3` or for all chats with `F4` (private chats and group
 - **You answered by hand:** if your own message is the last one when the reply starts, or arrives while the AI is generating, the AI reply is dropped (`Risposta AI scartata: hai già risposto`).
 - **Messages during a reply:** the chat stays busy until its reply is sent; messages that arrive meanwhile and were not part of it start one new wait afterwards.
 - **After a restart:** with `catch_up_seconds` > 0, the last unanswered recent message of each private chat is answered once, at once.
+- **Voice notes:** received voice notes and audio files are transcribed to text before the reply (see [Voice notes](#voice-notes)).
 
 ### Images
 
@@ -113,6 +116,24 @@ Enable it per chat with `F3` or for all chats with `F4` (private chats and group
 - Images received while the app was off are described for the catch-up reply, or on their own if they arrive after it.
 - `VISION.md`, next to `SOUL.md`, replaces the built-in request used to describe images; when missing or blank the built-in request (objective, concise, in Italian) is used. The vision model receives only this request and the image, never the conversation: tone and reply style belong in `SOUL.md`, which can react to what the description contains.
 
+### Voice notes
+
+- Received voice notes and audio files are transcribed and the text replaces the message content as `[messaggio vocale] <trascrizione>`: it shows in the TUI and is part of the AI reply context.
+- Each audio is transcribed **once**; only live audio and the pre-restart backlog are transcribed, never the history sync.
+- Transcription runs only for chats with autoreply enabled, right before the reply.
+- Off by default. Requires `transcribe_key` for any OpenAI-compatible `/audio/transcriptions` endpoint; nothing to install locally. The default is Groq `whisper-large-v3-turbo` (free tier: no credit card, 2000 requests/day; works well for Italian). The audio is sent to that endpoint: with the feature on, voice notes leave your server.
+- Enabled only when `transcribe_key` is set, in `config.toml`:
+
+  ```toml
+  # transcribe_key = "gsk_..."
+  # transcribe_url = "https://api.groq.com/openai/v1/audio/transcriptions"
+  # transcribe_model = "whisper-large-v3-turbo"
+  # transcribe_language = "auto"
+  ```
+
+- Audio over 25 MiB is skipped.
+- Transcription errors show in the status line and the reply continues without the transcription.
+
 ## Keys
 
 | Key | Action |

+ 7 - 0
config.toml.global.template

@@ -27,5 +27,12 @@ ai_retries = 2
 # Valid range 1-65535; leave unset to disable the panel.
 # web_port = 8080
 
+# Voice-note transcription (optional, off unless transcribe_key is set). Sends the audio
+# to any OpenAI-compatible /audio/transcriptions endpoint (default: Groq, free tier).
+# transcribe_key = "gsk_..."
+# transcribe_url = "https://api.groq.com/openai/v1/audio/transcriptions"
+# transcribe_model = "whisper-large-v3-turbo"
+# transcribe_language = "it"
+
 # The system prompt (required for the AI autoreply) is not set here:
 # write it in SOUL.md, next to this file (see soul.md.template).

+ 4 - 0
src/ai.rs

@@ -602,6 +602,10 @@ mod tests {
             openai_vision_model: None,
             ai_retries: crate::config::DEFAULT_AI_RETRIES,
             web_port: None,
+            transcribe_key: None,
+            transcribe_url: crate::config::DEFAULT_TRANSCRIBE_URL.to_owned(),
+            transcribe_model: crate::config::DEFAULT_TRANSCRIBE_MODEL.to_owned(),
+            transcribe_language: crate::config::DEFAULT_TRANSCRIBE_LANGUAGE.to_owned(),
         }
     }
 

+ 452 - 80
src/app.rs

@@ -14,8 +14,9 @@ use crate::autoreply::{
 };
 use crate::config::Config;
 use crate::history::{ChatSummary, Contact, History, StoredMessage};
+use crate::transcribe::TranscriptionJob;
 use crate::web::{self, WebChat, WebCommand, WebRequest, WebState};
-use crate::whatsapp::{ImageRef, WaEvent, WaHandle};
+use crate::whatsapp::{AudioRef, DownloadedAudio, ImageRef, WaEvent, WaHandle, is_voice_note};
 
 /// Maximum number of messages of the open chat kept in `App::messages`.
 pub const MESSAGES_SHOWN: usize = 200;
@@ -34,6 +35,10 @@ const QUIT_FUNCTION_KEY: u8 = 10;
 const LIST_PAGE_ROWS: isize = 10;
 /// Sender jid stored for messages written by the user (own account).
 const OWN_SENDER_JID: &str = "";
+/// Text of a message carrying a voice note only, used as prefix of its transcription.
+/// shortcut: duplicates the private VOICE_PLACEHOLDER in whatsapp.rs; make it pub and reuse when
+/// whatsapp.rs is next touched.
+const VOICE_NOTE_TEXT: &str = "[messaggio vocale]";
 
 /// Status shown once the WhatsApp session is connected.
 const STATUS_CONNECTED: &str = "Connesso";
@@ -62,16 +67,22 @@ const STATUS_AUTOREPLY_UNSUPPORTED: &str = "Autoreply disponibile solo nelle cha
 const STATUS_IMAGE_DOWNLOAD_PREFIX: &str = "Download immagine fallito";
 /// Prefix of the status shown when an image description cannot be obtained.
 const STATUS_IMAGE_DESCRIPTION_PREFIX: &str = "Descrizione immagine fallita";
+/// Prefix of the status shown when the audio to transcribe cannot be downloaded.
+const STATUS_AUDIO_DOWNLOAD_PREFIX: &str = "Download audio fallito";
+/// Prefix of the status shown when an audio transcription cannot be obtained.
+const STATUS_AUDIO_TRANSCRIPTION_PREFIX: &str = "Trascrizione vocale fallita";
 /// Maximum number of new images described before one reply (the most recent ones are kept).
 const MAX_IMAGES_PER_REPLY: usize = 5;
-/// Attempts made to download an image before giving up.
-const IMAGE_DOWNLOAD_ATTEMPTS: u32 = 3;
-/// Base wait, in seconds, between image download attempts (multiplied by the attempt number).
-const IMAGE_DOWNLOAD_RETRY_SECONDS: u64 = 2;
-/// Seconds after which an image download attempt is abandoned.
-const IMAGE_DOWNLOAD_TIMEOUT_SECONDS: u64 = 60;
-/// Error text of an image download attempt abandoned after `IMAGE_DOWNLOAD_TIMEOUT_SECONDS`.
-const IMAGE_DOWNLOAD_TIMEOUT_MESSAGE: &str = "tempo scaduto";
+/// Maximum number of new audios transcribed before one reply (the most recent ones are kept).
+const MAX_AUDIOS_PER_REPLY: usize = 3;
+/// Attempts made to download an image or an audio before giving up.
+const MEDIA_DOWNLOAD_ATTEMPTS: u32 = 3;
+/// Base wait, in seconds, between media download attempts (multiplied by the attempt number).
+const MEDIA_DOWNLOAD_RETRY_SECONDS: u64 = 2;
+/// Seconds after which a media download attempt is abandoned.
+const MEDIA_DOWNLOAD_TIMEOUT_SECONDS: u64 = 60;
+/// Error text of a media download attempt abandoned after `MEDIA_DOWNLOAD_TIMEOUT_SECONDS`.
+const MEDIA_DOWNLOAD_TIMEOUT_MESSAGE: &str = "tempo scaduto";
 /// Prefix of the status shown when the history lookup fails.
 const STATUS_HISTORY_ERROR_PREFIX: &str = "Errore storico";
 /// Prefix of the status shown when a message cannot be sent.
@@ -123,6 +134,15 @@ pub enum AppEvent {
         target: DescriptionTarget,
         results: Vec<(String, Result<String, String>)>,
     },
+    /// Outcome of the transcriptions of the audios of one chat: one result per audio, with the
+    /// id of the message carrying it; `images` are the postponed images to describe afterwards
+    /// and `then_reply` whether the chat must be answered when work finishes.
+    AudiosTranscribed {
+        chat_jid: String,
+        then_reply: bool,
+        results: Vec<(String, Result<String, String>)>,
+        images: Vec<(String, ImageRef)>,
+    },
     /// The reply delay of `chat_jid` ended: its scheduled reply, if still pending, must be sent.
     ReplyDue { chat_jid: String },
     /// Outcome of a send request; `Ok` carries the message id.
@@ -222,6 +242,14 @@ pub struct App {
     backlog_images: HashMap<String, Vec<(String, ImageRef)>>,
     /// Message ids of the images whose description is in progress.
     describing: HashSet<String>,
+    /// Audios received by each chat with a pending reply, keyed by chat JID, in arrival order,
+    /// each with the id of the message carrying it.
+    pending_audios: HashMap<String, Vec<(String, AudioRef)>>,
+    /// Audios delivered with the offline messages before the catch-up, same layout as
+    /// `pending_audios`; emptied by the catch-up.
+    backlog_audios: HashMap<String, Vec<(String, AudioRef)>>,
+    /// Message ids of the audios whose transcription is in progress.
+    transcribing: HashSet<String>,
     /// Id of the last message in the context of the reply in progress, keyed by chat JID.
     answered_up_to: HashMap<String, String>,
     /// Whether the gate has been given a non-empty set of own JIDs.
@@ -274,6 +302,9 @@ impl App {
             pending_images: HashMap::new(),
             backlog_images: HashMap::new(),
             describing: HashSet::new(),
+            pending_audios: HashMap::new(),
+            backlog_audios: HashMap::new(),
+            transcribing: HashSet::new(),
             answered_up_to: HashMap::new(),
             own_jids_known: false,
             catch_up_done: false,
@@ -300,6 +331,9 @@ impl App {
                 self.finish_reply(&chat_jid)
             }
             AppEvent::ImagesDescribed { target, results } => self.handle_images_described(target, results),
+            AppEvent::AudiosTranscribed { chat_jid, then_reply, results, images } => {
+                self.handle_audios_transcribed(&chat_jid, then_reply, results, images)
+            }
             AppEvent::ReplyDue { chat_jid } => self.handle_reply_due(&chat_jid),
             AppEvent::Sent { chat_jid, text, is_ai, result } => {
                 self.handle_sent(OutgoingMessage { chat_jid, text, is_ai }, result)
@@ -423,7 +457,7 @@ impl App {
             }
             WaEvent::OfflineSyncCompleted => self.on_offline_sync_completed()?,
             WaEvent::Contact(contact) => self.on_contact(&contact)?,
-            WaEvent::Message { message, image } => self.on_message(&message, image)?,
+            WaEvent::Message { message, image, audio } => self.on_message(&message, image, audio)?,
         }
         Ok(())
     }
@@ -444,10 +478,16 @@ impl App {
 
     /// Purpose: store an incoming/synced message and, if new, refresh views and maybe autoreply.
     /// Inputs: `message`, the message received from WhatsApp; `image`, the reference to its
-    /// image when it is a live image message.
+    /// image when it is a live image message; `audio`, the reference to its audio when it is a
+    /// live audio message.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: writes the history; reloads chats/messages; may schedule an AI reply.
-    fn on_message(&mut self, message: &StoredMessage, image: Option<ImageRef>) -> Result<()> {
+    fn on_message(
+        &mut self,
+        message: &StoredMessage,
+        image: Option<ImageRef>,
+        audio: Option<AudioRef>,
+    ) -> Result<()> {
         if !self.history.save_message(message)? {
             return Ok(());
         }
@@ -455,17 +495,23 @@ impl App {
         if self.open_chat.as_deref() == Some(message.chat_jid.as_str()) {
             self.reload_messages()?;
         }
-        self.maybe_autoreply(message, image)
+        self.maybe_autoreply(message, image, audio)
     }
 
     /// Purpose: schedule an AI reply to `message`, sent `reply_delay_seconds` later, if the gate
-    /// allows it (autoreply enabled globally or for its chat), and keep its image to describe.
-    /// Inputs: `message`, the newly stored message; `image`, the reference to its image, if any.
+    /// allows it (autoreply enabled globally or for its chat), and keep its media to describe.
+    /// Inputs: `message`, the newly stored message; `image`, the reference to its image, if any;
+    /// `audio`, the reference to its audio, if any.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: may schedule a reply (see `schedule_reply`); a chat already pending (waiting
-    /// or being answered) gets no second timer. Images sent by the contact in a chat with the
-    /// autoreply enabled are handled by `keep_image`.
-    fn maybe_autoreply(&mut self, message: &StoredMessage, image: Option<ImageRef>) -> Result<()> {
+    /// or being answered) gets no second timer. Images and audios sent by the contact in a chat
+    /// with the autoreply enabled are handled by `keep_image` and `keep_audio`.
+    fn maybe_autoreply(
+        &mut self,
+        message: &StoredMessage,
+        image: Option<ImageRef>,
+        audio: Option<AudioRef>,
+    ) -> Result<()> {
         if self.ai_setup().is_none() {
             return Ok(());
         }
@@ -476,10 +522,13 @@ impl App {
         if self.gate.should_schedule(message, enabled) {
             self.schedule_reply(&message.chat_jid);
         }
-        match image.filter(|_| enabled && self.gate.sender_allowed(message)) {
-            Some(image) => self.keep_image(message, image),
-            None => Ok(()),
+        if let Some(image) = image.filter(|_| enabled && self.gate.sender_allowed(message)) {
+            self.keep_image(message, image)?;
+        }
+        if let Some(audio) = audio.filter(|_| enabled && self.gate.sender_allowed(message)) {
+            self.keep_audio(message, audio)?;
         }
+        Ok(())
     }
 
     /// Purpose: start the reply delay of a chat.
@@ -531,33 +580,95 @@ impl App {
         Ok(())
     }
 
+    /// Purpose: keep an audio sent by the contact until it can be transcribed.
+    /// Inputs: `message`, the audio message; `audio`, its reference.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: nothing when transcription is not configured; otherwise, a chat with a pending
+    /// reply keeps it for that reply; an offline audio (sent before the start) is kept for the
+    /// catch-up while it has not run yet, and is transcribed at once, without reply, when it
+    /// arrives after the catch-up.
+    fn keep_audio(&mut self, message: &StoredMessage, audio: AudioRef) -> Result<()> {
+        // Nothing would consume them: audios kept now stay untranscribed if transcription is
+        // enabled later, which is acceptable for the replies already in flight.
+        if !self.transcription_enabled() {
+            return Ok(());
+        }
+        let chat_jid = message.chat_jid.clone();
+        let entry = (message.message_id.clone(), audio);
+
+        // Same as `keep_image`: an audio sent by the contact after the start always schedules a
+        // reply first (see `maybe_autoreply`), so its chat is already pending here.
+        if self.gate.is_pending(&chat_jid) {
+            self.pending_audios.entry(chat_jid).or_default().push(entry);
+        } else if self.gate.is_backlog(message) && !self.catch_up_done {
+            self.backlog_audios.entry(chat_jid).or_default().push(entry);
+        } else if self.gate.is_backlog(message) {
+            self.transcribe_late_audio(&chat_jid, entry)?;
+        }
+        Ok(())
+    }
+
+    /// Purpose: transcribe an offline audio delivered after the catch-up, without answering.
+    /// Inputs: `chat_jid`, its chat; `entry`, the message id and the audio reference.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: when the audio still needs a transcription (see `audios_to_transcribe`),
+    /// spawns its transcription task.
+    fn transcribe_late_audio(&mut self, chat_jid: &str, entry: (String, AudioRef)) -> Result<()> {
+        if !self.transcription_enabled() {
+            return Ok(());
+        }
+        let context = self.load_context(chat_jid)?;
+        let audios = self.audios_to_transcribe(&context, vec![entry]);
+        if !audios.is_empty() {
+            self.spawn_transcriptions(chat_jid, audios, Vec::new(), false);
+        }
+        Ok(())
+    }
+
     /// Purpose: start the scheduled reply of a chat whose reply delay has elapsed.
     /// Inputs: `chat_jid`, the chat whose timer fired.
     /// Output: `Ok(())`, or a history error.
-    /// Side effects: consumes the kept images; if the chat is still pending (not deleted
-    /// meanwhile), continues with `prepare_reply`. The chat stays pending until `finish_reply`.
+    /// Side effects: consumes the kept images and audios; if the chat is still pending (not
+    /// deleted meanwhile), continues with `prepare_reply`. The chat stays pending until
+    /// `finish_reply`.
     fn handle_reply_due(&mut self, chat_jid: &str) -> Result<()> {
         let images = self.pending_images.remove(chat_jid).unwrap_or_default();
+        let audios = self.pending_audios.remove(chat_jid).unwrap_or_default();
         if !self.gate.is_pending(chat_jid) {
             return Ok(());
         }
-        self.prepare_reply(chat_jid, images).map(|_| ())
+        self.prepare_reply(chat_jid, images, audios).map(|_| ())
     }
 
-    /// Purpose: describe the new images of a pending chat, then answer it.
+    /// Purpose: transcribe the new audios of a pending chat and describe its new images, then
+    /// answer it.
     /// Inputs: `chat_jid`, the chat to answer; `images`, the kept images of the messages being
-    /// answered, each with the id of the message carrying it.
-    /// Output: `true` if the reply goes ahead (now or after the descriptions), `false` if it is
-    /// no longer wanted; or a history error.
-    /// Side effects: when a reply is still wanted (see `reply_context`) and some images need a
-    /// description (see `images_to_describe`), spawns their description task, whose
+    /// answered, each with the id of the message carrying it; `audios`, the kept audios, same
+    /// layout.
+    /// Output: `true` if the reply goes ahead (now or after the descriptions/transcriptions),
+    /// `false` if it is no longer wanted; or a history error.
+    /// Side effects: when a reply is still wanted (see `reply_context`) and some audios need a
+    /// transcription (see `audios_to_transcribe`), spawns their transcription task, whose
+    /// `AudiosTranscribed` event describes the images and sends the reply; when only images need
+    /// a description (see `images_to_describe`), spawns their description task, whose
     /// `ImagesDescribed` event sends the reply; otherwise spawns the reply at once. When no reply
     /// is wanted, ends the pending state (see `finish_reply`).
-    fn prepare_reply(&mut self, chat_jid: &str, images: Vec<(String, ImageRef)>) -> Result<bool> {
+    fn prepare_reply(
+        &mut self,
+        chat_jid: &str,
+        images: Vec<(String, ImageRef)>,
+        audios: Vec<(String, AudioRef)>,
+    ) -> Result<bool> {
         let Some((ai, prompt, context)) = self.reply_context(chat_jid)? else {
             self.finish_reply(chat_jid)?;
             return Ok(false);
         };
+        let audios = self.audios_to_transcribe(&context, audios);
+        if !audios.is_empty() {
+            // The images wait for the transcriptions: `handle_audios_transcribed` describes them.
+            self.spawn_transcriptions(chat_jid, audios, images, true);
+            return Ok(true);
+        }
         let images = self.images_to_describe(chat_jid, &context, images, ai.has_vision())?;
         if images.is_empty() {
             self.start_reply(ai, &prompt, chat_jid, &context)?;
@@ -567,6 +678,51 @@ impl App {
         Ok(true)
     }
 
+    /// Purpose: spawn the transcription of audios, tracking them so none is transcribed twice.
+    /// Inputs: `chat_jid`, their chat; `audios`, the audios with the ids of their messages;
+    /// `images`, the images to describe once they are transcribed; `then_reply`, whether the chat
+    /// must be answered when the work finishes.
+    /// Output: none.
+    /// Side effects: adds the message ids to `transcribing`; spawns the transcription tasks (see
+    /// `spawn_audio_transcriptions`). Nothing happens when transcription is not configured.
+    fn spawn_transcriptions(
+        &mut self,
+        chat_jid: &str,
+        audios: Vec<(String, AudioRef)>,
+        images: Vec<(String, ImageRef)>,
+        then_reply: bool,
+    ) {
+        // The MIME type of each audio comes from its download, so the shared job leaves it empty.
+        let Some(job) = self.config.transcribe_key.clone().map(|key| TranscriptionJob {
+            url: self.config.transcribe_url.clone(),
+            key,
+            model: self.config.transcribe_model.clone(),
+            language: self.config.transcribe_language.clone(),
+            mime: String::new(),
+        }) else {
+            return;
+        };
+        self.transcribing.extend(audios.iter().map(|(message_id, _)| message_id.clone()));
+        spawn_audio_transcriptions(
+            job,
+            self.wa.clone(),
+            self.events.clone(),
+            chat_jid.to_owned(),
+            then_reply,
+            audios,
+            images,
+        );
+    }
+
+    /// Purpose: tell whether audio transcription is configured.
+    /// Inputs: none.
+    /// Output: `true` when the transcription API key is set; the URL, model and language always
+    /// have a value (defaults from the configuration).
+    /// Side effects: none.
+    fn transcription_enabled(&self) -> bool {
+        self.config.transcribe_key.is_some()
+    }
+
     /// Purpose: spawn the description of images, tracking them so none is described twice.
     /// Inputs: `ai`, the client; `chat_jid`, their chat; `images`, the images with the ids of
     /// their messages; `then_reply`, whether the chat must be answered once they are described.
@@ -613,12 +769,71 @@ impl App {
         }
     }
 
+    /// Purpose: store the transcriptions of a chat, then describe its postponed images and
+    /// answer it when requested.
+    /// Inputs: `chat_jid`, the chat; `then_reply`, whether it must be answered; `results`, the
+    /// transcription (or error text) of each audio, with the id of the message carrying it;
+    /// `images`, the images to describe once transcribed.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: removes the ids from `transcribing`; appends each transcription to the text
+    /// of its message when it still exists (each audio is transcribed once); failures set one
+    /// status (see `transcription_failure_status`) and leave those audios without transcription.
+    /// Reloads the open chat so the text appears. Then, while the chat is still pending, describes
+    /// the postponed images (whose `ImagesDescribed` event sends the reply) or, with none to
+    /// describe, spawns the reply if it is still wanted, else ends the pending state.
+    fn handle_audios_transcribed(
+        &mut self,
+        chat_jid: &str,
+        then_reply: bool,
+        results: Vec<(String, Result<String, String>)>,
+        images: Vec<(String, ImageRef)>,
+    ) -> Result<()> {
+        let mut failures = Vec::new();
+        for (message_id, result) in results {
+            self.transcribing.remove(&message_id);
+            match result {
+                Ok(text) if self.history.message_exists(&message_id)? => {
+                    self.history.update_message_text(&message_id, &format!("{VOICE_NOTE_TEXT} {text}"))?;
+                }
+                Ok(_) => {}
+                Err(err) => failures.push(err),
+            }
+        }
+        if let Some(status) = transcription_failure_status(&failures) {
+            self.status = status;
+        }
+        if self.open_chat.as_deref() == Some(chat_jid) {
+            self.reload_messages()?;
+        }
+        if !images.is_empty() && self.gate.is_pending(chat_jid) {
+            let Some((ai, prompt, context)) = self.reply_context(chat_jid)? else {
+                return self.finish_reply(chat_jid);
+            };
+            let images = self.images_to_describe(chat_jid, &context, images, ai.has_vision())?;
+            if !images.is_empty() {
+                self.spawn_descriptions(ai, chat_jid, images, then_reply);
+                return Ok(());
+            }
+            if !then_reply {
+                return Ok(());
+            }
+            return self.start_reply(ai, &prompt, chat_jid, &context);
+        }
+        if !then_reply || !self.gate.is_pending(chat_jid) {
+            return Ok(());
+        }
+        match self.reply_context(chat_jid)? {
+            Some((ai, prompt, context)) => self.start_reply(ai, &prompt, chat_jid, &context),
+            None => self.finish_reply(chat_jid),
+        }
+    }
+
     /// Purpose: end the pending state of a chat once its reply is done or dropped.
     /// Inputs: `chat_jid`, the chat.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: clears the pending state and the answered snapshot; if a reply was sent and
     /// a newer message that deserves a reply arrived meanwhile (see `arrived_after`), schedules a
-    /// new reply (keeping the images received meanwhile), otherwise drops those images.
+    /// new reply (keeping the images and audios received meanwhile), otherwise drops them.
     fn finish_reply(&mut self, chat_jid: &str) -> Result<()> {
         let answered = self.answered_up_to.remove(chat_jid);
         // The returned flag only tells whether the chat was pending: nothing to do either way.
@@ -632,6 +847,7 @@ impl App {
             return Ok(());
         }
         self.pending_images.remove(chat_jid);
+        self.pending_audios.remove(chat_jid);
         Ok(())
     }
 
@@ -666,7 +882,7 @@ impl App {
     /// Purpose: pick the images of a chat that still need a description.
     /// Inputs: `chat_jid`, the chat; `context`, its messages; `images`, the kept images in
     /// arrival order; `has_vision`, whether a vision model is configured.
-    /// Output: see `select_images_to_describe` (stored descriptions and images being described
+    /// Output: see `select_media_to_describe` (stored descriptions and images being described
     /// are skipped); or a history error.
     /// Side effects: reads the stored image descriptions.
     fn images_to_describe(
@@ -678,7 +894,27 @@ impl App {
     ) -> Result<Vec<(String, ImageRef)>> {
         let described = self.history.image_descriptions(chat_jid)?;
         let known = |message_id: &str| described.contains_key(message_id) || self.describing.contains(message_id);
-        Ok(select_images_to_describe(images, context, known, has_vision))
+        Ok(select_media_to_describe(images, context, known, has_vision, MAX_IMAGES_PER_REPLY))
+    }
+
+    /// Purpose: pick the audios of a chat that still need a transcription.
+    /// Inputs: `context`, its messages; `audios`, the kept audios in arrival order.
+    /// Output: see `select_media_to_describe` (audios being transcribed and messages whose text
+    /// is no longer the plain voice placeholder are skipped), or an empty list when transcription
+    /// is not configured.
+    /// Side effects: none.
+    fn audios_to_transcribe(
+        &self,
+        context: &[StoredMessage],
+        audios: Vec<(String, AudioRef)>,
+    ) -> Vec<(String, AudioRef)> {
+        let known = |message_id: &str| {
+            self.transcribing.contains(message_id)
+                || context
+                    .iter()
+                    .any(|message| message.message_id == message_id && !is_voice_note(&message.text))
+        };
+        select_media_to_describe(audios, context, known, self.transcription_enabled(), MAX_AUDIOS_PER_REPLY)
     }
 
     /// Purpose: run the catch-up once per process when the offline messages have been delivered.
@@ -697,12 +933,13 @@ impl App {
     /// reached the history while the application was off.
     /// Inputs: none (the age limit is `catch_up_seconds` from the configuration).
     /// Output: `Ok(())`, or a history error.
-    /// Side effects: for every chat found by `catch_up_chats`, marks it pending, describes its
-    /// offline images and answers at once (no reply delay, see `prepare_reply`); drops the other
-    /// offline images; sets the status to the number of replies that went ahead, when above
-    /// zero. Does nothing when the AI is not configured.
+    /// Side effects: for every chat found by `catch_up_chats`, marks it pending, transcribes its
+    /// offline audios, describes its offline images and answers at once (no reply delay, see
+    /// `prepare_reply`); drops the other offline media; sets the status to the number of replies
+    /// that went ahead, when above zero. Does nothing when the AI is not configured.
     fn catch_up(&mut self) -> Result<()> {
-        let mut backlog = std::mem::take(&mut self.backlog_images);
+        let mut backlog_images = std::mem::take(&mut self.backlog_images);
+        let mut backlog_audios = std::mem::take(&mut self.backlog_audios);
         if self.ai_setup().is_none() {
             return Ok(());
         }
@@ -711,9 +948,10 @@ impl App {
         }
         let mut started = 0;
         for chat_jid in self.catch_up_chats(now_unix())? {
-            let images = backlog.remove(&chat_jid).unwrap_or_default();
+            let images = backlog_images.remove(&chat_jid).unwrap_or_default();
+            let audios = backlog_audios.remove(&chat_jid).unwrap_or_default();
             self.gate.mark_pending(&chat_jid);
-            if self.prepare_reply(&chat_jid, images)? {
+            if self.prepare_reply(&chat_jid, images, audios)? {
                 started += 1;
             }
         }
@@ -1011,8 +1249,9 @@ impl App {
     /// Purpose: delete a chat from the local history only (no WhatsApp call).
     /// Inputs: `jid`, the chat to delete; `name`, its display name for the status.
     /// Output: `Ok(())`, or a history error.
-    /// Side effects: deletes the chat from the history, drops its scheduled AI reply, closes it if
-    /// open, reloads the chat list and updates `status`; always clears `pending_delete`.
+    /// Side effects: deletes the chat from the history, drops its scheduled AI reply and the
+    /// images and audios kept for it, closes it if open, reloads the chat list and updates
+    /// `status`; always clears `pending_delete`.
     fn confirm_delete(&mut self, jid: String, name: String) -> Result<()> {
         self.pending_delete = None;
         self.history.delete_chat(&jid)?;
@@ -1021,6 +1260,8 @@ impl App {
         let _ = self.gate.take_pending(&jid);
         self.pending_images.remove(&jid);
         self.backlog_images.remove(&jid);
+        self.pending_audios.remove(&jid);
+        self.backlog_audios.remove(&jid);
         self.answered_up_to.remove(&jid);
         if self.open_chat.as_deref() == Some(jid.as_str()) {
             self.close_open_chat();
@@ -1403,31 +1644,33 @@ fn spawn_image_descriptions(
     });
 }
 
-/// Purpose: choose the images to describe before a reply.
-/// Inputs: `images`, the kept images in arrival order with the ids of their messages;
-/// `context`, the messages that will be sent to the AI; `known`, tells whether a message id
-/// already has a description or one in progress; `has_vision`, whether a vision model is set.
-/// Output: the images whose message is in `context` and not `known`, at most the last
-/// `MAX_IMAGES_PER_REPLY`, in arrival order; empty without a vision model.
+/// Purpose: choose the media to describe before a reply.
+/// Inputs: `items`, the kept media in arrival order with the ids of their messages; `context`,
+/// the messages that will be sent to the AI; `known`, tells whether a message id is already
+/// described or being described; `enabled`, whether the description is possible (a vision model
+/// for images, the transcriber for audios); `limit`, the maximum number of items to keep.
+/// Output: the items whose message is in `context` and not `known`, at most the last `limit`, in
+/// arrival order; empty when not `enabled`.
 /// Side effects: none.
-fn select_images_to_describe<T>(
-    images: Vec<(String, T)>,
+fn select_media_to_describe<T>(
+    items: Vec<(String, T)>,
     context: &[StoredMessage],
     known: impl Fn(&str) -> bool,
-    has_vision: bool,
+    enabled: bool,
+    limit: usize,
 ) -> Vec<(String, T)> {
-    if !vision_applies(!images.is_empty(), has_vision) {
+    if !descriptions_apply(!items.is_empty(), enabled) {
         return Vec::new();
     }
-    let mut selected: Vec<(String, T)> = images
+    let mut selected: Vec<(String, T)> = items
         .into_iter()
         .filter(|(message_id, _)| !known(message_id))
         .filter(|(message_id, _)| context.iter().any(|message| message.message_id == *message_id))
         .collect();
 
-    // Intentional: older images beyond the limit are dropped for good, never described later,
-    // to cap the cost of albums and bursts of photos.
-    let excess = selected.len().saturating_sub(MAX_IMAGES_PER_REPLY);
+    // Intentional: older items beyond the limit are dropped for good, never described later, to
+    // cap the cost of albums, bursts of photos and bursts of voice notes.
+    let excess = selected.len().saturating_sub(limit);
     selected.drain(..excess);
     selected
 }
@@ -1464,6 +1707,16 @@ fn description_failure_status(failures: &[String]) -> Option<String> {
     Some(format!("{STATUS_IMAGE_DESCRIPTION_PREFIX} ({}): {first}", failures.len()))
 }
 
+/// Purpose: build the status for failed audio transcriptions.
+/// Inputs: `failures`, the error texts, in order.
+/// Output: `None` when empty, otherwise `STATUS_AUDIO_TRANSCRIPTION_PREFIX`, the number of
+/// failures in parentheses and the first error.
+/// Side effects: none.
+fn transcription_failure_status(failures: &[String]) -> Option<String> {
+    let first = failures.first()?;
+    Some(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX} ({}): {first}", failures.len()))
+}
+
 /// Purpose: download one image and ask the vision model for its description.
 /// Inputs: `ai`, the client; `wa`, the WhatsApp handle; `image`, the reference of the image.
 /// Output: the description, or the error text of the download or of the description request.
@@ -1478,37 +1731,129 @@ async fn describe_one_image(ai: &AiClient, wa: &WaHandle, image: &ImageRef) -> R
 /// Inputs: `wa`, the WhatsApp handle; `image`, the reference of the image.
 /// Output: the image bytes and MIME type, or the error text of the last attempt carrying
 /// `STATUS_IMAGE_DOWNLOAD_PREFIX`.
-/// Side effects: up to `IMAGE_DOWNLOAD_ATTEMPTS` CDN downloads, each abandoned after
-/// `IMAGE_DOWNLOAD_TIMEOUT_SECONDS`, waiting `IMAGE_DOWNLOAD_RETRY_SECONDS` times the attempt
+/// Side effects: up to `MEDIA_DOWNLOAD_ATTEMPTS` CDN downloads, each abandoned after
+/// `MEDIA_DOWNLOAD_TIMEOUT_SECONDS`, waiting `MEDIA_DOWNLOAD_RETRY_SECONDS` times the attempt
 /// number between them; the bytes stay in memory.
 async fn download_image_with_retry(wa: &WaHandle, image: &ImageRef) -> Result<ImageInput, String> {
     let mut attempt = 1;
     loop {
-        let timeout = Duration::from_secs(IMAGE_DOWNLOAD_TIMEOUT_SECONDS);
+        let timeout = Duration::from_secs(MEDIA_DOWNLOAD_TIMEOUT_SECONDS);
         let outcome = match tokio::time::timeout(timeout, wa.download_image(image)).await {
             Ok(result) => result.map_err(|err| format!("{err:#}")),
-            Err(_) => Err(IMAGE_DOWNLOAD_TIMEOUT_MESSAGE.to_owned()),
+            Err(_) => Err(MEDIA_DOWNLOAD_TIMEOUT_MESSAGE.to_owned()),
         };
         match outcome {
             Ok(downloaded) => return Ok(ImageInput { mime: downloaded.mime, bytes: downloaded.bytes }),
-            Err(err) if attempt >= IMAGE_DOWNLOAD_ATTEMPTS => {
+            Err(err) if attempt >= MEDIA_DOWNLOAD_ATTEMPTS => {
                 return Err(format!("{STATUS_IMAGE_DOWNLOAD_PREFIX}: {err}"));
             }
             Err(_) => {
-                tokio::time::sleep(Duration::from_secs(IMAGE_DOWNLOAD_RETRY_SECONDS * u64::from(attempt))).await;
+                tokio::time::sleep(Duration::from_secs(MEDIA_DOWNLOAD_RETRY_SECONDS * u64::from(attempt))).await;
+                attempt += 1;
+            }
+        }
+    }
+}
+
+/// Purpose: download one audio, retrying failed attempts.
+/// Inputs: `wa`, the WhatsApp handle; `audio`, the reference of the audio.
+/// Output: the audio bytes and MIME type, or the error text of the last attempt carrying
+/// `STATUS_AUDIO_DOWNLOAD_PREFIX`.
+/// Side effects: up to `MEDIA_DOWNLOAD_ATTEMPTS` CDN downloads, each abandoned after
+/// `MEDIA_DOWNLOAD_TIMEOUT_SECONDS`, waiting `MEDIA_DOWNLOAD_RETRY_SECONDS` times the attempt
+/// number between them; the bytes stay in memory.
+async fn download_audio_with_retry(wa: &WaHandle, audio: &AudioRef) -> Result<DownloadedAudio, String> {
+    let mut attempt = 1;
+    loop {
+        let timeout = Duration::from_secs(MEDIA_DOWNLOAD_TIMEOUT_SECONDS);
+        let outcome = match tokio::time::timeout(timeout, wa.download_audio(audio)).await {
+            Ok(result) => result.map_err(|err| format!("{err:#}")),
+            Err(_) => Err(MEDIA_DOWNLOAD_TIMEOUT_MESSAGE.to_owned()),
+        };
+        match outcome {
+            Ok(downloaded) => return Ok(downloaded),
+            Err(err) if attempt >= MEDIA_DOWNLOAD_ATTEMPTS => {
+                return Err(format!("{STATUS_AUDIO_DOWNLOAD_PREFIX}: {err}"));
+            }
+            Err(_) => {
+                tokio::time::sleep(Duration::from_secs(MEDIA_DOWNLOAD_RETRY_SECONDS * u64::from(attempt))).await;
                 attempt += 1;
             }
         }
     }
 }
 
-/// Purpose: decide whether the images of a reply are described.
-/// Inputs: `has_image`, whether there are images to describe; `has_vision`, whether the AI
-/// client has a vision model configured.
-/// Output: `true` only when both hold; otherwise the images stay without description.
+/// Purpose: transcribe audios in the background, once each.
+/// Inputs: `job`, the transcription parameters, whose MIME type is replaced by each download's;
+/// `wa`, the WhatsApp handle; `events`, the app event sender; `chat_jid`, their chat;
+/// `then_reply`, whether to answer it afterwards; `audios`, the audios with the ids of their
+/// messages; `images`, the images to describe once they are transcribed.
+/// Output: none.
+/// Side effects: spawns one tokio task per audio, all running in parallel, each downloading (see
+/// `download_audio_with_retry`) and transcribing its audio, plus one task
+/// that waits for all of them and sends a single `AudiosTranscribed` with one result per audio; a
+/// failed audio does not stop the others. The bytes are never logged or stored.
+fn spawn_audio_transcriptions(
+    job: TranscriptionJob,
+    wa: WaHandle,
+    events: UnboundedSender<AppEvent>,
+    chat_jid: String,
+    then_reply: bool,
+    audios: Vec<(String, AudioRef)>,
+    images: Vec<(String, ImageRef)>,
+) {
+    let tasks: Vec<_> = audios
+        .into_iter()
+        .map(|(message_id, audio)| {
+            let wa = wa.clone();
+            let job = TranscriptionJob {
+                url: job.url.clone(),
+                key: job.key.clone(),
+                model: job.model.clone(),
+                language: job.language.clone(),
+                mime: job.mime.clone(),
+            };
+            let task = tokio::spawn(async move { transcribe_one_audio(job, &wa, &audio).await });
+            (message_id, task)
+        })
+        .collect();
+    tokio::spawn(async move {
+        let mut results = Vec::with_capacity(tasks.len());
+        for (message_id, task) in tasks {
+            let result = task
+                .await
+                .unwrap_or_else(|err| Err(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX}: {err}")));
+            results.push((message_id, result));
+        }
+        send_event(&events, AppEvent::AudiosTranscribed { chat_jid, then_reply, results, images });
+    });
+}
+
+/// Purpose: download one audio and transcribe it.
+/// Inputs: `job`, the transcription parameters, whose MIME type is replaced by the downloaded
+/// one; `wa`, the WhatsApp handle; `audio`, the reference of the audio.
+/// Output: the transcription, or the error text of the download or of the transcription service.
+/// Side effects: CDN download (see `download_audio_with_retry`) and one request to the
+/// transcription service; the bytes stay in memory only and are never logged.
+async fn transcribe_one_audio(
+    job: TranscriptionJob,
+    wa: &WaHandle,
+    audio: &AudioRef,
+) -> Result<String, String> {
+    let downloaded = download_audio_with_retry(wa, audio).await?;
+    let job = TranscriptionJob { mime: downloaded.mime, ..job };
+    crate::transcribe::transcribe(&job, &downloaded.bytes)
+        .await
+        .map_err(|err| format!("{err:#}"))
+}
+
+/// Purpose: decide whether the media of a reply are described.
+/// Inputs: `has_media`, whether there are items to describe; `enabled`, whether the description
+/// is possible (a vision model for images, the transcriber for audios).
+/// Output: `true` only when both hold; otherwise the media stay without description.
 /// Side effects: none.
-fn vision_applies(has_image: bool, has_vision: bool) -> bool {
-    has_image && has_vision
+fn descriptions_apply(has_media: bool, enabled: bool) -> bool {
+    has_media && enabled
 }
 
 /// Purpose: send a text message in the background.
@@ -1654,11 +1999,11 @@ mod tests {
     }
 
     #[test]
-    fn vision_applies_requires_image_and_vision_model() {
-        assert!(vision_applies(true, true));
-        assert!(!vision_applies(true, false));
-        assert!(!vision_applies(false, true));
-        assert!(!vision_applies(false, false));
+    fn descriptions_apply_requires_media_and_the_needed_model() {
+        assert!(descriptions_apply(true, true));
+        assert!(!descriptions_apply(true, false));
+        assert!(!descriptions_apply(false, true));
+        assert!(!descriptions_apply(false, false));
     }
 
     /// Builds a stored message with the given id, for the tests.
@@ -1681,26 +2026,39 @@ mod tests {
     }
 
     #[test]
-    fn select_images_keeps_only_the_last_five_in_arrival_order() {
+    fn select_media_keeps_only_the_last_five_images_in_arrival_order() {
         let names = ["i1", "i2", "i3", "i4", "i5", "i6", "i7"];
         let context: Vec<StoredMessage> = names.iter().map(|id| stored(id)).collect();
         let images = names.iter().map(|id| ((*id).to_owned(), ())).collect();
-        let selected = select_images_to_describe(images, &context, |_| false, true);
+        let selected = select_media_to_describe(images, &context, |_| false, true, MAX_IMAGES_PER_REPLY);
         assert_eq!(ids(&selected), ["i3", "i4", "i5", "i6", "i7"]);
     }
 
     #[test]
-    fn select_images_skips_known_and_out_of_context_images() {
+    fn select_media_keeps_only_the_last_three_audios_in_arrival_order() {
+        let names = ["a1", "a2", "a3", "a4", "a5"];
+        let context: Vec<StoredMessage> = names.iter().map(|id| stored(id)).collect();
+        let audios = names.iter().map(|id| ((*id).to_owned(), ())).collect();
+        let selected = select_media_to_describe(audios, &context, |_| false, true, MAX_AUDIOS_PER_REPLY);
+        assert_eq!(ids(&selected), ["a3", "a4", "a5"]);
+    }
+
+    #[test]
+    fn select_media_skips_known_and_out_of_context_items() {
         let context = [stored("described"), stored("new")];
         let images = vec![("described".to_owned(), ()), ("new".to_owned(), ()), ("old".to_owned(), ())];
-        let selected = select_images_to_describe(images, &context, |id| id == "described", true);
+        let selected =
+            select_media_to_describe(images, &context, |id| id == "described", true, MAX_IMAGES_PER_REPLY);
         assert_eq!(ids(&selected), ["new"]);
     }
 
     #[test]
-    fn select_images_is_empty_without_vision_model() {
+    fn select_media_is_empty_when_not_enabled() {
         let context = [stored("a")];
-        assert!(select_images_to_describe(vec![("a".to_owned(), ())], &context, |_| false, false).is_empty());
+        assert!(
+            select_media_to_describe(vec![("a".to_owned(), ())], &context, |_| false, false, MAX_IMAGES_PER_REPLY)
+                .is_empty()
+        );
     }
 
     #[test]
@@ -1736,6 +2094,20 @@ mod tests {
         assert_eq!(two, Some(format!("{STATUS_IMAGE_DESCRIPTION_PREFIX} (2): timeout")));
     }
 
+    #[test]
+    fn transcription_failure_status_counts_failures_and_shows_the_first() {
+        assert_eq!(transcription_failure_status(&[]), None);
+        let one = transcription_failure_status(&["timeout".to_owned()]);
+        assert_eq!(one, Some(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX} (1): timeout")));
+        let two = transcription_failure_status(&["timeout".to_owned(), "uscita 1".to_owned()]);
+        assert_eq!(two, Some(format!("{STATUS_AUDIO_TRANSCRIPTION_PREFIX} (2): timeout")));
+    }
+
+    #[test]
+    fn voice_note_text_is_a_plain_voice_placeholder() {
+        assert!(is_voice_note(VOICE_NOTE_TEXT));
+    }
+
     #[test]
     fn matches_filter_is_case_insensitive_and_empty_matches_all() {
         assert!(matches_filter("Mario Rossi", "mar"));

+ 115 - 6
src/config.rs

@@ -14,7 +14,11 @@
 //! `ai_retries` (non-negative integer, default 2: how many times a failed AI request is
 //! retried, waiting 2s, 4s, ... between attempts; 0 disables retrying; HTTP 4xx errors other
 //! than 429 are never retried), `web_port` (optional integer 1-65535: when set, a web control
-//! panel listens on `0.0.0.0:<port>` without authentication; unset disables it).
+//! panel listens on `0.0.0.0:<port>` without authentication; unset disables it),
+//! `transcribe_key` (API key of a cloud, OpenAI-compatible transcription endpoint: audio
+//! transcription is enabled only when it is set), `transcribe_url` and `transcribe_model`
+//! (endpoint URL and model name, which have working defaults for Groq),
+//! `transcribe_language` (default `auto`).
 //! Unknown keys are ignored. A missing file means
 //! all defaults; an unreadable or invalid file is an error. The data directory is fixed and
 //! cannot be changed from the file.
@@ -41,6 +45,12 @@ pub const DEFAULT_REPLY_DELAY_SECONDS: u64 = 30;
 pub const DEFAULT_CATCH_UP_SECONDS: i64 = 7200;
 /// Default number of retries of a failed AI request (0 disables retrying).
 pub const DEFAULT_AI_RETRIES: u32 = 2;
+/// Default language of audio transcription, passed to the transcriber ("auto" detects it).
+pub const DEFAULT_TRANSCRIBE_LANGUAGE: &str = "auto";
+/// Default URL of the OpenAI-compatible transcription endpoint (Groq).
+pub const DEFAULT_TRANSCRIBE_URL: &str = "https://api.groq.com/openai/v1/audio/transcriptions";
+/// Default transcription model, available on the default Groq endpoint.
+pub const DEFAULT_TRANSCRIBE_MODEL: &str = "whisper-large-v3-turbo";
 
 /// Name of the application sub-directory inside the platform config directory.
 const APP_DIR_NAME: &str = "wh";
@@ -100,6 +110,18 @@ pub struct Config {
     /// Port of the web control panel from `web_port` (file); `None` = web panel disabled.
     /// It listens on all interfaces, with no authentication.
     pub web_port: Option<u16>,
+    /// API key of the cloud transcription endpoint from `transcribe_key` (file), trimmed;
+    /// `None` if unset or blank. Audio transcription is enabled only when this is `Some`.
+    pub transcribe_key: Option<String>,
+    /// URL of the OpenAI-compatible transcription endpoint from `transcribe_url` (file),
+    /// without trailing '/'; default `DEFAULT_TRANSCRIBE_URL`, which works with Groq.
+    pub transcribe_url: String,
+    /// Model name of the transcription endpoint from `transcribe_model` (file), trimmed;
+    /// default `DEFAULT_TRANSCRIBE_MODEL`, which works with Groq.
+    pub transcribe_model: String,
+    /// Language passed to the transcriber, from `transcribe_language` (file); default `auto`,
+    /// which detects it.
+    pub transcribe_language: String,
     /// Directory holding `config.toml` and the databases: `<platform config dir>/wh`.
     pub data_dir: PathBuf,
 }
@@ -127,6 +149,26 @@ struct FileConfig {
     /// Port of the optional web control panel (unsigned 16-bit: negatives and values above
     /// 65535 are rejected by TOML parsing).
     web_port: Option<u16>,
+    /// API key of the transcription endpoint (unset or blank disables transcription).
+    transcribe_key: Option<String>,
+    /// URL of the transcription endpoint (unset or blank means the default).
+    transcribe_url: Option<String>,
+    /// Transcription model (unset or blank means the default).
+    transcribe_model: Option<String>,
+    /// Language of the transcription (unset or blank means `auto`).
+    transcribe_language: Option<String>,
+}
+
+/// Purpose: choose the text shown for an optional API key in `Debug` output.
+/// Inputs: `key`, the optional key.
+/// Output: the mask for a set key, else the "unset" text.
+/// Side effects: none.
+fn debug_key(key: Option<&String>) -> &'static str {
+    if key.is_some() {
+        DEBUG_KEY_MASK
+    } else {
+        DEBUG_KEY_UNSET
+    }
 }
 
 impl fmt::Debug for Config {
@@ -135,11 +177,7 @@ impl fmt::Debug for Config {
     /// Output: `fmt::Result` of the formatting operation.
     /// Side effects: none (writes only to `f`).
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        let key = if self.openai_api_key.is_some() {
-            DEBUG_KEY_MASK
-        } else {
-            DEBUG_KEY_UNSET
-        };
+        let key = debug_key(self.openai_api_key.as_ref());
         f.debug_struct("Config")
             .field("openai_api_key", &key)
             .field("openai_base_url", &self.openai_base_url)
@@ -152,6 +190,10 @@ impl fmt::Debug for Config {
             .field("catch_up_seconds", &self.catch_up_seconds)
             .field("ai_retries", &self.ai_retries)
             .field("web_port", &self.web_port)
+            .field("transcribe_key", &debug_key(self.transcribe_key.as_ref()))
+            .field("transcribe_url", &self.transcribe_url)
+            .field("transcribe_model", &self.transcribe_model)
+            .field("transcribe_language", &self.transcribe_language)
             .field("data_dir", &self.data_dir)
             .finish()
     }
@@ -251,6 +293,18 @@ fn resolve_base_url(raw: Option<String>) -> Result<String> {
     Ok(url)
 }
 
+/// Purpose: normalise the transcription endpoint URL.
+/// Inputs: `raw`, the optional `transcribe_url` file value.
+/// Output: the trimmed URL without trailing '/' (default if absent, blank or only slashes).
+/// Side effects: none.
+fn resolve_transcribe_url(raw: Option<String>) -> String {
+    let url = raw.as_deref().unwrap_or_default().trim().trim_end_matches('/');
+    if url.is_empty() {
+        return DEFAULT_TRANSCRIBE_URL.to_owned();
+    }
+    url.to_owned()
+}
+
 /// Purpose: validate the optional web control panel port.
 /// Inputs: `raw`, the optional `web_port` file value (already range-checked to 0-65535 by parsing).
 /// Output: `raw` unchanged; error naming the key and the file if it is 0.
@@ -301,6 +355,12 @@ impl Config {
             catch_up_seconds: file.catch_up_seconds.map_or(DEFAULT_CATCH_UP_SECONDS, i64::from),
             ai_retries: file.ai_retries.unwrap_or(DEFAULT_AI_RETRIES),
             web_port: resolve_web_port(file.web_port)?,
+            transcribe_key: trimmed_non_blank(file.transcribe_key),
+            transcribe_url: resolve_transcribe_url(file.transcribe_url),
+            transcribe_model: trimmed_non_blank(file.transcribe_model)
+                .unwrap_or_else(|| DEFAULT_TRANSCRIBE_MODEL.to_owned()),
+            transcribe_language: trimmed_non_blank(file.transcribe_language)
+                .unwrap_or_else(|| DEFAULT_TRANSCRIBE_LANGUAGE.to_owned()),
             data_dir,
         })
     }
@@ -508,6 +568,55 @@ mod tests {
         assert!(format!("{unset:?}").contains("openai_vision_model"));
     }
 
+    #[test]
+    fn transcribe_unset_disables_transcription_with_defaults() {
+        let cfg = ok_config("");
+        assert_eq!(cfg.transcribe_key, None);
+        assert_eq!(cfg.transcribe_url, DEFAULT_TRANSCRIBE_URL);
+        assert_eq!(cfg.transcribe_model, DEFAULT_TRANSCRIBE_MODEL);
+        assert_eq!(cfg.transcribe_language, DEFAULT_TRANSCRIBE_LANGUAGE);
+        assert!(format!("{cfg:?}").contains("transcribe_key"));
+        assert!(format!("{cfg:?}").contains("transcribe_url"));
+        assert!(format!("{cfg:?}").contains("transcribe_model"));
+        assert!(format!("{cfg:?}").contains("transcribe_language"));
+    }
+
+    #[test]
+    fn transcribe_keys_are_parsed_and_trimmed() {
+        const SECRET: &str = "gsk-secret-transcribe";
+        let cfg = ok_config(&format!(
+            "transcribe_key = \"  {SECRET} \\t\"\n\
+             transcribe_url = \" https://example.test/v1/audio/transcriptions// \"\n\
+             transcribe_model = \" whisper-large-v3 \"\n\
+             transcribe_language = \" it \"\n",
+        ));
+        assert_eq!(cfg.transcribe_key.as_deref(), Some(SECRET));
+        assert_eq!(cfg.transcribe_url, "https://example.test/v1/audio/transcriptions");
+        assert_eq!(cfg.transcribe_model, "whisper-large-v3");
+        assert_eq!(cfg.transcribe_language, "it");
+        assert!(!format!("{cfg:?}").contains(SECRET));
+    }
+
+    #[test]
+    fn blank_transcribe_values_fall_back() {
+        let cfg = ok_config(
+            "transcribe_key = \"   \"\ntranscribe_url = \"\"\ntranscribe_model = \" \\t \"\n\
+             transcribe_language = \" \\t \"\n",
+        );
+        assert_eq!(cfg.transcribe_key, None);
+        assert_eq!(cfg.transcribe_url, DEFAULT_TRANSCRIBE_URL);
+        assert_eq!(cfg.transcribe_model, DEFAULT_TRANSCRIBE_MODEL);
+        assert_eq!(cfg.transcribe_language, DEFAULT_TRANSCRIBE_LANGUAGE);
+    }
+
+    #[test]
+    fn removed_transcribe_command_key_is_ignored() {
+        let cfg = ok_config("transcribe_command = \"C:\\\\w\\\\cli.exe\"\n");
+        assert_eq!(cfg.transcribe_key, None);
+        assert_eq!(cfg.transcribe_url, DEFAULT_TRANSCRIBE_URL);
+        assert_eq!(cfg.transcribe_model, DEFAULT_TRANSCRIBE_MODEL);
+    }
+
     #[test]
     fn web_port_unset_is_none_and_set_is_kept() {
         assert_eq!(ok_config("").web_port, None);

+ 27 - 0
src/history.rs

@@ -331,6 +331,20 @@ impl History {
         Ok(found.is_some())
     }
 
+    /// Purpose: replace the text of a stored message, e.g. with its audio transcription.
+    /// Inputs: `message_id` and the `new_text` to store.
+    /// Output: `true` if a stored message was updated, `false` if the id does not exist.
+    /// Side effects: updates the text column of the message row.
+    pub fn update_message_text(&self, message_id: &str, new_text: &str) -> Result<bool> {
+        let sql = format!("UPDATE {TABLE_MESSAGES} SET text = ?1 WHERE message_id = ?2");
+        let updated = self
+            .connection
+            .execute(&sql, params![new_text, message_id])
+            .with_context(|| format!("update text of message {message_id}"))?;
+
+        Ok(updated > 0)
+    }
+
     /// Purpose: store the description of an image message, replacing any previous one.
     /// Inputs: `message_id` of the image message, its `chat_jid` and the `description`.
     /// Output: `()`.
@@ -764,6 +778,19 @@ mod tests {
         assert!(!history.message_exists("a1").unwrap());
     }
 
+    #[test]
+    fn update_message_text_changes_stored_text_and_reports_missing_ids() {
+        let history = History::open_in_memory().unwrap();
+        history.save_message(&message("a1", CHAT_A, 10)).unwrap();
+
+        assert!(history.update_message_text("a1", "trascrizione audio").unwrap());
+        let stored = history.recent_messages(CHAT_A, 10).unwrap();
+        assert_eq!(stored.len(), 1);
+        assert_eq!(stored[0].text, "trascrizione audio");
+
+        assert!(!history.update_message_text("missing", "x").unwrap());
+    }
+
     #[test]
     fn image_descriptions_are_stored_per_chat_replaced_and_deleted_with_the_chat() {
         let history = History::open_in_memory().unwrap();

+ 1 - 0
src/main.rs

@@ -6,6 +6,7 @@ mod autoreply;
 mod config;
 mod config_watch;
 mod history;
+mod transcribe;
 mod ui;
 mod web;
 mod whatsapp;

+ 520 - 0
src/transcribe.rs

@@ -0,0 +1,520 @@
+//! Trascrizione audio tramite un endpoint HTTP compatibile OpenAI (`audio/transcriptions`).
+
+use std::sync::OnceLock;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use anyhow::{Context, Result, bail};
+use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
+use serde_json::Value;
+
+/// Dimensione massima dell'audio accettato, in byte.
+pub const MAX_AUDIO_BYTES: usize = 25 * 1024 * 1024;
+
+/// Tempo massimo concesso alla richiesta HTTP, in secondi.
+const REQUEST_TIMEOUT_SECONDS: u64 = 60;
+
+/// Numero massimo di caratteri del corpo di risposta riportati nei messaggi d'errore.
+const MAX_DETAIL_CHARS: usize = 300;
+
+/// Estensione usata quando il MIME non è riconosciuto.
+const FALLBACK_SUFFIX: &str = "bin";
+
+/// MIME usato quando il valore fornito non contiene caratteri validi.
+const FALLBACK_MIME: &str = "application/octet-stream";
+
+/// Prefisso del boundary multipart, completato con un valore univoco.
+const BOUNDARY_PREFIX: &str = "wh-boundary";
+
+/// Nome del campo multipart che porta i byte audio.
+const FILE_FIELD_NAME: &str = "file";
+
+/// Nome del campo multipart con il modello.
+const MODEL_FIELD_NAME: &str = "model";
+
+/// Nome del campo multipart con la lingua.
+const LANGUAGE_FIELD_NAME: &str = "language";
+
+/// Valore di lingua che richiede l'auto-rilevamento: il campo va omesso.
+const AUTO_LANGUAGE: &str = "auto";
+
+/// Nome del campo JSON che contiene il testo trascritto.
+const TEXT_FIELD_NAME: &str = "text";
+
+/// Schema di autenticazione dell'header `Authorization`.
+const BEARER_SCHEME: &str = "Bearer";
+
+/// Separatore di riga del protocollo HTTP.
+const CRLF: &str = "\r\n";
+
+/// Spazio riservato in anticipo per gli header di ogni campo multipart, in byte.
+const FORM_FIELD_OVERHEAD_BYTES: usize = 128;
+
+/// Contatore per rendere univoci i boundary delle richieste.
+static BOUNDARY_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+/// Client HTTP condiviso da tutte le trascrizioni del processo.
+static SHARED_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
+
+/// Parametri di un singolo lavoro di trascrizione.
+pub struct TranscriptionJob {
+    /// URL dell'endpoint, ad esempio `https://api.groq.com/openai/v1/audio/transcriptions`.
+    pub url: String,
+    /// Chiave API: non viene mai registrata né riportata negli errori.
+    pub key: String,
+    /// Modello di trascrizione, ad esempio `whisper-large-v3-turbo`.
+    pub model: String,
+    /// Lingua, ad esempio `auto` o `it`.
+    pub language: String,
+    /// MIME type dell'audio in `bytes`, ad esempio `audio/ogg`.
+    pub mime: String,
+}
+
+/// Invia l'audio all'endpoint di trascrizione e restituisce il testo ripulito.
+///
+/// Input: parametri del lavoro e byte audio grezzi. Output: testo trascritto
+/// senza spazi ai bordi. Fallisce prima di qualsiasi richiesta se i byte sono
+/// vuoti o superano [`MAX_AUDIO_BYTES`].
+/// La lingua viene inviata solo se esplicita: con [`AUTO_LANGUAGE`] o un valore
+/// vuoto il campo è omesso, così l'endpoint rileva la lingua da solo.
+/// Effetti: esegue una richiesta HTTPS POST.
+pub async fn transcribe(job: &TranscriptionJob, bytes: &[u8]) -> Result<String> {
+    validate_audio(bytes)?;
+
+    let boundary = boundary();
+    let filename = format!("audio.{}", audio_suffix(&job.mime));
+    let fields = request_fields(job);
+    let body = multipart_body(
+        &boundary,
+        &fields,
+        FILE_FIELD_NAME,
+        &filename,
+        &job.mime,
+        bytes,
+    );
+
+    let response = client()?
+        .post(&job.url)
+        .header(AUTHORIZATION, format!("{BEARER_SCHEME} {}", job.key))
+        .header(
+            CONTENT_TYPE,
+            format!("multipart/form-data; boundary={boundary}"),
+        )
+        .body(body)
+        .send()
+        .await
+        .context("richiesta al servizio di trascrizione fallita")?;
+
+    read_response(response).await
+}
+
+/// Restituisce il client HTTP condiviso, costruendolo al primo uso.
+///
+/// Nota: il provider crittografico rustls è già installato da `AiClient::new`;
+/// qui si costruisce solo un client semplice, senza installare provider.
+fn client() -> Result<reqwest::Client> {
+    if let Some(shared) = SHARED_CLIENT.get() {
+        return Ok(shared.clone());
+    }
+    let fresh = build_client().context("impossibile creare il client HTTP")?;
+    Ok(SHARED_CLIENT.get_or_init(|| fresh).clone())
+}
+
+/// Costruisce un client HTTP con il timeout delle richieste.
+fn build_client() -> Result<reqwest::Client> {
+    reqwest::Client::builder()
+        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
+        .build()
+        .context("impossibile creare il client HTTP")
+}
+
+/// Legge la risposta dell'endpoint e ne estrae il testo trascritto.
+///
+/// Fallisce con stato e corpo (troncato, mai la chiave) se la risposta non è 2xx.
+async fn read_response(response: reqwest::Response) -> Result<String> {
+    let status = response.status();
+    let text = response
+        .text()
+        .await
+        .context("lettura della risposta del servizio di trascrizione fallita")?;
+
+    if !status.is_success() {
+        bail!(
+            "servizio di trascrizione ha risposto {status}: {}",
+            truncate_detail(&text)
+        );
+    }
+    parse_transcription(&text)
+}
+
+/// Estrae l'estensione del file audio dal MIME type, ignorando i parametri.
+pub fn audio_suffix(mime: &str) -> &'static str {
+    let subtype = mime
+        .split(';')
+        .next()
+        .unwrap_or_default()
+        .rsplit('/')
+        .next()
+        .unwrap_or_default()
+        .trim()
+        .to_ascii_lowercase();
+    match subtype.as_str() {
+        "ogg" => "ogg",
+        "mp3" | "mpeg" => "mp3",
+        "mp4" => "mp4",
+        "m4a" | "x-m4a" => "m4a",
+        "aac" => "aac",
+        "wav" | "x-wav" => "wav",
+        _ => FALLBACK_SUFFIX,
+    }
+}
+
+/// Costruisce i campi di testo della richiesta.
+///
+/// Input: parametri del lavoro. Output: il campo `model` sempre presente e il
+/// campo `language`, senza spazi ai bordi, solo se [`is_explicit_language`] lo
+/// ammette. Effetti: nessuno.
+fn request_fields(job: &TranscriptionJob) -> Vec<(&'static str, &str)> {
+    let language = job.language.trim();
+    let mut fields = vec![(MODEL_FIELD_NAME, job.model.as_str())];
+    if is_explicit_language(language) {
+        fields.push((LANGUAGE_FIELD_NAME, language));
+    }
+    fields
+}
+
+/// Indica se la lingua va inviata all'endpoint.
+///
+/// Input: lingua configurata, già senza spazi ai bordi. Output: `false` per
+/// valori vuoti o uguali a [`AUTO_LANGUAGE`] (senza distinzione di maiuscole);
+/// `true` altrimenti. Effetti: nessuno.
+fn is_explicit_language(language: &str) -> bool {
+    !language.is_empty() && !language.eq_ignore_ascii_case(AUTO_LANGUAGE)
+}
+
+/// Costruisce il corpo multipart della richiesta.
+///
+/// Input: `boundary`; `fields`, coppie nome/valore; `file_field`, nome della
+/// parte file; `filename`, `mime` e `bytes` dell'audio. Output: corpo completo,
+/// con ogni parte delimitata dal boundary e `bytes` aggiunti così come sono.
+/// Effetti: nessuno; le parti di `fields` non vengono filtrate.
+fn multipart_body(
+    boundary: &str,
+    fields: &[(&str, &str)],
+    file_field: &str,
+    filename: &str,
+    mime: &str,
+    bytes: &[u8],
+) -> Vec<u8> {
+    let mut body = Vec::with_capacity(bytes.len() + fields.len() * FORM_FIELD_OVERHEAD_BYTES);
+    for (name, value) in fields {
+        body.extend_from_slice(format!("--{boundary}{CRLF}").as_bytes());
+        body.extend_from_slice(
+            format!("Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}").as_bytes(),
+        );
+        body.extend_from_slice(value.as_bytes());
+        body.extend_from_slice(CRLF.as_bytes());
+    }
+
+    body.extend_from_slice(format!("--{boundary}{CRLF}").as_bytes());
+    body.extend_from_slice(
+        format!(
+            "Content-Disposition: form-data; name=\"{file_field}\"; filename=\"{filename}\"{CRLF}"
+        )
+        .as_bytes(),
+    );
+    body.extend_from_slice(format!("Content-Type: {}{CRLF}{CRLF}", header_mime(mime)).as_bytes());
+    body.extend_from_slice(bytes);
+    body.extend_from_slice(CRLF.as_bytes());
+    body.extend_from_slice(format!("--{boundary}--{CRLF}").as_bytes());
+    body
+}
+
+/// Riduce il MIME a un valore di header sicuro su una sola riga.
+///
+/// Input: MIME fornito dal mittente. Output: solo caratteri ASCII stampabili,
+/// senza virgolette; se non ne resta nessuno, [`FALLBACK_MIME`]. Scarta i
+/// caratteri di controllo (inclusi CR/LF) e le virgolette per impedire
+/// l'iniezione di header aggiuntivi nella parte multipart.
+fn header_mime(mime: &str) -> String {
+    let safe: String = mime
+        .chars()
+        .filter(|c| c.is_ascii() && !c.is_ascii_control() && *c != '"')
+        .collect();
+    if safe.is_empty() {
+        return FALLBACK_MIME.to_owned();
+    }
+    safe
+}
+
+/// Estrae il campo `text` dalla risposta JSON, rifiutandolo se manca o è vuoto.
+fn parse_transcription(json_str: &str) -> Result<String> {
+    let value: Value = serde_json::from_str(json_str).with_context(|| {
+        format!(
+            "risposta del servizio di trascrizione non valida: {}",
+            truncate_detail(json_str)
+        )
+    })?;
+    let text = value
+        .get(TEXT_FIELD_NAME)
+        .and_then(Value::as_str)
+        .map(str::trim)
+        .unwrap_or_default();
+    if text.is_empty() {
+        bail!("risposta del servizio di trascrizione senza testo");
+    }
+    Ok(text.to_owned())
+}
+
+/// Rifiuta audio vuoto o oltre il limite, senza inviare alcuna richiesta.
+fn validate_audio(bytes: &[u8]) -> Result<()> {
+    if bytes.is_empty() {
+        bail!("audio vuoto: impossibile trascrivere");
+    }
+    if bytes.len() > MAX_AUDIO_BYTES {
+        bail!(
+            "audio troppo grande: {} byte (massimo {MAX_AUDIO_BYTES})",
+            bytes.len()
+        );
+    }
+    Ok(())
+}
+
+/// Genera un boundary multipart univoco per la richiesta corrente.
+fn boundary() -> String {
+    let unique = BOUNDARY_COUNTER.fetch_add(1, Ordering::Relaxed);
+    format!("{BOUNDARY_PREFIX}-{}-{unique}", std::process::id())
+}
+
+/// Accorcia un dettaglio testuale mantenendo i primi caratteri utili.
+fn truncate_detail(text: &str) -> String {
+    let trimmed = text.trim();
+    if trimmed.chars().count() <= MAX_DETAIL_CHARS {
+        return trimmed.to_string();
+    }
+    let cut: String = trimmed.chars().take(MAX_DETAIL_CHARS).collect();
+    format!("{cut}…")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Costruisce un corpo multipart di esempio per i test di struttura.
+    fn sample_body() -> Vec<u8> {
+        multipart_body(
+            "test-boundary",
+            &[(MODEL_FIELD_NAME, "whisper-large-v3-turbo"), (LANGUAGE_FIELD_NAME, "it")],
+            FILE_FIELD_NAME,
+            "audio.ogg",
+            "audio/ogg",
+            b"AUDIO",
+        )
+    }
+
+    /// Costruisce un lavoro di trascrizione con la lingua indicata.
+    fn job_with_language(language: &str) -> TranscriptionJob {
+        TranscriptionJob {
+            url: "https://example.invalid/v1/audio/transcriptions".to_owned(),
+            key: "test-key".to_owned(),
+            model: "whisper-large-v3-turbo".to_owned(),
+            language: language.to_owned(),
+            mime: "audio/ogg".to_owned(),
+        }
+    }
+
+    /// Costruisce il corpo multipart completo per una lingua configurata.
+    fn body_for_language(language: &str) -> String {
+        let job = job_with_language(language);
+        let body = multipart_body(
+            "test-boundary",
+            &request_fields(&job),
+            FILE_FIELD_NAME,
+            "audio.ogg",
+            &job.mime,
+            b"AUDIO",
+        );
+        String::from_utf8_lossy(&body).into_owned()
+    }
+
+    /// Conta quante parti `language` contiene un corpo multipart.
+    fn language_field_count(body: &str) -> usize {
+        body.matches(&format!("name=\"{LANGUAGE_FIELD_NAME}\"")).count()
+    }
+
+    #[test]
+    fn multipart_body_omits_auto_language_and_keeps_model_and_file() {
+        let body = body_for_language(AUTO_LANGUAGE);
+        assert_eq!(language_field_count(&body), 0, "corpo: {body}");
+        assert!(
+            body.contains(&format!("name=\"{MODEL_FIELD_NAME}\"")),
+            "corpo: {body}"
+        );
+        assert!(
+            body.contains(&format!("name=\"{FILE_FIELD_NAME}\"; filename=\"audio.ogg\"")),
+            "corpo: {body}"
+        );
+    }
+
+    #[test]
+    fn multipart_body_sends_explicit_language_once() {
+        let body = body_for_language("it");
+        assert_eq!(language_field_count(&body), 1, "corpo: {body}");
+        assert!(
+            body.contains(&format!("name=\"{LANGUAGE_FIELD_NAME}\"{CRLF}{CRLF}it{CRLF}")),
+            "corpo: {body}"
+        );
+    }
+
+    #[test]
+    fn multipart_body_trims_padded_explicit_language() {
+        let body = body_for_language(" it ");
+        assert_eq!(language_field_count(&body), 1, "corpo: {body}");
+        assert!(
+            body.contains(&format!("name=\"{LANGUAGE_FIELD_NAME}\"{CRLF}{CRLF}it{CRLF}")),
+            "corpo: {body}"
+        );
+    }
+
+    #[test]
+    fn multipart_body_treats_blank_and_auto_variants_as_auto() {
+        for language in ["", "   ", "AUTO", " auto "] {
+            let body = body_for_language(language);
+            assert_eq!(
+                language_field_count(&body),
+                0,
+                "lingua {language:?}, corpo: {body}"
+            );
+        }
+    }
+
+    #[test]
+    fn audio_suffix_maps_known_types() {
+        assert_eq!(audio_suffix("audio/ogg"), "ogg");
+        assert_eq!(audio_suffix("audio/mpeg"), "mp3");
+        assert_eq!(audio_suffix("audio/mp3"), "mp3");
+        assert_eq!(audio_suffix("audio/mp4"), "mp4");
+        assert_eq!(audio_suffix("audio/m4a"), "m4a");
+        assert_eq!(audio_suffix("audio/aac"), "aac");
+        assert_eq!(audio_suffix("audio/wav"), "wav");
+    }
+
+    #[test]
+    fn audio_suffix_ignores_codecs_parameter() {
+        assert_eq!(audio_suffix("audio/ogg; codecs=opus"), "ogg");
+        assert_eq!(audio_suffix("audio/mp4;codecs=mp4a.40.2"), "mp4");
+    }
+
+    #[test]
+    fn audio_suffix_falls_back_to_bin() {
+        assert_eq!(audio_suffix("application/octet-stream"), "bin");
+        assert_eq!(audio_suffix(""), "bin");
+    }
+
+    #[test]
+    fn multipart_body_contains_fields_before_the_file_part() {
+        let body = String::from_utf8_lossy(&sample_body()).into_owned();
+        let model = concat!(
+            "--test-boundary\r\n",
+            "Content-Disposition: form-data; name=\"model\"\r\n\r\n",
+            "whisper-large-v3-turbo\r\n"
+        );
+        let language = concat!(
+            "--test-boundary\r\n",
+            "Content-Disposition: form-data; name=\"language\"\r\n\r\n",
+            "it\r\n"
+        );
+        assert!(body.starts_with(model), "corpo: {body}");
+        assert!(body.contains(language), "corpo: {body}");
+        assert!(body.find(model).unwrap() < body.find(language).unwrap());
+    }
+
+    #[test]
+    fn multipart_body_has_file_headers_and_terminator() {
+        let body = sample_body();
+        let file_header = concat!(
+            "--test-boundary\r\n",
+            "Content-Disposition: form-data; name=\"file\"; filename=\"audio.ogg\"\r\n",
+            "Content-Type: audio/ogg\r\n\r\n"
+        );
+        let expected_tail = b"AUDIO\r\n--test-boundary--\r\n";
+        assert!(
+            body.windows(file_header.len()).any(|w| w == file_header.as_bytes()),
+            "header della parte file assente"
+        );
+        assert!(body.ends_with(expected_tail), "corpo: {:?}", String::from_utf8_lossy(&body));
+    }
+
+    #[test]
+    fn multipart_body_appends_bytes_unchanged() {
+        let bytes = [0x00, 0xFF, b'\r', b'\n', b'x'];
+        let body = multipart_body("b", &[], FILE_FIELD_NAME, "audio.bin", "audio/ogg", &bytes);
+        let start = body
+            .windows(bytes.len())
+            .position(|w| w == bytes)
+            .expect("byte audio assenti dal corpo");
+        assert!(start > 0, "i byte devono seguire gli header");
+    }
+
+    #[test]
+    fn multipart_body_sanitizes_mime_into_a_single_header_line() {
+        let injected = multipart_body(
+            "b",
+            &[],
+            FILE_FIELD_NAME,
+            "audio.ogg",
+            "audio/ogg\r\nX-Evil: 1\"",
+            b"A",
+        );
+        let text = String::from_utf8_lossy(&injected).into_owned();
+        assert!(
+            text.contains("Content-Type: audio/oggX-Evil: 1\r\n\r\n"),
+            "corpo: {text}"
+        );
+        assert_eq!(text.matches("Content-Type:").count(), 1, "corpo: {text}");
+
+        let blank = multipart_body("b", &[], FILE_FIELD_NAME, "audio.bin", "\r\n\"", b"A");
+        let blank = String::from_utf8_lossy(&blank).into_owned();
+        assert!(
+            blank.contains(&format!("Content-Type: {FALLBACK_MIME}\r\n\r\n")),
+            "corpo: {blank}"
+        );
+    }
+
+    #[test]
+    fn boundary_is_unique_per_call() {
+        assert_ne!(boundary(), boundary());
+    }
+
+    #[test]
+    fn validate_audio_rejects_empty_audio() {
+        let error = validate_audio(&[]).unwrap_err();
+        assert!(error.to_string().contains("vuoto"), "errore: {error}");
+    }
+
+    #[test]
+    fn validate_audio_rejects_oversize_audio() {
+        let bytes = vec![0u8; MAX_AUDIO_BYTES + 1];
+        let error = validate_audio(&bytes).unwrap_err();
+        assert!(error.to_string().contains("troppo grande"), "errore: {error}");
+    }
+
+    #[test]
+    fn parse_transcription_trims_text() {
+        assert_eq!(
+            parse_transcription(r#"{"text":"  ciao mondo\n"}"#).unwrap(),
+            "ciao mondo"
+        );
+    }
+
+    #[test]
+    fn parse_transcription_rejects_blank_text() {
+        assert!(parse_transcription(r#"{"text":"  \n\t "}"#).is_err());
+    }
+
+    #[test]
+    fn parse_transcription_rejects_missing_text() {
+        assert!(parse_transcription(r#"{"error":"boom"}"#).is_err());
+        assert!(parse_transcription("non json").is_err());
+    }
+}

+ 97 - 14
src/whatsapp.rs

@@ -51,6 +51,9 @@ const IMAGE_PLACEHOLDER: &str = "[immagine]";
 /// MIME type assumed when an image message does not declare one.
 const DEFAULT_IMAGE_MIME: &str = "image/jpeg";
 
+/// MIME type assumed when an audio message does not declare one.
+const DEFAULT_AUDIO_MIME: &str = "audio/ogg";
+
 /// Event kinds the forwarder subscribes to; every other kind is never materialized.
 const SUBSCRIBED_KINDS: &[EventKind] = &[
     EventKind::Connected,
@@ -116,12 +119,14 @@ pub enum WaEvent {
     /// (and, being sequential, already emitted as [`WaEvent::Message`]).
     OfflineSyncCompleted,
     /// Live, history-sync or phone-sent message (`is_ai` is always false).
-    /// `image` is `Some` only for live image messages (never for history sync).
+    /// `image` and `audio` are `Some` only for live media messages (never for history sync).
     Message {
         /// The message to persist.
         message: StoredMessage,
         /// Reference to download the image bytes on demand.
         image: Option<ImageRef>,
+        /// Reference to download the audio bytes on demand.
+        audio: Option<AudioRef>,
     },
     /// Contact name learned from contact sync, history sync or push names.
     Contact(Contact),
@@ -157,6 +162,34 @@ pub struct DownloadedImage {
     pub mime: String,
 }
 
+/// Reference to a live audio message, enough to download and decrypt its bytes.
+#[derive(Clone)]
+pub struct AudioRef {
+    /// Audio proto carrying the CDN path and media key.
+    message: Arc<wa::message::AudioMessage>,
+}
+
+impl std::fmt::Debug for AudioRef {
+    /// Formats the reference without any binary or key material.
+    ///
+    /// Inputs: the formatter. Output: the formatting result. Side effects: none.
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("AudioRef")
+            .field("mimetype", &self.message.mimetype)
+            .field("file_length", &self.message.file_length)
+            .finish_non_exhaustive()
+    }
+}
+
+/// Decrypted audio content downloaded on demand.
+#[derive(Debug, Clone)]
+pub struct DownloadedAudio {
+    /// Plain audio bytes (never written to disk by this module).
+    pub bytes: Vec<u8>,
+    /// MIME type from the message, `audio/ogg` when absent.
+    pub mime: String,
+}
+
 /// Handle to the running WhatsApp session. Clones share the same session, which
 /// [`WaHandle::relink`] can replace with a new one.
 #[derive(Clone)]
@@ -276,6 +309,26 @@ impl WaHandle {
         Ok(DownloadedImage { bytes, mime })
     }
 
+    /// Downloads and decrypts an audio message.
+    ///
+    /// Inputs: the reference received in [`WaEvent::Message`].
+    /// Output: the plain bytes and MIME type.
+    /// Side effects: HTTPS request to the WhatsApp CDN; the bytes stay in memory.
+    pub async fn download_audio(&self, audio: &AudioRef) -> Result<DownloadedAudio> {
+        let bytes = self
+            .client()?
+            .download(audio.message.as_ref())
+            .await
+            .context("failed to download the audio from WhatsApp")?;
+        let mime = audio
+            .message
+            .mimetype
+            .clone()
+            .filter(|m| !m.trim().is_empty())
+            .unwrap_or_else(|| DEFAULT_AUDIO_MIME.to_owned());
+        Ok(DownloadedAudio { bytes, mime })
+    }
+
     /// Lists this account's own JIDs.
     ///
     /// Inputs: none (reads the client's device identity).
@@ -680,6 +733,11 @@ impl Translator {
         } else {
             None
         };
+        let audio = if is_voice_note(&text) {
+            audio_ref_of(&inbound.message)
+        } else {
+            None
+        };
         self.emit(WaEvent::Message {
             message: StoredMessage {
                 message_id: info.id.to_string(),
@@ -692,6 +750,7 @@ impl Translator {
                 is_ai: false,
             },
             image,
+            audio,
         });
     }
 
@@ -817,6 +876,7 @@ impl Translator {
                     is_ai: false,
                 },
                 image: None,
+                audio: None,
             });
         }
     }
@@ -1083,6 +1143,21 @@ fn image_ref_of(message: &wa::Message) -> Option<ImageRef> {
         })
 }
 
+/// Extracts a downloadable reference when the message is an audio message.
+///
+/// Inputs: the decoded message.
+/// Output: the [`AudioRef`], or `None` when the message carries no audio.
+/// Side effects: clones the audio proto (no network access).
+fn audio_ref_of(message: &wa::Message) -> Option<AudioRef> {
+    message
+        .get_base_message()
+        .audio_message
+        .as_option()
+        .map(|audio| AudioRef {
+            message: Arc::new(audio.clone()),
+        })
+}
+
 /// Tells whether a message text is one of the placeholders generated for
 /// media without caption or for unsupported content.
 ///
@@ -1201,6 +1276,17 @@ mod tests {
         }
     }
 
+    /// Builds an audio message; `ptt` marks it as a push-to-talk voice note.
+    fn audio_message(ptt: bool) -> wa::Message {
+        wa::Message {
+            audio_message: MessageField::some(wa::message::AudioMessage {
+                ptt: Some(ptt),
+                ..Default::default()
+            }),
+            ..Default::default()
+        }
+    }
+
     #[test]
     fn is_fatal_sync_failure_only_for_non_empty_fatal() {
         assert!(!is_fatal_sync_failure(&[]));
@@ -1276,23 +1362,12 @@ mod tests {
 
     #[test]
     fn audio_gets_voice_placeholder() {
-        let message = wa::Message {
-            audio_message: MessageField::some(wa::message::AudioMessage {
-                ptt: Some(true),
-                ..Default::default()
-            }),
-            ..Default::default()
-        };
         assert_eq!(
-            describe_message(&message),
+            describe_message(&audio_message(true)),
             Some(VOICE_PLACEHOLDER.to_owned())
         );
-        let plain_audio = wa::Message {
-            audio_message: MessageField::some(wa::message::AudioMessage::default()),
-            ..Default::default()
-        };
         assert_eq!(
-            describe_message(&plain_audio),
+            describe_message(&audio_message(false)),
             Some(VOICE_PLACEHOLDER.to_owned())
         );
     }
@@ -1311,6 +1386,14 @@ mod tests {
         assert!(image_ref_of(&text_message("ciao")).is_none());
     }
 
+    #[test]
+    fn audio_ref_is_extracted_only_from_audio_messages() {
+        assert!(audio_ref_of(&audio_message(true)).is_some());
+        assert!(audio_ref_of(&audio_message(false)).is_some());
+        assert!(audio_ref_of(&image_message(None)).is_none());
+        assert!(audio_ref_of(&text_message("ciao")).is_none());
+    }
+
     #[test]
     fn voice_note_detection() {
         assert!(is_voice_note(VOICE_PLACEHOLDER));