_binary From 978c1a2fcf85faa453e1e40cb50b3f58d80b7de8 Mon Sep 17 00:00:00 2001
From: Aditya <thesyn3rman@gmail.com>
Date: Mon, 21 Apr 2025 23:40:36 -0700
Subject: [PATCH] audio transcripts sync

Signed-off-by: Aditya <thesyn3rman@gmail.com>
---
 src/controllers/ApiController.php             |  89 +++++++++++
 src/css/messages.css                          |  68 ++++++++
 .../media_jobs/AudioTranscriptionJob.php      | 147 ++++++++++++++++++
 src/models/GroupModel.php                     |  65 +++++---
 src/scripts/basic.js                          |  99 ++++++++++++
 5 files changed, 450 insertions(+), 18 deletions(-)
 create mode 100644 src/library/media_jobs/AudioTranscriptionJob.php

diff --git a/src/controllers/ApiController.php b/src/controllers/ApiController.php
index a9a20a63a..5eec91c0d 100644
--- a/src/controllers/ApiController.php
+++ b/src/controllers/ApiController.php
@@ -117,4 +117,93 @@ class ApiController extends Controller implements CrawlConstants
         }
         return $data;
     }
+
+    /**
+     * Handles request to transcribe an audio file
+     * @return array Response with success status and any error messages
+     */
+    public function transcribe()
+    {
+        $data = [];
+        if (!$this->checkCSRFToken()) {
+            $data['success'] = false;
+            $data['errors'] = ['Invalid CSRF token'];
+            return $data;
+        }
+
+        $json = file_get_contents('php://input');
+        $task_data = json_decode($json, true);
+
+        if (!$task_data) {
+            $data['success'] = false;
+            $data['errors'] = ['Invalid task data'];
+            return $data;
+        }
+
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        if (!file_exists($task_dir)) {
+            mkdir($task_dir, 0777, true);
+        }
+
+        $task_file = $task_dir . "/" . $task_data['task_id'] . ".txt";
+        $status_file = $task_dir . "/" . $task_data['task_id'] . ".status";
+
+        if (file_put_contents($task_file, serialize($task_data)) === false ||
+            file_put_contents($status_file, 'pending') === false) {
+            $data['success'] = false;
+            $data['errors'] = ['Failed to create task files'];
+            return $data;
+        }
+
+        $data['success'] = true;
+        return $data;
+    }
+
+    /**
+     * Checks the status of a transcription task
+     * @return array Response with task status and transcript if available
+     */
+    public function transcriptionStatus()
+    {
+        $data = [];
+        if (!$this->checkCSRFToken()) {
+            $data['success'] = false;
+            $data['errors'] = ['Invalid CSRF token'];
+            return $data;
+        }
+
+        $task_id = $this->clean($_REQUEST['task_id'], "string");
+        if (!$task_id) {
+            $data['success'] = false;
+            $data['errors'] = ['Invalid task ID'];
+            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) || !file_exists($task_file)) {
+            $data['success'] = false;
+            $data['errors'] = ['Task not found'];
+            return $data;
+        }
+
+        $status = file_get_contents($status_file);
+        $task_data = unserialize(file_get_contents($task_file));
+
+        $data['status'] = $status;
+        if ($status === 'completed') {
+            $transcript_path = dirname($task_data['audio_path']) . "/" .
+                pathinfo($task_data['filename'], PATHINFO_FILENAME) . ".txt";
+            if (file_exists($transcript_path)) {
+                $data['transcript'] = file_get_contents($transcript_path);
+            } else {
+                $data['status'] = 'failed';
+            }
+        }
+
+        $data['success'] = true;
+        return $data;
+    }
 }
diff --git a/src/css/messages.css b/src/css/messages.css
index b6fea5a0b..f94ae30bd 100644
--- a/src/css/messages.css
+++ b/src/css/messages.css
@@ -234,6 +234,7 @@
     padding: 7px;
     margin: 5px .5in 5px .5in;
     width: fit-content;
+    max-width: 100%;
 }
 .html-ltr .message-detail
 {
@@ -500,3 +501,70 @@
     background-color: #EEF !important;
     color: red;
 }
