Files
AndyTranscribe/app/Models/Recording.php
T
ben 5cea5192c2 Add transcription progress handling and local Whisper integration.
Implement a new method for calculating elapsed transcription time in the Recording model. Enhance the TranscriptionService to handle local Whisper requests, including asynchronous processing and progress reporting. Update the recordings index view to display transcription progress percentage. Add unit tests for the new transcription service functionality.
2026-08-13 12:12:10 +02:00

609 lines
19 KiB
PHP

<?php
namespace App\Models;
use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class Recording extends Model
{
/**
* @var list<string>
*/
protected $fillable = [
'user_id',
'title',
'original_filename',
'file_path',
'duration_seconds',
'recorded_at',
'artist',
'album',
'file_size_bytes',
'content_hash',
'transcript',
'transcription_status',
'transcription_progress',
'transcription_percent',
'transcription_started_at',
'transcription_error',
'transcription_driver',
'ollama_url',
'transcribed_at',
'transcription_duration_seconds',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'recorded_at' => 'datetime',
'transcribed_at' => 'datetime',
'transcription_started_at' => 'datetime',
'duration_seconds' => 'integer',
'transcription_duration_seconds' => 'integer',
'file_size_bytes' => 'integer',
'transcription_percent' => 'integer',
'user_id' => 'integer',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Human-readable duration (m:ss).
*/
protected function durationFormatted(): Attribute
{
return Attribute::get(function (): string {
if ($this->duration_seconds === null) {
return '—';
}
$minutes = intdiv($this->duration_seconds, 60);
$seconds = $this->duration_seconds % 60;
return sprintf('%d:%02d', $minutes, $seconds);
});
}
/**
* Word count of the stored transcript (0 when empty).
*/
protected function wordCount(): Attribute
{
return Attribute::get(function (): int {
if (! filled($this->transcript)) {
return 0;
}
return count(preg_split('/\s+/u', trim($this->transcript), -1, PREG_SPLIT_NO_EMPTY) ?: []);
});
}
/**
* Friendly label for the selected transcription engine.
*/
protected function transcriptionDriverLabel(): Attribute
{
return Attribute::get(function (): ?string {
return match ($this->transcription_driver) {
'local' => 'Local (faster-whisper)',
default => $this->transcription_driver ?: 'Local (faster-whisper)',
};
});
}
/**
* Whether transcription is actively running or queued.
*/
public function isTranscribing(): bool
{
return in_array($this->transcription_status, ['pending', 'processing'], true);
}
/**
* Queue a new local faster-whisper transcription run.
*/
public function queueLocalTranscription(): void
{
// Only stop a real in-flight/queued run — bare "pending" uploads have no job yet.
if ($this->transcription_status === 'processing' || $this->hasActiveTranscriptionJob()) {
$this->cancelTranscription(silent: true);
$this->refresh();
}
$this->update([
'transcription_driver' => 'local',
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_started_at' => now(),
'transcription_error' => null,
'transcription_duration_seconds' => null,
// Keep the previous transcript until a new run succeeds.
'transcribed_at' => $this->transcribed_at,
]);
$recording = $this->fresh();
RecordingTranscriptionUpdated::dispatch($recording);
TranscribeRecording::dispatch($recording);
}
/**
* Human-readable transcription status for badges.
*/
public function transcriptionStatusLabel(): string
{
return match ($this->transcription_status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $this->transcription_status,
};
}
/**
* Seconds since the current transcription run started.
*/
public function transcriptionElapsedSeconds(): ?int
{
if ($this->transcription_started_at === null) {
return null;
}
return max(0, now()->getTimestamp() - $this->transcription_started_at->getTimestamp());
}
/**
* Search title, metadata, and stored transcript text.
*/
#[Scope]
protected function search(Builder $query, string $term): void
{
$like = '%'.$term.'%';
$query->where(function (Builder $builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('artist', 'like', $like)
->orWhere('album', 'like', $like)
->orWhere('original_filename', 'like', $like)
->orWhere('transcript', 'like', $like);
});
}
/**
* First line of the transcript, truncated for compact list rows.
*/
public function transcriptFirstLine(int $limit = 120): ?string
{
if (! filled($this->transcript)) {
return null;
}
$firstLine = Str::of($this->transcript)
->before("\n")
->replaceMatches('/\s+/', ' ')
->trim()
->toString();
if ($firstLine === '') {
return null;
}
return Str::limit($firstLine, $limit);
}
/**
* Short transcript excerpt, optionally centered on a search hit.
*/
public function transcriptSnippet(?string $term = null, int $radius = 80): ?string
{
if (! filled($this->transcript)) {
return null;
}
$transcript = preg_replace('/\s+/', ' ', $this->transcript) ?? $this->transcript;
if ($term === null || $term === '') {
return Str::limit($transcript, $radius * 2);
}
$position = mb_stripos($transcript, $term);
if ($position === false) {
return Str::limit($transcript, $radius * 2);
}
$start = max(0, $position - $radius);
$excerpt = mb_substr($transcript, $start, ($radius * 2) + mb_strlen($term));
return ($start > 0 ? '…' : '').$excerpt.(mb_strlen($transcript) > $start + mb_strlen($excerpt) ? '…' : '');
}
/**
* Seconds after which a reserved queue row is considered abandoned
* (worker died mid-Whisper without releasing the job).
*/
public function transcriptionJobStaleAfterSeconds(): int
{
return max(120, (int) config('ai.transcription_timeout', 600) + 90);
}
/**
* Whether a TranscribeRecording job for this recording is still on the queue.
*
* Reserved jobs older than the transcription timeout (+ grace) are ignored so
* orphan recovery can unblock the UI when Whisper/the worker is wedged.
*/
public function hasActiveTranscriptionJob(): bool
{
$staleBefore = now()->timestamp - $this->transcriptionJobStaleAfterSeconds();
return DB::table('jobs')
->orderBy('id')
->get(['id', 'payload', 'reserved_at'])
->contains(function (object $job) use ($staleBefore): bool {
$payload = (string) $job->payload;
if (! str_contains($payload, 'TranscribeRecording')) {
return false;
}
if ($job->reserved_at !== null && (int) $job->reserved_at <= $staleBefore) {
return false;
}
return $this->jobPayloadBelongsToRecording($payload);
});
}
/**
* Fallback payload match when unserialize is unavailable.
*/
private function payloadMentionsRecording(string $payload): bool
{
// Jobs table stores JSON; the serialized command inside escapes quotes as \".
return (bool) preg_match('/id\\\\";i:'.$this->id.';/', $payload)
|| (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|| str_contains($payload, 'id\\\\";s:'.strlen((string) $this->id).':\\"'.$this->id.'\\"');
}
/**
* Processing/pending with no worker job left (crashed worker, bad retry_after, etc.).
*/
public function isOrphanedTranscription(): bool
{
if (! $this->isTranscribing()) {
return false;
}
if ($this->hasActiveTranscriptionJob()) {
return false;
}
$reference = $this->transcription_started_at ?? $this->updated_at;
if ($reference === null) {
return true;
}
// Only treat as orphaned after the job could not possibly still be running.
// (A short grace caused false failures while Whisper was still working.)
$orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60);
return $reference->lte(now()->subSeconds($orphanAfterSeconds));
}
/**
* Whether a payload/job belongs to this recording's transcription run start time.
*/
public function matchesTranscriptionRun(?string $runStartedAt): bool
{
if ($runStartedAt === null || $this->transcription_started_at === null) {
return false;
}
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
}
/**
* Whether this recording still expects results for the given run.
*/
public function ownsTranscriptionRun(?string $runStartedAt): bool
{
$this->refresh();
if (! $this->isTranscribing()) {
return false;
}
return $this->matchesTranscriptionRun($runStartedAt);
}
/**
* Remove queued TranscribeRecording jobs for this recording.
*/
public function discardQueuedTranscriptionJobs(): int
{
$deleted = 0;
DB::table('jobs')
->orderBy('id')
->get()
->each(function (object $job) use (&$deleted): void {
$payload = (string) $job->payload;
if (! str_contains($payload, 'TranscribeRecording')) {
return;
}
if (! $this->jobPayloadBelongsToRecording($payload)) {
return;
}
DB::table('jobs')->where('id', $job->id)->delete();
$deleted++;
});
$this->releaseTranscriptionUniqueLock();
return $deleted;
}
/**
* Whether a jobs.payload row targets this recording.
*/
private function jobPayloadBelongsToRecording(string $payload): bool
{
$data = json_decode($payload, true);
$command = $data['data']['command'] ?? null;
if (is_string($command)) {
if (
preg_match('/id";i:'.$this->id.';/', $command)
|| str_contains($command, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
) {
return true;
}
try {
$queued = unserialize($command);
if ($queued instanceof TranscribeRecording) {
return (int) $queued->recording->getKey() === (int) $this->id;
}
} catch (Throwable) {
// Fall through to escaped JSON heuristics.
}
}
return $this->payloadMentionsRecording($payload);
}
/**
* Stop transcription: drop queued jobs and mark the run cancelled.
*
* @param bool $silent When true, skip status update (used before starting a replacement run).
*/
public function cancelTranscription(bool $silent = false): void
{
$this->discardQueuedTranscriptionJobs();
if ($silent) {
return;
}
$this->forceFill([
'transcription_status' => 'cancelled',
'transcription_progress' => 'Stopped by user',
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => 'Stopped by user',
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
* Release a leftover ShouldBeUnique lock from earlier job versions.
*/
public function releaseTranscriptionUniqueLock(): void
{
Cache::lock(
'laravel_unique_job:'.TranscribeRecording::class.'transcribe-recording:'.$this->id
)->forceRelease();
}
/**
* Persist a successful transcript and how long the run took.
*/
public function markTranscriptionComplete(string $text): void
{
$finishedAt = now();
$startedAt = $this->transcription_started_at;
$durationSeconds = $startedAt === null
? null
: max(0, $finishedAt->getTimestamp() - $startedAt->getTimestamp());
$this->forceFill([
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => $finishedAt,
'transcription_duration_seconds' => $durationSeconds,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
* Mark transcription as failed and unblock the UI.
*/
public function markTranscriptionFailed(string $message): void
{
if (! $this->isTranscribing()) {
return;
}
$this->forceFill([
'transcription_status' => 'failed',
'transcription_progress' => 'Transcription failed',
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => $message,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
* Recover a stuck transcription if the queue job is gone or a reservation is stale.
*/
public function recoverOrphanedTranscription(): bool
{
if (! $this->isOrphanedTranscription()) {
return false;
}
// Drop abandoned reserved rows so a restart can enqueue cleanly.
$this->discardQueuedTranscriptionJobs();
$this->markTranscriptionFailed(
'Transcription timed out or the worker stopped before finishing. Start transcription again.',
);
return true;
}
/**
* Update the live progress fields shown in the UI.
*/
public function reportProgress(string $message, int $percent, string $status = 'processing'): void
{
$this->forceFill([
'transcription_status' => $status,
'transcription_progress' => $message,
'transcription_percent' => max(0, min(100, $percent)),
'transcription_error' => null,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
}
/**
* Broadcast the current transcription status to connected browsers.
*/
public function broadcastTranscriptionUpdated(): void
{
RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this);
}
/**
* Absolute filesystem path for the stored audio file.
*/
public function absolutePath(): string
{
return Storage::disk('local')->path($this->file_path);
}
/**
* MIME type for browser audio playback based on the original filename.
*/
public function audioMimeType(): string
{
$extension = strtolower(pathinfo((string) $this->original_filename, PATHINFO_EXTENSION));
return match ($extension) {
'mp3', 'mpga', 'mpeg' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg', 'oga' => 'audio/ogg',
'flac' => 'audio/flac',
'm4a', 'mp4', 'aac' => 'audio/mp4',
'webm' => 'audio/webm',
'wma' => 'audio/x-ms-wma',
'aiff', 'aif' => 'audio/aiff',
default => 'application/octet-stream',
};
}
/**
* Delete the audio file from storage.
*/
public function deleteFile(): void
{
if ($this->file_path && Storage::disk('local')->exists($this->file_path)) {
Storage::disk('local')->delete($this->file_path);
}
}
/**
* Payload for the live status endpoint / Alpine poller.
*
* @return array<string, mixed>
*/
public function transcriptionStatusPayload(): array
{
$startedAt = $this->transcription_started_at;
$elapsed = $this->transcriptionElapsedSeconds();
return [
'id' => $this->id,
'status' => $this->transcription_status,
'status_label' => $this->transcriptionStatusLabel(),
'progress' => $this->transcription_progress,
'percent' => $this->transcription_percent,
'driver' => $this->transcription_driver,
'driver_label' => $this->transcription_driver_label,
'error' => $this->transcription_error,
'started_at' => $startedAt?->toIso8601String(),
'elapsed_seconds' => $elapsed,
'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed),
'duration_seconds' => $this->duration_seconds,
'transcription_duration_seconds' => $this->transcription_duration_seconds,
'transcription_duration_human' => $this->transcription_duration_seconds === null
? null
: $this->formatElapsed($this->transcription_duration_seconds),
'is_active' => $this->isTranscribing(),
'has_transcript' => filled($this->transcript),
'transcript' => $this->transcript,
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
];
}
private function formatElapsed(int $seconds): string
{
$minutes = intdiv($seconds, 60);
$remain = $seconds % 60;
if ($minutes === 0) {
return sprintf('%ds', $remain);
}
return sprintf('%dm %02ds', $minutes, $remain);
}
}