Consume faster-whisper SSE instead of blocking on one JSON response, persist growing text and progress over Reverb, and drop the fake percent heartbeat.
184 lines
5.8 KiB
PHP
184 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Recording;
|
|
use App\Services\TranscriptionService;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Queue\Queueable;
|
|
use Illuminate\Queue\Attributes\FailOnTimeout;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
#[FailOnTimeout]
|
|
class TranscribeRecording implements ShouldQueue
|
|
{
|
|
use Queueable;
|
|
|
|
/**
|
|
* The number of times the job may be attempted.
|
|
*/
|
|
public int $tries = 1;
|
|
|
|
/**
|
|
* The number of seconds the job can run before timing out.
|
|
*
|
|
* Covers a hung Whisper HTTP call: the worker is killed, failed() runs,
|
|
* and the recording is marked failed so the UI can restart.
|
|
*/
|
|
public int $timeout;
|
|
|
|
/**
|
|
* ISO-8601 transcription_started_at this job owns (ignored after cancel/restart).
|
|
*/
|
|
public ?string $runStartedAt = null;
|
|
|
|
/**
|
|
* Create a new job instance.
|
|
*/
|
|
public function __construct(public Recording $recording)
|
|
{
|
|
$this->timeout = max(60, (int) config('ai.transcription_timeout', 600));
|
|
$this->runStartedAt = $recording->transcription_started_at?->toIso8601String();
|
|
}
|
|
|
|
/**
|
|
* Execute the job.
|
|
*/
|
|
public function handle(TranscriptionService $transcription): void
|
|
{
|
|
$recording = Recording::query()->find($this->recording->id);
|
|
|
|
if ($recording === null) {
|
|
return;
|
|
}
|
|
|
|
$this->recording = $recording;
|
|
|
|
if ($this->runStartedAt !== null && ! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
|
return;
|
|
}
|
|
|
|
if ($this->runStartedAt === null && ! $this->recording->isTranscribing()) {
|
|
return;
|
|
}
|
|
|
|
$this->recording->forceFill([
|
|
'transcription_status' => 'processing',
|
|
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
|
|
'transcription_error' => null,
|
|
])->save();
|
|
|
|
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
|
|
|
|
$this->reportIfOwned('Preparing audio file…', 15);
|
|
|
|
try {
|
|
$text = $transcription->transcribe(
|
|
$this->recording,
|
|
function (string $message, int $percent, ?string $partialTranscript = null): void {
|
|
$this->reportIfOwned($message, $percent, $partialTranscript);
|
|
},
|
|
fn (): bool => Recording::query()->find($this->recording->id)
|
|
?->ownsTranscriptionRun($this->runStartedAt) ?? false,
|
|
);
|
|
|
|
if (! $this->claimSuccessfulTranscript($text)) {
|
|
return;
|
|
}
|
|
} catch (Throwable $e) {
|
|
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
|
Log::warning('Transcription exception ignored after run was superseded', [
|
|
'recording_id' => $this->recording->id,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
Log::error('Transcription failed', [
|
|
'recording_id' => $this->recording->id,
|
|
'driver' => $this->recording->transcription_driver,
|
|
'message' => $e->getMessage(),
|
|
'exception' => $e::class,
|
|
]);
|
|
|
|
$this->recording->markTranscriptionFailed($e->getMessage());
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a job failure (timeouts, worker kill, etc.).
|
|
*/
|
|
public function failed(?Throwable $e): void
|
|
{
|
|
$recording = Recording::query()->find($this->recording->id);
|
|
|
|
if ($recording === null || ! $recording->ownsTranscriptionRun($this->runStartedAt)) {
|
|
return;
|
|
}
|
|
|
|
$recording->markTranscriptionFailed(
|
|
$e?->getMessage() ?: 'Transcription stopped unexpectedly.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Persist a finished transcript when this job still owns the run.
|
|
*
|
|
* Also recovers runs that were falsely marked failed by orphan detection
|
|
* while Whisper was still working.
|
|
*/
|
|
private function claimSuccessfulTranscript(string $text): bool
|
|
{
|
|
$this->recording->refresh();
|
|
|
|
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
|
$this->reportIfOwned('Saving transcript…', 90);
|
|
$this->recording->refresh();
|
|
}
|
|
|
|
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
|
$this->recording->markTranscriptionComplete($text);
|
|
|
|
return true;
|
|
}
|
|
|
|
// Same run was wrongly marked failed as "orphaned" while Whisper was still running.
|
|
if (
|
|
$this->recording->transcription_status === 'failed'
|
|
&& $this->recording->matchesTranscriptionRun($this->runStartedAt)
|
|
&& (
|
|
str_contains((string) $this->recording->transcription_error, 'worker stopped')
|
|
|| str_contains((string) $this->recording->transcription_error, 'timed out')
|
|
)
|
|
) {
|
|
Log::warning('Recovering transcript after false orphan failure', [
|
|
'recording_id' => $this->recording->id,
|
|
]);
|
|
|
|
$this->recording->markTranscriptionComplete($text);
|
|
|
|
return true;
|
|
}
|
|
|
|
Log::info('Discarding transcript because transcription run was superseded', [
|
|
'recording_id' => $this->recording->id,
|
|
'status' => $this->recording->transcription_status,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
private function reportIfOwned(string $message, int $percent, ?string $partialTranscript = null): void
|
|
{
|
|
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
|
return;
|
|
}
|
|
|
|
$this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript);
|
|
}
|
|
}
|