+.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: 0;
+    left: 0;
+    background-color: rgba(255, 255, 255, 0.9);
+    border: none;
+    border-radius: 50%;
+    color: #000;
+    cursor: pointer;
+    font-size: 12px;
+    padding: 3px;
+    height: 20px;
+    width: 20px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    transition: all 0.2s;
+    z-index: 1;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+.transcribe-btn:hover {
+    background-color: #fff;
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
+}
+.transcript-container {
+    display: none;
+    padding: 4px 8px;
+    background-color: transparent;
+    border: none;
+    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;
+}
+.transcript-container.show {
+    display: block;
+}
+/* Remove extra spaces between words */
+.transcript-container::after {
+    content: "";
+    white-space: normal;
+}
diff --git a/src/library/media_jobs/AudioTranscriptionJob.php b/src/library/media_jobs/AudioTranscriptionJob.php
new file mode 100644
index 000000000..516388774
--- /dev/null
+++ b/src/library/media_jobs/AudioTranscriptionJob.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * SeekQuarry/Yioop --
+ * Open Source Pure PHP Search Engine, Crawler, and Indexer
+ *
+ * Copyright (C) 2009 - 2025  Aditya Prajapati aditya@jkprajapati.tech
+ *
+ * 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
+ * @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
+ * Uses Whisper for speech recognition
+ */
+class AudioTranscriptionJob extends MediaJob
+{
+    /**
+     * Initialize the job
+     */
+    public function init()
+    {
+        $this->name_server_does_client_tasks = true;
+        $this->name_server_does_client_tasks_only = true;
+    }
+    /**
+     * Handle nondistributed mode by directly processing tasks
+     */
+    public function nondistributedTasks()
+    {
+        L\crawlLog("Running AudioTranscriptionJob in nondistributed mode");
+        if ($this->checkPrerequisites()) {
+            $this->prepareTasks();
+            if (!empty($this->tasks)) {
+                foreach ($this->tasks as $task) {
+                    $this->doTasks($task);
+                }
+            }
+        }
+    }
+    /**
+     * Check if there are any audio files that need transcription
+     */
+    public function checkPrerequisites()
+    {
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        if (!file_exists($task_dir)) {
+            return false;
+        }
+        $files = glob($task_dir . "/*.txt");
+        return !empty($files);
+    }
+    /**
+     * Prepare tasks for audio transcription
+     * Scans for audio files that need transcription
+     */
+    public function prepareTasks()
+    {
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        if (!file_exists($task_dir)) {
+            return;
+        }
+        $tasks = [];
+        $files = glob($task_dir . "/*.txt");
+        foreach ($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) &&
+                    file_get_contents($status_file) === 'pending') {
+                    $tasks[] = $task_data;
+                }
+            }
+        }
+        $this->tasks = $tasks;
+    }
+    /**
+     * Process audio file and generate transcript
+     * @param array $task Audio file to process
+     * @return array Result of transcription
+     */
+    public function doTasks($task)
+    {
+        L\crawlLog("Starting transcription for: " . $task['filename']);
+        $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+        $status_file = $task_dir . "/" . $task['task_id'] . ".status";
+        $task_file = $task_dir . "/" . $task['task_id'] . ".txt";
+        file_put_contents($status_file, 'processing');
+        $audio_path = $task['audio_path'];
+        $filename = $task['filename'];
+        $transcript_path = dirname($audio_path) . "/" .
+            pathinfo($filename, PATHINFO_FILENAME) . ".txt";
+        $command = "whisper " . escapeshellarg($audio_path) .
+            " --model turbo --output_dir " .
+            escapeshellarg(dirname($transcript_path)) .
+            " --output_format txt 2>&1";
+        exec($command, $output, $return_var);
+        if ($return_var !== 0) {
+            L\crawlLog("Whisper transcription failed: " . implode("\n", $output));
+            file_put_contents($status_file, 'failed');
+            return [
+                'success' => false,
+                'error' => "Failed to transcribe audio",
+                'file' => $filename
+            ];
+        }
+        $whisper_output = dirname($transcript_path) . "/" .
+            pathinfo($audio_path, PATHINFO_FILENAME) . ".txt";
+        if (file_exists($whisper_output)) {
+            rename($whisper_output, $transcript_path);
+        }
+        file_put_contents($status_file, 'completed');
+        unlink($task_file);
+        L\crawlLog("Completed transcription for: " . $filename);
+        return [
+            'success' => true,
+            'file' => $filename,
+            'transcript_path' => $transcript_path
+        ];
+    }
+}
diff --git a/src/models/GroupModel.php b/src/models/GroupModel.php
index 391ec4f5f..1a5f36256 100644
--- a/src/models/GroupModel.php
+++ b/src/models/GroupModel.php
@@ -2740,12 +2740,22 @@ 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='".
+                $replace_string = "<div class='audio-message-container'>\n".
+                    "<div class='audio-player-wrapper'>\n".
+                    "<audio controls='controls' $autoplay class='audio' id='".
                     L\crawlHash($resource_name)."' >\n".
                     "<source src='$resource_url'  >\n".
                     $resource_description."\n".
-                    "</audio>";
+                    "</audio>\n".
+                    "<button onclick='transcribeAudio(\"" .
+                    addslashes($resource_name) . "\", \"" .
+                    addslashes($current_folder) . "\", \"" .
+                    addslashes($csrf_token) . "\")' " .
+                    "class='transcribe-btn'>📝</button>\n".
+                    "</div>\n".
+                    "<div class='transcript-container' id='transcript-" .
+                    L\crawlHash($resource_name) . "'></div>\n".
+                    "</div>";
                 $parsed_page = preg_replace('/'.preg_quote($match_string, '/')
                     .'/u', $replace_string, $parsed_page);
             } else if ($mime_type == 'application/epub+zip' &&
@@ -3982,6 +3992,40 @@ EOD;
                 }
             }
         }
