<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 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 - 2026
* @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.m4a";
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";
$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
];
}
}
} 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 -c:a aac -b:a 128k %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', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav',
'webm', 'flac', 'ogg', 'oga' ];
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($extension, $supported_extensions);
}
}