Stream Whisper verbose metadata live without overflowing Reverb.

Persist accumulated verbose_json on the recording and broadcast only transcript/whisper deltas so the show page can render language, segments, word timestamps, and confidence while a run is in progress.
This commit is contained in:
ben
2026-08-13 15:55:51 +02:00
parent 4c73620458
commit 187b6b5d12
16 changed files with 1646 additions and 189 deletions
+157 -8
View File
@@ -12,6 +12,7 @@ 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\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
@@ -33,6 +34,7 @@ class Recording extends Model
'file_size_bytes',
'content_hash',
'transcript',
'transcription_verbose',
'transcription_status',
'transcription_progress',
'transcription_percent',
@@ -57,6 +59,7 @@ class Recording extends Model
'transcription_duration_seconds' => 'integer',
'file_size_bytes' => 'integer',
'transcription_percent' => 'integer',
'transcription_verbose' => 'array',
'user_id' => 'integer',
];
}
@@ -146,7 +149,7 @@ class Recording extends Model
]);
$recording = $this->fresh();
RecordingTranscriptionUpdated::dispatch($recording);
$this->broadcastTranscriptionUpdated();
TranscribeRecording::dispatch($recording);
}
@@ -424,7 +427,7 @@ class Recording extends Model
'transcription_error' => 'Stopped by user',
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
$this->broadcastTranscriptionUpdated();
}
/**
@@ -458,7 +461,7 @@ class Recording extends Model
'transcription_duration_seconds' => $durationSeconds,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
$this->broadcastTranscriptionUpdated();
}
/**
@@ -477,7 +480,7 @@ class Recording extends Model
'transcription_error' => $message,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
$this->broadcastTranscriptionUpdated();
}
/**
@@ -501,9 +504,13 @@ class Recording extends Model
/**
* Update the live progress fields shown in the UI.
*
* @param array<string, mixed>|null $whisperDelta
*/
public function reportProgress(string $message, int $percent, string $status = 'processing', ?string $partialTranscript = null): void
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,
@@ -515,17 +522,158 @@ class Recording extends Model
$attributes['transcript'] = $partialTranscript;
}
if ($whisperDelta !== null) {
$attributes['transcription_verbose'] = $this->mergeWhisperVerbose($whisperDelta);
}
$this->forceFill($attributes)->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
$this->broadcastTranscriptionUpdated($diff['delta'], $diff['replace'], $whisperDelta);
}
/**
* Broadcast the current transcription status to connected browsers.
*
* @param array<string, mixed>|null $whisperDelta
*/
public function broadcastTranscriptionUpdated(): void
public function broadcastTranscriptionUpdated(?string $transcriptDelta = null, bool $transcriptReplace = false, ?array $whisperDelta = null): void
{
RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this);
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<string, mixed> $delta
* @return array<string, mixed>
*/
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<array<string, mixed>> $segments
* @param array<string, mixed> $segment
* @return list<array<string, mixed>>
*/
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<string, mixed> $segment
*/
private function verboseSegmentKey(array $segment): string
{
return implode('|', [
$segment['id'] ?? '',
$segment['start'] ?? '',
$segment['end'] ?? '',
$segment['text'] ?? '',
]);
}
/**
@@ -596,6 +744,7 @@ class Recording extends Model
'is_active' => $this->isTranscribing(),
'has_transcript' => filled($this->transcript),
'transcript' => $this->transcript,
'whisper' => $this->transcription_verbose,
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
];
}