+        // Handle audio files for transcription
+        if (in_array($mime_type, ['audio/mpeg', 'audio/wav', 'audio/ogg',
+            'audio/mp4', 'audio/webm'])) {
+            // Create a placeholder transcript file
+            $transcript_path = "$folder/" . pathinfo($file_name, PATHINFO_FILENAME) . ".txt";
+            file_put_contents($transcript_path, "Transcription in progress...");
+            $vcs->createVersion($transcript_path, "", $timestamp);
+
+            // Create a task file for the background job
+            $task_dir = C\SCHEDULES_DIR . "/audio_transcribe";
+            if (!file_exists($task_dir)) {
+                mkdir($task_dir, 0777, true);
+            }
+
+            // Create a unique task ID
+            $task_id = L\crawlHash($file_name) . 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,
+                'status' => 'pending',
+                'created_at' => time()
+            ];
+
+            // Write task data
+            file_put_contents($task_file, serialize($task_data));
+            file_put_contents($status_file, 'pending');
+
+            // The MediaUpdater will automatically pick up this task
+            // when it runs its next cycle (every 10 seconds)
+        }
     }
     /**
      * Creates a symlink between two resource folders (and their associate
@@ -4437,21 +4481,6 @@ EOD;
         $this->db->copyRecursive($from_folder, $to_folder);
         $this->db->copyRecursive($from_thumb_folder, $to_thumb_folder);
     }
-    /**
-     * Gets all the urls of resources belonging to a particular groups wiki
-     * page.
-     *
-     * @param int $group_id group identifier of group wiki page belongs to
-     * @param int $page_id identifier for page want to get page resources for
-     * @param string $sub_path additional path beneath the default folder used
-     *      for the resource folder
-     * @param bool $create if folder doesn't exist whether to create it or not
-     * @return array (url_prefix - prefix to apply to all urls, thum_prefix
-     *      prefix to apply to a resource name to get its thumb, list of
-     *      resources). Each resource is an pair (name - string
-     *      file name of the resource, has_thumb a boolean as to whether the
-     *      resource has a thumb)
-     */
     public function getGroupPageResourceUrls($group_id, $page_id, $sub_path ="",
         $create = false, $needs_descriptions_format = null)
     {
diff --git a/src/scripts/basic.js b/src/scripts/basic.js
index 9a6e08dca..4a58dfac5 100755
--- a/src/scripts/basic.js
+++ b/src/scripts/basic.js
@@ -1048,3 +1048,102 @@ 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;
+
+    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
+ * @param {string} filename - Name of the audio file
+ * @param {string} folder - Folder path containing the audio file
+ * @param {string} csrfToken - CSRF token for API requests
+ */
+function transcribeAudio(filename, folder, csrfToken) {
+    console.log('Transcribe button clicked with:', { filename, folder, csrfToken });
+
+    // Get the audio element first
+    const audioElement = document.querySelector(`audio source[src*="${filename}"]`)?.parentElement;
+    console.log('Audio element:', audioElement);
+
+    if (!audioElement) {
+        console.error('Could not find audio element');
+        return;
+    }
+
+    // Use the audio element's ID to find the transcript container
+    const transcriptContainer = document.getElementById('transcript-' + audioElement.id);
+    console.log('Transcript container:', transcriptContainer);
+
+    if (!transcriptContainer) {
+        console.error('Could not find transcript container');
+        return;
+    }
+
+    if (transcriptContainer.classList.contains('show')) {
+        transcriptContainer.classList.remove('show');
+        return;
+    }
+
+    transcriptContainer.classList.add('show');
+    transcriptContainer.textContent = 'Loading transcript...';
+
+    const audioSource = audioElement.querySelector('source');
+    console.log('Audio source:', audioSource);
+
+    if (!audioSource) {
+        console.error('Could not find audio source');
+        transcriptContainer.textContent = 'Could not find audio source.';
+        return;
+    }
+
+    // Get the base URL without the file extension
+    const baseUrl = audioSource.src.replace(/\.(mp3|wav|ogg|m4a|webm|mp4)$/, '');
+    const transcriptUrl = baseUrl + '.txt';
+    console.log('Transcript URL:', transcriptUrl);
+
+    // Try to get the transcript
+    fetch(transcriptUrl)
+    .then(response => {
+        console.log('Fetch response:', response);
+        if (!response.ok) throw new Error('Transcript not found');
+        // Check if the response is text
+        const contentType = response.headers.get('content-type');
+        if (!contentType || !contentType.includes('text/plain')) {
+            throw new Error('Invalid transcript format');
+        }
+        return response.text();
+    })
+    .then(transcript => {
+        console.log('Transcript content:', transcript);
+        // Check if the transcript is valid text
+        if (transcript.includes('\0') || transcript.length > 10000) {
+            throw new Error('Invalid transcript content');
+        }
+        // Normalize spaces and line breaks
+        const normalizedText = transcript
+            .replace(/\s+/g, ' ') // Replace multiple spaces/tabs/newlines with a single space
+            .trim(); // Remove leading/trailing whitespace
+        transcriptContainer.textContent = normalizedText;
+    })
+    .catch(error => {
+        console.error('Error fetching transcript:', error);
+        transcriptContainer.textContent = 'Transcript not available yet. Please try again later.';
+    });
+}
-- 
2.39.5 (Apple Git-154)

