_binary diff --git a/src/configs/Config.php b/src/configs/Config.php
index 55cb7ffb8..5ed9610fd 100755
--- a/src/configs/Config.php
+++ b/src/configs/Config.php
@@ -356,9 +356,7 @@ nsdefine('ONE_MINUTE', 60);
 nsdefine('ONE_SECOND', 1);
 /** Maximum number of messages to retrieve for summarization */
 nsdefine('MAX_SUMMARIZE_MESSAGES', 10000);
-/** URL endpoint for LLM API service used for AI summarization
- * For example, LM studio api url
- */
+/** URL endpoint for LLM API service used for AI summarization */
 nsdefine('LLM_API_URL', "http://localhost:1234/v1/chat/completions");
 /** Model name to use for LLM API requests */
 nsdefine('LLM_MODEL', "aya-23-8b");
@@ -1062,6 +1060,28 @@ nsconddefine("MAX_VIDEO_CONVERT_SIZE", 2000000000);
  * loop to avoid conversion of same file multiple times.
  */
 nsconddefine('MAX_FILE_TIMESTAMP_LIMIT', 600);
+/**
+ * Path to Whisper binary for audio transcription
+ * Set this to enable automatic transcription of uploaded audio files
+ */
+nsconddefine('WHISPER', '/opt/homebrew/bin/whisper');
+/**
+ * Path to FFMPEG binary for audio/video processing
+ * Used for audio format conversion and video processing
+ */
+nsconddefine('FFMPEG', '/opt/homebrew/bin/ffmpeg');
+/**
+ * Maximum size in bytes for audio files that can be transcribed
+ */
+nsconddefine('MAX_AUDIO_TRANSCRIBE_SIZE', 100000000);
+/**
+ * Timeout in seconds for audio transcription processing
+ */
+nsconddefine('TRANSCRIPTION_TIMEOUT', 1800);
+/**
+ * Maximum length of transcript content to display in browser
+ */
+nsconddefine('MAX_TRANSCRIPT_DISPLAY_LENGTH', 50000);
 /**
  * Mail scheduled for delivery by yioop is aggregated in a text file until
  * MAIL_AGGREGATION_TIME seconds has passed. At this point all the mail
diff --git a/src/controllers/ApiController.php b/src/controllers/ApiController.php
index 8d3ab6fd7..ce0f6fc0b 100644
--- a/src/controllers/ApiController.php
+++ b/src/controllers/ApiController.php
@@ -55,7 +55,7 @@ class ApiController extends Controller implements CrawlConstants
      * These are the activities supported by this controller
      * @var array
      */
