*/ protected $fillable = [ 'user_id', 'title', 'original_filename', 'file_path', 'duration_seconds', 'recorded_at', 'artist', 'album', 'file_size_bytes', 'content_hash', 'transcript', 'transcription_verbose', 'transcription_status', 'transcription_progress', 'transcription_percent', 'transcription_started_at', 'transcription_error', 'transcription_driver', 'ollama_url', 'transcribed_at', 'transcription_duration_seconds', ]; /** * @return array */ 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', 'transcription_verbose' => 'array', 'user_id' => 'integer', ]; } /** * @return BelongsTo */ 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(); $this->broadcastTranscriptionUpdated(); 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(); $this->broadcastTranscriptionUpdated(); } /** * 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(); $this->broadcastTranscriptionUpdated(); } /** * 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(); $this->broadcastTranscriptionUpdated(); } /** * 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. * * @param array|null $whisperDelta */ public function reportProgress(string $message, int $percent, string $status = 'processing', ?string $partialTranscript = null, ?array $whisperDelta = null): void { $diff = $this->transcriptBroadcastDiff($partialTranscript); $attributes = [ 'transcription_status' => $status, 'transcription_progress' => $message, 'transcription_percent' => max(0, min(100, $percent)), 'transcription_error' => null, ]; if ($partialTranscript !== null) { $attributes['transcript'] = $partialTranscript; } if ($whisperDelta !== null) { $attributes['transcription_verbose'] = $this->mergeWhisperVerbose($whisperDelta); } $this->forceFill($attributes)->save(); $this->broadcastTranscriptionUpdated($diff['delta'], $diff['replace'], $whisperDelta); } /** * Broadcast the current transcription status to connected browsers. * * @param array|null $whisperDelta */ public function broadcastTranscriptionUpdated(?string $transcriptDelta = null, bool $transcriptReplace = false, ?array $whisperDelta = null): void { try { RecordingTranscriptionUpdated::dispatch( $this->fresh() ?? $this, $transcriptDelta, $transcriptReplace, $whisperDelta, ); } catch (Throwable $e) { Log::warning('Failed to broadcast transcription status', [ 'recording_id' => $this->id, 'message' => $e->getMessage(), ]); } } /** * Incremental text to push over Reverb instead of the full transcript. * * @return array{delta: ?string, replace: bool} */ public function transcriptBroadcastDiff(?string $next): array { if ($next === null) { return ['delta' => null, 'replace' => false]; } $previous = (string) $this->transcript; if ($previous === '' || ! str_starts_with($next, $previous)) { return ['delta' => $next, 'replace' => true]; } $delta = substr($next, strlen($previous)); return ['delta' => $delta === '' ? null : $delta, 'replace' => false]; } /** * Fold a streamed Whisper chunk into the stored verbose_json snapshot. * * @param array $delta * @return array */ public function mergeWhisperVerbose(array $delta): array { $verbose = is_array($this->transcription_verbose) ? $this->transcription_verbose : []; if (isset($delta['language']) && is_string($delta['language'])) { $verbose['language'] = $delta['language']; } if (isset($delta['duration']) && is_numeric($delta['duration'])) { $verbose['duration'] = (float) $delta['duration']; } $segments = is_array($verbose['segments'] ?? null) ? $verbose['segments'] : []; if (isset($delta['segments']) && is_array($delta['segments'])) { $incoming = []; foreach ($delta['segments'] as $segment) { if (is_array($segment)) { $incoming[] = $segment; } } if ($incoming !== []) { $segments = $incoming; } } elseif (isset($delta['segment']) && is_array($delta['segment'])) { $segments = $this->appendVerboseSegment($segments, $delta['segment']); } if (isset($delta['words']) && is_array($delta['words']) && $delta['words'] !== []) { $last = $segments === [] ? null : count($segments) - 1; if ($last === null) { $segments[] = ['words' => $delta['words']]; } else { $existing = is_array($segments[$last]['words'] ?? null) ? $segments[$last]['words'] : []; $segments[$last]['words'] = array_merge($existing, $delta['words']); } } $logprobs = is_array($verbose['logprobs'] ?? null) ? $verbose['logprobs'] : []; if (isset($delta['logprobs']) && is_array($delta['logprobs'])) { foreach ($delta['logprobs'] as $row) { if (is_array($row)) { $logprobs[] = $row; } } } if ($segments !== []) { $verbose['segments'] = $segments; } if ($logprobs !== []) { $verbose['logprobs'] = $logprobs; } return $verbose; } /** * @param list> $segments * @param array $segment * @return list> */ private function appendVerboseSegment(array $segments, array $segment): array { $key = $this->verboseSegmentKey($segment); foreach ($segments as $existing) { if ($this->verboseSegmentKey($existing) === $key) { return $segments; } } $segments[] = $segment; return $segments; } /** * @param array $segment */ private function verboseSegmentKey(array $segment): string { return implode('|', [ $segment['id'] ?? '', $segment['start'] ?? '', $segment['end'] ?? '', $segment['text'] ?? '', ]); } /** * 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 */ 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, 'whisper' => $this->transcription_verbose, '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); } }