-    public $activities = ["summarize"];
+    public $activities = ["summarize", "transcribe", "transcriptionStatus"];
     /**
      * Used to process requests related to user group activities outside of
      * the admin panel setting. This either could be because the admin panel
@@ -271,4 +271,197 @@ class ApiController extends Controller implements CrawlConstants
         }
         return $response;
     }
+
+    /**
+     * Handles request to transcribe an audio file
+     * Creates a transcription task for background processing by MediaUpdater
+     *
+     * @return array Response with success status and any error messages
+     */
+    public function transcribe()
+    {
+        $data = ['success' => false, 'errors' => []];
+
+        if (!$this->checkCSRFToken()) {
+            $data['errors'][] = 'Invalid CSRF token';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        if (!C\nsdefined('WHISPER')) {
+            $data['errors'][] = 'Audio transcription not configured';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        $json = file_get_contents('php://input');
+        $task_data = json_decode($json, true);
+
+        if (!$task_data) {
+            $data['errors'][] = 'Invalid request data';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+        $required_fields = ['audio_path', 'filename', 'task_id'];
+        foreach ($required_fields as $field) {
+            if (!isset($task_data[$field]) || empty($task_data[$field])) {
+                $data['errors'][] = "Missing required field: $field";
+                $this->returnJsonResponse($data);
+                return $data;
+            }
+        }
+
+        $task_data['audio_path'] = $this->clean($task_data['audio_path'],
+            'string');
+        $task_data['filename'] = $this->clean($task_data['filename'],
+            'string');
+        $task_data['task_id'] = $this->clean($task_data['task_id'], 'string');
+
+        if (!file_exists($task_data['audio_path']) ||
+            !is_readable($task_data['audio_path'])) {
+            $data['errors'][] = 'Audio file not found or not accessible';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        $file_size = filesize($task_data['audio_path']);
+        if ($file_size > C\MAX_AUDIO_TRANSCRIBE_SIZE) {
+            $data['errors'][] = 'Audio file too large for transcription';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        if (!file_exists($task_dir)) {
+            if (!mkdir($task_dir, 0777, true)) {
+                $data['errors'][] = 'Failed to create task directory';
+                $this->returnJsonResponse($data);
+                return $data;
+            }
+        }
+
+        $task_file = $task_dir . "/" . $task_data['task_id'] . ".txt";
+        $status_file = $task_dir . "/" . $task_data['task_id'] . ".status";
+
+        if (file_exists($task_file)) {
+            $data['errors'][] = 'Transcription task already exists';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        $task_data['created_at'] = time();
+        $task_data['status'] = 'pending';
+        $task_data['retry_count'] = 0;
+
+        if (file_put_contents($task_file, serialize($task_data)) === false) {
+            $data['errors'][] = 'Failed to create task file';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        if (file_put_contents($status_file, 'pending') === false) {
+            $data['errors'][] = 'Failed to create status file';
+            unlink($task_file);
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+        chmod($task_file, 0644);
+        chmod($status_file, 0644);
+
+        $data['success'] = true;
+        $data['task_id'] = $task_data['task_id'];
+        $data['message'] = 'Transcription task created successfully';
+
+        L\crawlLog("AudioTranscription: Task created for " .
+            $task_data['filename'] . " (ID: " . $task_data['task_id'] . ")");
+
+        $this->returnJsonResponse($data);
+        return $data;
+    }
+
+    /**
+     * Checks the status of a transcription task
+     * Returns current status and transcript content if available
+     *
+     * @return array Response with task status and transcript if available
+     */
+    public function transcriptionStatus()
+    {
+        $data = ['success' => false, 'errors' => []];
+
+        // Validate CSRF token
+        if (!$this->checkCSRFToken()) {
+            $data['errors'][] = 'Invalid CSRF token';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        $task_id = $this->clean($_REQUEST['task_id'] ?? '', 'string');
+        if (!$task_id) {
+            $data['errors'][] = 'Invalid task ID';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        $status_file = $task_dir . "/" . $task_id . ".status";
+        $task_file = $task_dir . "/" . $task_id . ".txt";
+
+        if (!file_exists($status_file)) {
+            $data['errors'][] = 'Task not found';
+            $this->returnJsonResponse($data);
+            return $data;
+        }
+
+        $status = file_get_contents($status_file);
+        $data['status'] = $status;
+        $data['success'] = true;
+
+        if ($status === 'completed') {
+            if (file_exists($task_file)) {
+                $task_data = unserialize(file_get_contents($task_file));
+                if ($task_data && isset($task_data['audio_path'])) {
+                    $transcript_path = dirname($task_data['audio_path']) . "/" .
+                        pathinfo($task_data['filename'],
+                            PATHINFO_FILENAME) . ".txt";
+
+                    if (file_exists($transcript_path)) {
+                        $transcript_content =
+                            file_get_contents($transcript_path);
+                        if ($transcript_content !== false) {
+                            $data['transcript'] = trim($transcript_content);
+                        } else {
+                            $data['status'] = 'failed';
+                            $data['errors'][] =
+                                'Failed to read transcript file';
+                        }
+                    } else {
+                        $data['status'] = 'failed';
+                        $data['errors'][] = 'Transcript file not found';
+                    }
+                }
+            }
+        } elseif ($status === 'failed') {
+            $data['errors'][] = 'Transcription failed';
+        } elseif ($status === 'processing') {
+            $data['message'] = 'Transcription in progress';
+        } elseif ($status === 'pending') {
+            $data['message'] = 'Transcription queued';
+        }
+
+        $this->returnJsonResponse($data);
+        return $data;
+    }
+
+    /**
+     * Helper method to return JSON response with proper headers
+     *
+     * @param array $data Response data to encode as JSON
+     */
+    private function returnJsonResponse($data)
+    {
+        header('Content-Type: application/json');
+        header('Cache-Control: no-cache, must-revalidate');
+        header('Expires: Sat, 26 Jul 1997 05:00:00 GMT');
+        echo json_encode($data, JSON_UNESCAPED_UNICODE);
+    }
 }
diff --git a/src/css/messages.css b/src/css/messages.css
index 879bdb93b..bfe1b6840 100644
--- a/src/css/messages.css
+++ b/src/css/messages.css
@@ -581,3 +581,80 @@
     display: none;
     margin-left: 65px;
 }
+
+/* Audio transcription styles */
+.audio-message-container {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    padding: 8px;
+    background-color: #f8f9fa;
+    border-radius: 8px;
+    margin: 8px 0;
+    width: 300px;
+    max-width: 100%;
+}
+.audio-player-wrapper {
+    position: relative;
+    width: 100%;
+    margin-bottom: -4px;
+}
+.audio-message-container audio {
+    width: 100%;
+    border-radius: 4px;
+}
+.transcribe-btn {
+    position: absolute;
+    top: -12px;
+    left: -12px;
+    background-color: transparent;
+    border: none;
+    color: #000;
+    cursor: pointer;
+    font-size: 16px;
+    padding: 2px;
+    height: auto;
+    width: auto;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    transition: all 0.2s;
+    z-index: 1;
+    text-shadow: 0 0 3px rgba(255, 255, 255, 0.8);
+}
+.transcribe-btn:hover {
+    transform: scale(1.2);
+    text-shadow: 0 0 5px rgba(255, 255, 255, 1);
+}
+.transcribe-btn:active {
+    transform: scale(0.95);
+}
+.transcript-container {
+    display: none;
+    padding: 8px;
+    background-color: #ffffff;
+    border: 1px solid #e0e0e0;
+    border-radius: 4px;
+    white-space: normal;
+    overflow-wrap: break-word;
+    word-wrap: break-word;
+    word-break: break-word;
+    font-size: 14px;
+    color: #333;
+    line-height: 1.4;
+    max-height: 200px;
+    overflow-y: auto;
+    word-spacing: -0.1em;
+}
+.transcript-container.show {
+    display: block;
+}
+.transcript-container.loading {
+    color: #666;
+    font-style: italic;
+}
+.transcript-container.error {
+    color: #d32f2f;
+    font-style: italic;
+}
diff --git a/src/library/media_jobs/AudioTranscriptionJob.php b/src/library/media_jobs/AudioTranscriptionJob.php
new file mode 100644
index 000000000..51f49cd03
--- /dev/null
+++ b/src/library/media_jobs/AudioTranscriptionJob.php
@@ -0,0 +1,582 @@
+<?php
+/**
+ * SeekQuarry/Yioop --
+ * Open Source Pure PHP Search Engine, Crawler, and Indexer
+ *
+ * Copyright (C) 2009 - 2025  Chris Pollett chris@pollett.org
+ *
+ * LICENSE:
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * END LICENSE
+ *
+ * @author Aditya Prajapati aditya@jkprajapati.tech
+ * @package seek_quarry
+ * @subpackage library
+ * @license https://www.gnu.org/licenses/ GPL3
+ * @link https://www.seekquarry.com/
+ * @copyright 2009 - 2025
+ * @filesource
+ */
+namespace seekquarry\yioop\library\media_jobs;
+
+use seekquarry\yioop\configs as C;
+use seekquarry\yioop\library as L;
+use seekquarry\yioop\library\CrawlConstants;
+use seekquarry\yioop\library\MediaConstants;
+
+/**
+ * MediaJob to handle audio file transcription using Whisper
+ *
+ * This job processes audio files uploaded to group pages and wiki pages
+ * by generating text transcripts using OpenAI's Whisper speech recognition
+ * model. Transcripts are stored alongside the original audio files and
+ * can be displayed to users via the web interface.
+ */
+class AudioTranscriptionJob extends MediaJob
+{
+
+    /**
+     * Maximum number of concurrent transcription tasks
+     */
+    const MAX_CONCURRENT_TASKS = 3;
+
+
+    /**
+     * Maximum retry attempts for failed transcriptions
+     */
+    const MAX_RETRY_ATTEMPTS = 3;
+
+    /**
+     * Supported audio file types for transcription
+     * @var array
+     */
+    public $supported_audio_types = [
+        'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg',
+        'audio/mp4', 'audio/webm', 'audio/m4a'
+    ];
+
+    /**
+     * Initialize the job configuration
+     */
+    public function init()
+    {
+        $this->name_server_does_client_tasks = true;
+        $this->name_server_does_client_tasks_only = true;
+    }
+
+    /**
+     * Check if prerequisites for transcription are met
+     * Verifies Whisper installation and checks for pending tasks
+     *
+     * @return bool whether prerequisites are satisfied
+     */
+    public function checkPrerequisites()
+    {
+        // Check if Whisper is installed and configured
+        if (!C\nsdefined('WHISPER') || !function_exists("exec")) {
+            L\crawlLog("AudioTranscriptionJob: Whisper not configured or " .
+                "exec() not available");
+            return false;
+        }
+
+        // Verify Whisper binary exists
+        $whisper_path = C\WHISPER;
+        if (!is_executable($whisper_path)) {
+            L\crawlLog("AudioTranscriptionJob: Whisper binary not found or " .
+                "not executable at: $whisper_path");
+            return false;
+        }
+
+        // Check for pending transcription tasks
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        if (!file_exists($task_dir)) {
+            return false;
+        }
+
+        $pending_files = glob($task_dir . "/*.txt");
+        if (empty($pending_files)) {
+            return false;
+        }
+
+        // Check for tasks that are actually pending
+        foreach ($pending_files as $file) {
+            $task_data = unserialize(file_get_contents($file));
+            if ($task_data) {
+                $status_file = $task_dir . "/" . $task_data['task_id'] .
+                    ".status";
+                if (file_exists($status_file)) {
+                    $status = file_get_contents($status_file);
+                    if ($status === 'pending' || $status === 'failed') {
+                        return true;
+                    }
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Handle nondistributed mode by directly processing tasks
+     */
+    public function nondistributedTasks()
+    {
+        L\crawlLog("AudioTranscriptionJob: Running in nondistributed mode");
+        $this->prepareTasks();
+        if (!empty($this->tasks)) {
+            foreach ($this->tasks as $task) {
+                $result = $this->doTasks($task);
+                if (!$result['success']) {
+                    L\crawlLog("AudioTranscriptionJob: Task failed - " .
+                        $result['error']);
+                }
+            }
+        }
+    }
+
+    /**
+     * Prepare tasks for audio transcription
+     * Scans for audio files that need transcription and filters by status
+     */
+    public function prepareTasks()
+    {
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        if (!file_exists($task_dir)) {
+            return;
+        }
+
+        $tasks = [];
+        $files = glob($task_dir . "/*.txt");
+        $current_processing = $this->countProcessingTasks($task_dir);
+
+        foreach ($files as $file) {
+            // Limit concurrent processing
+            if (count($tasks) + $current_processing >=
+                self::MAX_CONCURRENT_TASKS) {
+                break;
+            }
+
+            $task_data = unserialize(file_get_contents($file));
+            if (!$task_data || !isset($task_data['task_id'])) {
+                continue;
+            }
+
+            $status_file = $task_dir . "/" . $task_data['task_id'] . ".status";
+            if (file_exists($status_file)) {
+                $status = file_get_contents($status_file);
+
+                // Process pending tasks or retry failed tasks
+                if ($status === 'pending' ||
+                    ($status === 'failed' && $this->shouldRetry($task_data))) {
+                    // Validate task data
+                    if ($this->validateTaskData($task_data)) {
+                        $tasks[] = $task_data;
+                    } else {
+                        L\crawlLog("AudioTranscriptionJob: Invalid task data, " .
+                            "removing task " . $task_data['task_id']);
+                        $this->cleanupTask($task_data['task_id'], $task_dir);
+                    }
+                } else if ($status === 'failed' &&
+                    !$this->shouldRetry($task_data)) {
+                    // Handle permanently failed tasks (max retries exceeded)
+                    L\crawlLog("AudioTranscriptionJob: Task " .
+                        $task_data['task_id'] . " permanently failed after " .
+                        ($task_data['retry_count'] ?? 0) . " attempts");
+                    $this->handlePermanentFailure($task_data, $task_dir);
+                }
+            }
+        }
+
+        $this->tasks = $tasks;
+        L\crawlLog("AudioTranscriptionJob: Prepared " . count($tasks) .
+            " tasks for processing");
+    }
+
+    /**
+     * Process audio file and generate transcript
+     *
+     * @param array $task Audio file task data
+     * @return array Result of transcription
+     */
+    public function doTasks($task)
+    {
+        $task_id = $task['task_id'];
+        $filename = $task['filename'];
+
+        L\crawlLog("AudioTranscriptionJob: Starting transcription for " .
+            "task $task_id: $filename");
+
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        $status_file = $task_dir . "/" . $task_id . ".status";
+        $task_file = $task_dir . "/" . $task_id . ".txt";
+
+        // Update status to processing
+        file_put_contents($status_file, 'processing');
+
+        $audio_path = $task['audio_path'];
+
+        // Validate audio file exists and is readable
+        if (!file_exists($audio_path) || !is_readable($audio_path)) {
+            $error = "Audio file not found or not readable: $audio_path";
+            L\crawlLog("AudioTranscriptionJob: $error");
+            $task['retry_count'] = ($task['retry_count'] ?? 0) + 1;
+            file_put_contents($task_file, serialize($task));
+            file_put_contents($status_file, 'failed');
+            return [
+                'success' => false,
+                'error' => $error,
+                'file' => $filename
+            ];
+        }
+
+        // Check if audio format is supported, convert if needed
+        $working_audio_path = $audio_path;
+        $converted_file = null;
+
+        if (!$this->isSupportedAudioFormat($filename)) {
+            L\crawlLog("AudioTranscriptionJob: Unsupported format detected: " .
+                "$filename, attempting conversion");
+
+            $converted_file = dirname($audio_path) . "/" .
+                pathinfo($filename, PATHINFO_FILENAME) . "_converted.wav";
+
+            if (!$this->convertAudioFormat($audio_path, $converted_file)) {
+                $error = "Failed to convert unsupported audio format: " .
+                    $filename;
+                L\crawlLog("AudioTranscriptionJob: $error");
+                $task['retry_count'] = ($task['retry_count'] ?? 0) + 1;
+                file_put_contents($task_file, serialize($task));
+                file_put_contents($status_file, 'failed');
+                return [
+                    'success' => false,
+                    'error' => $error,
+                    'file' => $filename
+                ];
+            }
+
+            $working_audio_path = $converted_file;
+            L\crawlLog("AudioTranscriptionJob: Successfully converted to: " .
+                $converted_file);
+        }
+
+        // Check file size
+        $file_size = filesize($audio_path);
+        if ($file_size > C\MAX_AUDIO_TRANSCRIBE_SIZE) {
+            $error = "Audio file too large: " .
+                round($file_size / 1024 / 1024, 2) . "MB";
+            L\crawlLog("AudioTranscriptionJob: $error");
+            file_put_contents($status_file, 'failed');
+            return [
+                'success' => false,
+                'error' => $error,
+                'file' => $filename
+            ];
+        }
+
+        $transcript_path = dirname($audio_path) . "/" .
+            pathinfo($filename, PATHINFO_FILENAME) . ".txt";
+
+        // Build Whisper command with proper escaping
+        $whisper_cmd = C\WHISPER;
+        $output_dir = dirname($transcript_path);
+        $output_format = "txt";
+        $model = "turbo"; // Use turbo model for faster processing
+
+        $command = sprintf(
+            "%s %s --model %s --output_dir %s --output_format %s 2>&1",
+            escapeshellcmd($whisper_cmd),
+            escapeshellarg($working_audio_path),
+            escapeshellarg($model),
+            escapeshellarg($output_dir),
+            escapeshellarg($output_format)
+        );
+
+        L\crawlLog("AudioTranscriptionJob: Executing: $command");
+
+        $start_time = time();
+        exec($command, $output, $return_var);
+        $duration = time() - $start_time;
+
+        L\crawlLog("AudioTranscriptionJob: Command completed in " .
+            "{$duration}s with return code: $return_var");
+
+        if ($return_var !== 0) {
+            $error_msg = "Whisper transcription failed (exit code: " .
+                "$return_var): " . implode("\n", $output);
+            L\crawlLog("AudioTranscriptionJob: $error_msg");
+
+            // Increment retry count
+            $task['retry_count'] = ($task['retry_count'] ?? 0) + 1;
+            file_put_contents($task_file, serialize($task));
+            file_put_contents($status_file, 'failed');
+
+            return [
+                'success' => false,
+                'error' => "Failed to transcribe audio: " .
+                    (end($output) ?: "Unknown error"),
+                'file' => $filename
+            ];
+        }
+
+        // Find and move the generated transcript
+        // Whisper generates output based on the input filename
+        $whisper_output = $output_dir . "/" .
+            pathinfo($working_audio_path, PATHINFO_FILENAME) . ".txt";
+
+        if (file_exists($whisper_output)) {
+            // Validate transcript content
+            $transcript_content = file_get_contents($whisper_output);
+            if (empty(trim($transcript_content))) {
+                $error = "Generated transcript is empty";
+                L\crawlLog("AudioTranscriptionJob: $error");
+                file_put_contents($status_file, 'failed');
+                unlink($whisper_output);
+                return [
+                    'success' => false,
+                    'error' => $error,
+                    'file' => $filename
+                ];
+            }
+
+            // Move to final location if different
+            if ($whisper_output !== $transcript_path) {
+                if (!rename($whisper_output, $transcript_path)) {
+                    $error = "Failed to move transcript to final location";
+                    L\crawlLog("AudioTranscriptionJob: $error");
+                    file_put_contents($status_file, 'failed');
+                    return [
+                        'success' => false,
+                        'error' => $error,
+                        'file' => $filename
+                    ];
+                }
+            }
+
+            // Set proper permissions
+            chmod($transcript_path, 0644);
+
+        } else {
+            $error = "Transcript file not generated at expected location: " .
+                $whisper_output;
+            L\crawlLog("AudioTranscriptionJob: $error");
+            file_put_contents($status_file, 'failed');
+            return [
+                'success' => false,
+                'error' => "Transcript file not found after processing",
+                'file' => $filename
+            ];
+        }
+
+        // Mark as completed and cleanup
+        file_put_contents($status_file, 'completed');
+        unlink($task_file);
+
+        // Clean up converted file if it was created
+        if ($converted_file && file_exists($converted_file)) {
+            unlink($converted_file);
+            L\crawlLog("AudioTranscriptionJob: Cleaned up converted file: " .
+                $converted_file);
+        }
+
+        L\crawlLog("AudioTranscriptionJob: Successfully completed " .
+            "transcription for: $filename");
+
+        return [
+            'success' => true,
+            'file' => $filename,
+            'transcript_path' => $transcript_path,
+            'duration' => $duration
+        ];
+    }
+
+    /**
+     * Count currently processing tasks to limit concurrency
+     *
+     * @param string $task_dir Task directory path
+     * @return int Number of tasks currently being processed
+     */
+    private function countProcessingTasks($task_dir)
+    {
+        $processing_count = 0;
+        $status_files = glob($task_dir . "/*.status");
+
+        foreach ($status_files as $status_file) {
+            $status = file_get_contents($status_file);
+            if ($status === 'processing') {
+                $processing_count++;
+            }
+        }
+
+        return $processing_count;
+    }
+
+    /**
+     * Validate task data structure and file availability
+     *
+     * @param array $task_data Task data to validate
+     * @return bool True if task data is valid
+     */
+    private function validateTaskData($task_data)
+    {
+        $required_fields = ['task_id', 'audio_path', 'filename'];
+
+        foreach ($required_fields as $field) {
+            if (!isset($task_data[$field]) || empty($task_data[$field])) {
+                return false;
+            }
+        }
+
+        // Validate audio file exists
+        if (!file_exists($task_data['audio_path'])) {
+            return false;
+        }
+
+        // Validate file size
+        $file_size = filesize($task_data['audio_path']);
+        if ($file_size > C\MAX_AUDIO_TRANSCRIBE_SIZE || $file_size === 0) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Determine if a failed task should be retried
+     *
+     * @param array $task_data Task data
+     * @return bool True if task should be retried
+     */
+    private function shouldRetry($task_data)
+    {
+        $retry_count = $task_data['retry_count'] ?? 0;
+        return $retry_count < self::MAX_RETRY_ATTEMPTS;
+    }
+
+    /**
+     * Clean up task files for a given task ID
+     *
+     * @param string $task_id Task identifier
+     * @param string $task_dir Task directory path
+     */
+    private function cleanupTask($task_id, $task_dir)
+    {
+        $task_file = $task_dir . "/" . $task_id . ".txt";
+        $status_file = $task_dir . "/" . $task_id . ".status";
+
+        if (file_exists($task_file)) {
+            unlink($task_file);
+        }
+        if (file_exists($status_file)) {
+            unlink($status_file);
+        }
+    }
+
+    /**
+     * Handle permanently failed tasks that have exceeded max retry attempts
+     *
+     * @param array $task_data Task data
+     * @param string $task_dir Task directory path
+     */
+    private function handlePermanentFailure($task_data, $task_dir)
+    {
+        $task_id = $task_data['task_id'];
+        $audio_path = $task_data['audio_path'] ?? '';
+        $filename = $task_data['filename'] ?? '';
+
+        // Update transcript file to show permanent failure
+        if (!empty($audio_path) && file_exists($audio_path)) {
+            $transcript_path = dirname($audio_path) . "/" .
+                pathinfo($filename, PATHINFO_FILENAME) . ".txt";
+
+            $error_message = "Transcription failed permanently after " .
+                           ($task_data['retry_count'] ?? 0) . " attempts. " .
+                           "The audio format may not be supported or the " .
+                           "file may be corrupted.";
+
+            file_put_contents($transcript_path, $error_message);
+            L\crawlLog("AudioTranscriptionJob: Updated transcript file with " .
+                "error message: $transcript_path");
+        }
+
+        // Update status to permanently failed
+        $status_file = $task_dir . "/" . $task_id . ".status";
+        file_put_contents($status_file, 'permanently_failed');
+
+        // Clean up task file but keep status for debugging
+        $task_file = $task_dir . "/" . $task_id . ".txt";
+        if (file_exists($task_file)) {
+            unlink($task_file);
+        }
+
+        L\crawlLog("AudioTranscriptionJob: Marked task $task_id as " .
+            "permanently failed");
+    }
+
+    /**
+     * Convert unsupported audio formats to supported ones using ffmpeg
+     *
+     * @param string $input_path Path to input audio file
+     * @param string $output_path Path for converted output file
+     * @return bool True if conversion succeeded
+     */
+    private function convertAudioFormat($input_path, $output_path)
+    {
+        // Check if ffmpeg is available
+        if (!C\nsdefined('FFMPEG')) {
+            L\crawlLog("AudioTranscriptionJob: FFMPEG not configured, " .
+                "cannot convert audio format");
+            return false;
+        }
+
+        $ffmpeg_cmd = C\FFMPEG;
+        $command = sprintf(
+            "%s -i %s -acodec pcm_s16le -ar 16000 %s 2>&1",
+            escapeshellcmd($ffmpeg_cmd),
+            escapeshellarg($input_path),
+            escapeshellarg($output_path)
+        );
+
+        L\crawlLog("AudioTranscriptionJob: Converting audio format: $command");
+
+        exec($command, $output, $return_var);
+
+        if ($return_var === 0 && file_exists($output_path)) {
+            L\crawlLog("AudioTranscriptionJob: Successfully converted " .
+                "audio format");
+            return true;
+        } else {
+            L\crawlLog("AudioTranscriptionJob: Audio conversion failed: " .
+                implode("\n", $output));
+            return false;
+        }
+    }
+
+    /**
+     * Check if audio file format is supported by Whisper
+     *
+     * @param string $filename Audio filename
+     * @return bool True if format is supported
+     */
+    private function isSupportedAudioFormat($filename)
+    {
+        $supported_extensions = [
+            'mp3', 'wav', 'flac', 'm4a', 'ogg', 'wma', 'aac'
+        ];
+
+        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
+        return in_array($extension, $supported_extensions);
+    }
+}
\ No newline at end of file
diff --git a/src/models/GroupModel.php b/src/models/GroupModel.php
index 479136305..d78733777 100644
--- a/src/models/GroupModel.php
+++ b/src/models/GroupModel.php
@@ -2750,12 +2750,29 @@ class GroupModel extends Model implements MediaConstants
                 'audio/ogg', 'audio/opus',
                 'audio/vorbis', 'audio/vnd.rn-realaudio', 'audio/vnd.wave',
                 'audio/webm'])) {
-                $replace_string = "<audio controls='controls' $autoplay".
-                    " class='audio' id='".
-                    L\crawlHash($resource_name)."' >\n".
-                    "<source src='$resource_url'  type='$mime_type' >\n".
+                $audio_id = L\crawlHash($resource_name);
+                $replace_string = "<div class='audio-message-container'>\n".
+                    "<div class='audio-player-wrapper'>\n".
+                    "<audio controls='controls' $autoplay class='audio' " .
+                        "id='$audio_id'>\n".
+                    "<source src='$resource_url' type='$mime_type'>\n".
                     $resource_description."\n".
-                    "</audio>";
+                    "</audio>\n";
+
+                if (C\nsdefined('WHISPER')) {
+                    $replace_string .= "<button onclick='transcribeAudio(\"" .
+                        addslashes($resource_name) . "\", \"" .
+                        addslashes($current_folder) . "\", \"" .
+                        addslashes($csrf_token) . "\")' " .
+                        "class='transcribe-btn' title='Show transcript'>" .
+                            "📝</button>\n";
+                }
+
+                $replace_string .= "</div>\n".
+                    "<div class='transcript-container' " .
+                        "id='transcript-$audio_id'></div>\n".
+                    "</div>";
+
                 $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                     .'/u', $replace_string, $parsed_page);
             } else if ($mime_type == 'application/epub+zip' &&
@@ -3993,6 +4010,55 @@ EOD;
                 }
             }
         }
+
+        if (C\nsdefined('WHISPER') && in_array($mime_type, [
+            'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg',
+            'audio/mp4', 'audio/webm', 'audio/m4a', 'audio/aiff',
+            'audio/basic', 'audio/L24'])) {
+
+            $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+            if (!file_exists($task_dir)) {
+                if (!mkdir($task_dir, 0777, true)) {
+                    L\crawlLog("Failed to create audio transcription " .
+                        "task directory");
+                    return;
+                }
+            }
+            $task_id = L\crawlHash($file_name . $group_id . $page_id . time());
+            $task_file = $task_dir . "/" . $task_id . ".txt";
+            $status_file = $task_dir . "/" . $task_id . ".status";
+
+            $task_data = [
+                'audio_path' => "$folder/$file_name",
+                'filename' => $file_name,
+                'task_id' => $task_id,
+                'group_id' => $group_id,
+                'page_id' => $page_id,
+                'sub_path' => $sub_path,
+                'created_at' => time(),
+                'status' => 'pending',
+                'retry_count' => 0
+            ];
+
+            if (file_put_contents($task_file, serialize($task_data)) !==
+                false && file_put_contents($status_file, 'pending') !== false) {
+                chmod($task_file, 0644);
+                chmod($status_file, 0644);
+
+                L\crawlLog("AudioTranscription: Created task for $file_name " .
+                          "(ID: $task_id)");
+
+                $transcript_path = "$folder/" .
+                    pathinfo($file_name, PATHINFO_FILENAME) . ".txt";
+                file_put_contents($transcript_path,
+                    "Transcription pending...");
+                $vcs->createVersion($transcript_path, "", $timestamp);
+
+            } else {
+                L\crawlLog("AudioTranscription: Failed to create task files " .
+                    "for $file_name");
+            }
+        }
     }
     /**
      * Creates a symlink between two resource folders (and their associate
diff --git a/src/scripts/basic.js b/src/scripts/basic.js
index 9101f8c3a..7c88fcfad 100755
--- a/src/scripts/basic.js
+++ b/src/scripts/basic.js
@@ -1090,3 +1090,149 @@ function stopRecording()
         media_recorder.stream.getTracks().forEach(track => track.stop());
     }
 }
+
+/**
+ * Creates a hash string from input text
+ * Used for generating unique IDs for DOM elements
+ * @param {string} text - Text to hash
+ * @return {string} Hashed string
+ */
+function crawlHash(text) {
+    let hash = 0;
+    if (text.length === 0) return hash.toString();
+
+    for (let i = 0; i < text.length; i++) {
+        const char = text.charCodeAt(i);
+        hash = ((hash << 5) - hash) + char;
+        hash = hash & hash; // Convert to 32-bit integer
+    }
+
+    // Convert to positive hex string and ensure it's lowercase
+    return Math.abs(hash).toString(16).toLowerCase();
+}
+
+/**
+ * Handles displaying transcripts for audio messages
+ * Fetches transcript from server if available, otherwise shows placeholder
+ * @param {string} filename - Name of the audio file
+ * @param {string} folder - Folder path containing the audio file
+ * @param {string} csrf_token - CSRF token for API requests
+ */
+function transcribeAudio(filename, folder, csrf_token) {
+    let audio_element = document.querySelector(
+        `audio source[src*="${filename}"]`)?.parentElement;
+
+    if (!audio_element) {
+        const audio_elements = document.querySelectorAll('audio');
+        for (let audio of audio_elements) {
+            const source = audio.querySelector('source');
+            if (source && source.src.includes(filename)) {
+                audio_element = audio;
+                break;
+            }
+        }
+    }
+
+    if (!audio_element) {
+        console.error('Could not find audio element for:', filename);
+        return;
+    }
+
+    const transcript_container = document.getElementById(
+        'transcript-' + audio_element.id);
+    if (!transcript_container) {
+        console.error('Could not find transcript container for:',
+            audio_element.id);
+        return;
+    }
+
+    if (transcript_container.classList.contains('show')) {
+        transcript_container.classList.remove('show');
+        return;
+    }
+
+    transcript_container.classList.add('show', 'loading');
+    transcript_container.textContent = 'Loading transcript...';
+
+    const audio_source = audio_element.querySelector('source');
+    if (!audio_source) {
+        console.error('Could not find audio source');
+        transcript_container.textContent =
+            'Error: Could not find audio source.';
+        transcript_container.className = 'transcript-container show error';
+        return;
+    }
+
+    const base_url = audio_source.src.replace(
+        /\.(mp3|wav|ogg|m4a|webm|mp4|aiff|basic|L24)$/i, '');
+    const transcript_url = base_url + '.txt';
+
+    fetch(transcript_url, {
+        method: 'GET',
+        headers: {
+            'Cache-Control': 'no-cache'
+        }
+    })
+    .then(response => {
+        if (!response.ok) {
+            throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+        }
+        return response.text();
+    })
+    .then(transcript => {
+        const trimmed_transcript = transcript.trim();
+
+        if (!trimmed_transcript) {
+            throw new Error('Transcript is empty');
+        }
+
+        if (trimmed_transcript === 'Transcription pending...' ||
+            trimmed_transcript === 'Transcription in progress...') {
+            transcript_container.textContent =
+                'Transcription is being processed. Please try again in ' +
+                'a few minutes.';
+            transcript_container.className =
+                'transcript-container show loading';
+            return;
+        }
+
+        if (trimmed_transcript.includes('Transcription failed permanently') ||
+            trimmed_transcript.includes('failed permanently after')) {
+            transcript_container.textContent = trimmed_transcript;
+            transcript_container.className = 'transcript-container show error';
+            return;
+        }
+
+        if (transcript.includes('\0') || transcript.length > 50000) {
+            throw new Error('Invalid transcript content');
+        }
+
+        const cleaned_transcript = trimmed_transcript
+            .replace(/\s+/g, ' ')
+            .replace(/\n{3,}/g, '\n\n')
+            .trim();
+
+        transcript_container.textContent = cleaned_transcript;
+        transcript_container.className = 'transcript-container show';
+    })
+    .catch(error => {
+        let error_message = 'Transcript not available yet.';
+
+        if (error.message.includes('404')) {
+            error_message =
+                'Transcript not found. Processing may still be in progress.';
+        } else if (error.message.includes('403') ||
+            error.message.includes('401')) {
+            error_message = 'Access denied to transcript file.';
+        } else if (error.message.includes('500')) {
+            error_message = 'Server error while fetching transcript.';
+        } else if (error.message.includes('network') ||
+            error.message.includes('fetch')) {
+            error_message = 'Network error. Please check your connection.';
+        }
+
+        transcript_container.textContent = error_message +
+            ' Please try again later.';
+        transcript_container.className = 'transcript-container show error';
+    });
+}
