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:
@@ -14,9 +14,21 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
|
|||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new event instance.
|
* Reverb's default max payload is 10KB. Keep streamed text well under that.
|
||||||
*/
|
*/
|
||||||
public function __construct(public Recording $recording) {}
|
public const MAX_DELTA_BYTES = 8_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event instance.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $whisperDelta
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Recording $recording,
|
||||||
|
public ?string $transcriptDelta = null,
|
||||||
|
public bool $transcriptReplace = false,
|
||||||
|
public ?array $whisperDelta = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the channels the event should broadcast on.
|
* Get the channels the event should broadcast on.
|
||||||
@@ -41,11 +53,89 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
|
|||||||
*/
|
*/
|
||||||
public function broadcastWith(): array
|
public function broadcastWith(): array
|
||||||
{
|
{
|
||||||
return array_merge(
|
$payload = array_merge(
|
||||||
$this->recording->transcriptionStatusPayload(),
|
$this->recording->transcriptionStatusPayload(),
|
||||||
[
|
[
|
||||||
'word_count' => $this->recording->word_count,
|
'word_count' => $this->recording->word_count,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
unset($payload['transcript'], $payload['whisper']);
|
||||||
|
|
||||||
|
$delta = $this->transcriptDelta;
|
||||||
|
|
||||||
|
if ($delta !== null && $delta !== '' && strlen($delta) <= self::MAX_DELTA_BYTES) {
|
||||||
|
$payload['transcript_delta'] = $delta;
|
||||||
|
$payload['transcript_replace'] = $this->transcriptReplace;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whisper = $this->compactWhisperDelta($this->whisperDelta);
|
||||||
|
|
||||||
|
if ($whisper !== null) {
|
||||||
|
$payload['whisper_delta'] = $whisper;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $whisper
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function compactWhisperDelta(?array $whisper): ?array
|
||||||
|
{
|
||||||
|
if ($whisper === null || $whisper === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulated segment lists belong in HTTP hydrate, not on Reverb.
|
||||||
|
unset($whisper['segments']);
|
||||||
|
|
||||||
|
if ($whisper === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['tokens', 'logprobs', 'words'] as $drop) {
|
||||||
|
$encoded = json_encode($whisper);
|
||||||
|
|
||||||
|
if (! is_string($encoded) || strlen($encoded) <= self::MAX_DELTA_BYTES) {
|
||||||
|
return $whisper;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whisper = $this->dropWhisperField($whisper, $drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($whisper);
|
||||||
|
|
||||||
|
if (! is_string($encoded) || strlen($encoded) > self::MAX_DELTA_BYTES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $whisper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $whisper
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function dropWhisperField(array $whisper, string $field): array
|
||||||
|
{
|
||||||
|
unset($whisper[$field]);
|
||||||
|
|
||||||
|
if (isset($whisper['segment']) && is_array($whisper['segment'])) {
|
||||||
|
unset($whisper['segment'][$field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($whisper['segments']) && is_array($whisper['segments'])) {
|
||||||
|
$whisper['segments'] = array_map(function (mixed $segment) use ($field): mixed {
|
||||||
|
if (is_array($segment)) {
|
||||||
|
unset($segment[$field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segment;
|
||||||
|
}, $whisper['segments']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $whisper;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,17 +67,16 @@ class TranscribeRecording implements ShouldQueue
|
|||||||
'transcription_status' => 'processing',
|
'transcription_status' => 'processing',
|
||||||
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
|
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
|
||||||
'transcription_error' => null,
|
'transcription_error' => null,
|
||||||
|
'transcription_verbose' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
|
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
|
||||||
|
|
||||||
$this->reportIfOwned('Preparing audio file…', 15);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$text = $transcription->transcribe(
|
$text = $transcription->transcribe(
|
||||||
$this->recording,
|
$this->recording,
|
||||||
function (string $message, int $percent, ?string $partialTranscript = null): void {
|
function (string $message, int $percent, ?string $partialTranscript = null, ?array $whisper = null): void {
|
||||||
$this->reportIfOwned($message, $percent, $partialTranscript);
|
$this->reportIfOwned($message, $percent, $partialTranscript, $whisper);
|
||||||
},
|
},
|
||||||
fn (): bool => Recording::query()->find($this->recording->id)
|
fn (): bool => Recording::query()->find($this->recording->id)
|
||||||
?->ownsTranscriptionRun($this->runStartedAt) ?? false,
|
?->ownsTranscriptionRun($this->runStartedAt) ?? false,
|
||||||
@@ -135,11 +134,6 @@ class TranscribeRecording implements ShouldQueue
|
|||||||
{
|
{
|
||||||
$this->recording->refresh();
|
$this->recording->refresh();
|
||||||
|
|
||||||
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
|
||||||
$this->reportIfOwned('Saving transcript…', 90);
|
|
||||||
$this->recording->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||||
$this->recording->markTranscriptionComplete($text);
|
$this->recording->markTranscriptionComplete($text);
|
||||||
|
|
||||||
@@ -172,12 +166,12 @@ class TranscribeRecording implements ShouldQueue
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reportIfOwned(string $message, int $percent, ?string $partialTranscript = null): void
|
private function reportIfOwned(string $message, int $percent, ?string $partialTranscript = null, ?array $whisper = null): void
|
||||||
{
|
{
|
||||||
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript);
|
$this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript, whisperDelta: $whisper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -22,8 +21,6 @@ class Index extends Component
|
|||||||
{
|
{
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
public int $userId;
|
|
||||||
|
|
||||||
#[Url(as: 'q', history: true)]
|
#[Url(as: 'q', history: true)]
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
@@ -45,7 +42,6 @@ class Index extends Component
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->userId = (int) Auth::id();
|
|
||||||
$this->normalizeSort();
|
$this->normalizeSort();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,15 +66,6 @@ class Index extends Component
|
|||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-render when any of this user's recordings broadcast a status change.
|
|
||||||
*/
|
|
||||||
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
|
|
||||||
public function onTranscriptionUpdated(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public function queuePending(): void
|
public function queuePending(): void
|
||||||
{
|
{
|
||||||
$queued = 0;
|
$queued = 0;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use Flux\Flux;
|
|||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
@@ -15,8 +14,6 @@ class Show extends Component
|
|||||||
{
|
{
|
||||||
public Recording $recording;
|
public Recording $recording;
|
||||||
|
|
||||||
public int $userId;
|
|
||||||
|
|
||||||
public function mount(Recording $recording): void
|
public function mount(Recording $recording): void
|
||||||
{
|
{
|
||||||
Gate::authorize('view', $recording);
|
Gate::authorize('view', $recording);
|
||||||
@@ -25,22 +22,6 @@ class Show extends Component
|
|||||||
$recording->refresh();
|
$recording->refresh();
|
||||||
|
|
||||||
$this->recording = $recording;
|
$this->recording = $recording;
|
||||||
$this->userId = (int) $recording->user_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-render when this recording broadcasts a status change (same channel as the index).
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
#[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')]
|
|
||||||
public function onTranscriptionUpdated(array $event = []): void
|
|
||||||
{
|
|
||||||
if (isset($event['id']) && (int) $event['id'] !== (int) $this->recording->id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->recording->refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function startTranscription(): void
|
public function startTranscription(): void
|
||||||
@@ -83,10 +64,6 @@ class Show extends Component
|
|||||||
|
|
||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
if ($this->recording->isTranscribing()) {
|
|
||||||
$this->recording->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('livewire.recordings.show')
|
return view('livewire.recordings.show')
|
||||||
->title($this->recording->title);
|
->title($this->recording->title);
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-8
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -33,6 +34,7 @@ class Recording extends Model
|
|||||||
'file_size_bytes',
|
'file_size_bytes',
|
||||||
'content_hash',
|
'content_hash',
|
||||||
'transcript',
|
'transcript',
|
||||||
|
'transcription_verbose',
|
||||||
'transcription_status',
|
'transcription_status',
|
||||||
'transcription_progress',
|
'transcription_progress',
|
||||||
'transcription_percent',
|
'transcription_percent',
|
||||||
@@ -57,6 +59,7 @@ class Recording extends Model
|
|||||||
'transcription_duration_seconds' => 'integer',
|
'transcription_duration_seconds' => 'integer',
|
||||||
'file_size_bytes' => 'integer',
|
'file_size_bytes' => 'integer',
|
||||||
'transcription_percent' => 'integer',
|
'transcription_percent' => 'integer',
|
||||||
|
'transcription_verbose' => 'array',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -146,7 +149,7 @@ class Recording extends Model
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$recording = $this->fresh();
|
$recording = $this->fresh();
|
||||||
RecordingTranscriptionUpdated::dispatch($recording);
|
$this->broadcastTranscriptionUpdated();
|
||||||
TranscribeRecording::dispatch($recording);
|
TranscribeRecording::dispatch($recording);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +427,7 @@ class Recording extends Model
|
|||||||
'transcription_error' => 'Stopped by user',
|
'transcription_error' => 'Stopped by user',
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
$this->broadcastTranscriptionUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,7 +461,7 @@ class Recording extends Model
|
|||||||
'transcription_duration_seconds' => $durationSeconds,
|
'transcription_duration_seconds' => $durationSeconds,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
$this->broadcastTranscriptionUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -477,7 +480,7 @@ class Recording extends Model
|
|||||||
'transcription_error' => $message,
|
'transcription_error' => $message,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
$this->broadcastTranscriptionUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -501,9 +504,13 @@ class Recording extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the live progress fields shown in the UI.
|
* 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 = [
|
$attributes = [
|
||||||
'transcription_status' => $status,
|
'transcription_status' => $status,
|
||||||
'transcription_progress' => $message,
|
'transcription_progress' => $message,
|
||||||
@@ -515,17 +522,158 @@ class Recording extends Model
|
|||||||
$attributes['transcript'] = $partialTranscript;
|
$attributes['transcript'] = $partialTranscript;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($whisperDelta !== null) {
|
||||||
|
$attributes['transcription_verbose'] = $this->mergeWhisperVerbose($whisperDelta);
|
||||||
|
}
|
||||||
|
|
||||||
$this->forceFill($attributes)->save();
|
$this->forceFill($attributes)->save();
|
||||||
|
|
||||||
RecordingTranscriptionUpdated::dispatch($this->fresh());
|
$this->broadcastTranscriptionUpdated($diff['delta'], $diff['replace'], $whisperDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast the current transcription status to connected browsers.
|
* 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(),
|
'is_active' => $this->isTranscribing(),
|
||||||
'has_transcript' => filled($this->transcript),
|
'has_transcript' => filled($this->transcript),
|
||||||
'transcript' => $this->transcript,
|
'transcript' => $this->transcript,
|
||||||
|
'whisper' => $this->transcription_verbose,
|
||||||
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
|
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Closure;
|
|||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
use Illuminate\Http\Client\Response;
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Ai\Transcription;
|
use Laravel\Ai\Transcription;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -17,16 +18,15 @@ class TranscriptionService
|
|||||||
/**
|
/**
|
||||||
* Transcribe a recording with the local faster-whisper server.
|
* Transcribe a recording with the local faster-whisper server.
|
||||||
*
|
*
|
||||||
* @param (Closure(string, int, ?string): void)|null $onProgress
|
* @param (Closure(string, int, ?string, ?array): void)|null $onProgress
|
||||||
* @param (Closure(): bool)|null $shouldContinue
|
* @param (Closure(): bool)|null $shouldContinue
|
||||||
*/
|
*/
|
||||||
public function transcribe(Recording $recording, ?Closure $onProgress = null, ?Closure $shouldContinue = null): string
|
public function transcribe(Recording $recording, ?Closure $onProgress = null, ?Closure $shouldContinue = null): string
|
||||||
{
|
{
|
||||||
$report = $onProgress ?? static fn (string $message, int $percent, ?string $partial = null) => null;
|
$report = $onProgress ?? static fn (string $message, int $percent, ?string $partial = null, ?array $whisper = null) => null;
|
||||||
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
||||||
|
|
||||||
$report('Connecting to local faster-whisper server…', 30);
|
$report("Transcribing locally with {$model} (audio stays on this machine)…", 0);
|
||||||
$report("Transcribing locally with {$model} (audio stays on this machine)…", 50);
|
|
||||||
|
|
||||||
if (Transcription::isFaked()) {
|
if (Transcription::isFaked()) {
|
||||||
$transcript = Transcription::fromStorage($recording->file_path)
|
$transcript = Transcription::fromStorage($recording->file_path)
|
||||||
@@ -36,15 +36,13 @@ class TranscriptionService
|
|||||||
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
|
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
|
||||||
}
|
}
|
||||||
|
|
||||||
$report('Received transcript from local Whisper…', 85);
|
|
||||||
|
|
||||||
return (string) $transcript;
|
return (string) $transcript;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call local Whisper, streaming SSE when the server supports it.
|
* Call local Whisper, streaming SSE when the server supports it.
|
||||||
*
|
*
|
||||||
* @param Closure(string, int, ?string): void $report
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
* @param (Closure(): bool)|null $shouldContinue
|
* @param (Closure(): bool)|null $shouldContinue
|
||||||
*/
|
*/
|
||||||
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): string
|
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): string
|
||||||
@@ -62,9 +60,10 @@ class TranscriptionService
|
|||||||
->withOptions(['stream' => true])
|
->withOptions(['stream' => true])
|
||||||
->post('audio/transcriptions', [
|
->post('audio/transcriptions', [
|
||||||
'model' => $model,
|
'model' => $model,
|
||||||
'response_format' => 'json',
|
'response_format' => 'verbose_json',
|
||||||
'stream' => 'true',
|
'stream' => 'true',
|
||||||
'without_timestamps' => 'false',
|
'without_timestamps' => 'false',
|
||||||
|
'timestamp_granularities[]' => 'word',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
@@ -76,30 +75,33 @@ class TranscriptionService
|
|||||||
$contentType = strtolower((string) $response->header('Content-Type'));
|
$contentType = strtolower((string) $response->header('Content-Type'));
|
||||||
|
|
||||||
if (! str_contains($contentType, 'text/event-stream')) {
|
if (! str_contains($contentType, 'text/event-stream')) {
|
||||||
return $this->extractTranscriptText($response);
|
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
||||||
|
'recording_id' => $recording->id,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->extractTranscriptText($response, $report, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->consumeWhisperStream($response, $report, $message, $recording->duration_seconds, $shouldContinue);
|
return $this->consumeWhisperStream($response, $report, $message, $recording, $shouldContinue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Closure(string, int, ?string): void $report
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
* @param (Closure(): bool)|null $shouldContinue
|
* @param (Closure(): bool)|null $shouldContinue
|
||||||
*/
|
*/
|
||||||
private function consumeWhisperStream(
|
private function consumeWhisperStream(
|
||||||
Response $response,
|
Response $response,
|
||||||
Closure $report,
|
Closure $report,
|
||||||
string $message,
|
string $message,
|
||||||
?int $durationSeconds,
|
Recording $recording,
|
||||||
?Closure $shouldContinue,
|
?Closure $shouldContinue,
|
||||||
): string {
|
): string {
|
||||||
$body = $response->toPsrResponse()->getBody();
|
$body = $response->toPsrResponse()->getBody();
|
||||||
$buffer = '';
|
$buffer = '';
|
||||||
$accumulated = '';
|
$accumulated = '';
|
||||||
$lastPercent = 50;
|
$lastPercent = 0;
|
||||||
$eventsWithoutTimestamp = 0;
|
$idleReads = 0;
|
||||||
$lastFlushAt = 0.0;
|
|
||||||
$pending = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (! $body->eof()) {
|
while (! $body->eof()) {
|
||||||
@@ -112,43 +114,42 @@ class TranscriptionService
|
|||||||
$chunk = $body->read(8192);
|
$chunk = $body->read(8192);
|
||||||
|
|
||||||
if ($chunk === '') {
|
if ($chunk === '') {
|
||||||
|
$idleReads++;
|
||||||
|
|
||||||
|
if ($idleReads >= 40) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usleep(50_000);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idleReads = 0;
|
||||||
$buffer .= $chunk;
|
$buffer .= $chunk;
|
||||||
|
|
||||||
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
||||||
[$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent(
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
$payload,
|
$payload,
|
||||||
$accumulated,
|
$accumulated,
|
||||||
$lastPercent,
|
$lastPercent,
|
||||||
$eventsWithoutTimestamp,
|
$recording,
|
||||||
$durationSeconds,
|
|
||||||
$report,
|
$report,
|
||||||
$message,
|
$message,
|
||||||
$lastFlushAt,
|
|
||||||
$pending,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
||||||
[$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent(
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
||||||
$payload,
|
$payload,
|
||||||
$accumulated,
|
$accumulated,
|
||||||
$lastPercent,
|
$lastPercent,
|
||||||
$eventsWithoutTimestamp,
|
$recording,
|
||||||
$durationSeconds,
|
|
||||||
$report,
|
$report,
|
||||||
$message,
|
$message,
|
||||||
$lastFlushAt,
|
|
||||||
$pending,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pending && $accumulated !== '') {
|
|
||||||
$report($message, $lastPercent, $accumulated);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
$response->close();
|
$response->close();
|
||||||
}
|
}
|
||||||
@@ -161,61 +162,55 @@ class TranscriptionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Closure(string, int, ?string): void $report
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
* @return array{0: string, 1: int, 2: int, 3: bool}
|
* @return array{0: string, 1: int}
|
||||||
*/
|
*/
|
||||||
private function ingestEvent(
|
private function ingestEvent(
|
||||||
string $payload,
|
string $payload,
|
||||||
string $accumulated,
|
string $accumulated,
|
||||||
int $lastPercent,
|
int $lastPercent,
|
||||||
int $eventsWithoutTimestamp,
|
Recording $recording,
|
||||||
?int $durationSeconds,
|
|
||||||
Closure $report,
|
Closure $report,
|
||||||
string $message,
|
string $message,
|
||||||
float &$lastFlushAt,
|
|
||||||
bool $pending,
|
|
||||||
): array {
|
): array {
|
||||||
$event = $this->stream->parseEvent($payload);
|
$event = $this->stream->parseEvent($payload);
|
||||||
|
|
||||||
if ($event === null) {
|
if ($event === null) {
|
||||||
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending];
|
return [$accumulated, $lastPercent];
|
||||||
}
|
}
|
||||||
|
|
||||||
$wasEmpty = $accumulated === '';
|
|
||||||
$accumulated = $this->stream->applyEvent($accumulated, $event);
|
$accumulated = $this->stream->applyEvent($accumulated, $event);
|
||||||
|
$whisper = $event['whisper'] ?? [];
|
||||||
|
|
||||||
if ($accumulated === '') {
|
if ($accumulated === '' && $whisper === []) {
|
||||||
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending];
|
return [$accumulated, $lastPercent];
|
||||||
}
|
}
|
||||||
|
|
||||||
$percent = $this->percentForEvent($event, $durationSeconds, $lastPercent, $eventsWithoutTimestamp);
|
$lastPercent = $this->percentForEvent($event, $recording, $lastPercent);
|
||||||
$lastPercent = max($lastPercent, $percent);
|
|
||||||
|
|
||||||
$now = microtime(true);
|
$report(
|
||||||
$shouldFlush = $wasEmpty || $event['done'] || ($now - $lastFlushAt) >= 1.0;
|
$message,
|
||||||
|
$lastPercent,
|
||||||
|
$accumulated === '' ? null : $accumulated,
|
||||||
|
$whisper === [] ? null : $whisper,
|
||||||
|
);
|
||||||
|
|
||||||
if ($shouldFlush) {
|
return [$accumulated, $lastPercent];
|
||||||
$report($message, $lastPercent, $accumulated);
|
|
||||||
$lastFlushAt = $now;
|
|
||||||
|
|
||||||
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, false];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, true];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool} $event
|
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
|
||||||
*/
|
*/
|
||||||
private function percentForEvent(array $event, ?int $durationSeconds, int $lastPercent, int &$eventsWithoutTimestamp): int
|
private function percentForEvent(array $event, Recording $recording, int $lastPercent): int
|
||||||
{
|
{
|
||||||
if ($event['end'] !== null && $durationSeconds !== null && $durationSeconds > 0) {
|
$duration = $recording->duration_seconds;
|
||||||
return (int) min(99, max(50, round(100 * $event['end'] / $durationSeconds)));
|
$end = $event['end'] ?? null;
|
||||||
|
|
||||||
|
if ($end === null || $duration === null || $duration <= 0) {
|
||||||
|
return $lastPercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
$eventsWithoutTimestamp++;
|
return (int) min(99, max($lastPercent, round(100 * $end / $duration)));
|
||||||
|
|
||||||
return min(84, max($lastPercent, 50 + $eventsWithoutTimestamp));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function localWhisperRequest(string $filename, string $path): PendingRequest
|
private function localWhisperRequest(string $filename, string $path): PendingRequest
|
||||||
@@ -231,14 +226,29 @@ class TranscriptionService
|
|||||||
->attach('file', fopen($path, 'r'), $filename);
|
->attach('file', fopen($path, 'r'), $filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractTranscriptText(Response $response): string
|
/**
|
||||||
|
* @param Closure(string, int, ?string, ?array): void $report
|
||||||
|
*/
|
||||||
|
private function extractTranscriptText(Response $response, ?Closure $report = null, string $message = ''): string
|
||||||
{
|
{
|
||||||
$text = $response->json('text');
|
$json = $response->json();
|
||||||
|
|
||||||
|
if (! is_array($json)) {
|
||||||
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $json['text'] ?? null;
|
||||||
|
|
||||||
if (! is_string($text) || $text === '') {
|
if (! is_string($text) || $text === '') {
|
||||||
throw new RuntimeException('Local transcription returned an empty transcript.');
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$whisper = $this->stream->extractWhisperMeta($json);
|
||||||
|
|
||||||
|
if ($report !== null && $whisper !== []) {
|
||||||
|
$report($message, 99, $text, $whisper);
|
||||||
|
}
|
||||||
|
|
||||||
return $text;
|
return $text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ class WhisperTranscriptionStream
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse one SSE JSON payload into append/replace/end/done fields.
|
* Parse one SSE JSON payload into append/replace/end/done/whisper fields.
|
||||||
*
|
*
|
||||||
* @return array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool}|null
|
* @return array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper: array<string, mixed>}|null
|
||||||
*/
|
*/
|
||||||
public function parseEvent(string $json): ?array
|
public function parseEvent(string $json): ?array
|
||||||
{
|
{
|
||||||
@@ -62,20 +62,23 @@ class WhisperTranscriptionStream
|
|||||||
}
|
}
|
||||||
|
|
||||||
$type = $data['type'] ?? null;
|
$type = $data['type'] ?? null;
|
||||||
|
$whisper = $this->extractWhisperMeta($data);
|
||||||
|
$end = $this->latestAudioEnd($data, $whisper);
|
||||||
|
|
||||||
if ($type === 'transcript.text.delta') {
|
if ($type === 'transcript.text.delta') {
|
||||||
$delta = $data['delta'] ?? '';
|
$delta = $data['delta'] ?? '';
|
||||||
|
|
||||||
if (! is_string($delta) || $delta === '') {
|
if ((! is_string($delta) || $delta === '') && $whisper === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'append' => $delta,
|
'append' => is_string($delta) && $delta !== '' ? $delta : null,
|
||||||
'replace' => null,
|
'replace' => null,
|
||||||
'end' => $this->nullableFloat($data['end'] ?? null),
|
'end' => $end,
|
||||||
'done' => false,
|
'done' => false,
|
||||||
'legacy' => false,
|
'legacy' => false,
|
||||||
|
'whisper' => $whisper,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,19 +88,54 @@ class WhisperTranscriptionStream
|
|||||||
return [
|
return [
|
||||||
'append' => null,
|
'append' => null,
|
||||||
'replace' => is_string($text) ? $text : '',
|
'replace' => is_string($text) ? $text : '',
|
||||||
'end' => $this->nullableFloat($data['end'] ?? null),
|
'end' => $end,
|
||||||
'done' => true,
|
'done' => true,
|
||||||
'legacy' => false,
|
'legacy' => false,
|
||||||
|
'whisper' => $whisper,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === null && isset($data['text']) && is_string($data['text']) && $data['text'] !== '') {
|
if (isset($data['segments']) && is_array($data['segments'])) {
|
||||||
|
$text = $data['text'] ?? '';
|
||||||
|
|
||||||
|
if ((! is_string($text) || $text === '') && $whisper === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'append' => null,
|
||||||
|
'replace' => is_string($text) && $text !== '' ? $text : null,
|
||||||
|
'end' => $end,
|
||||||
|
'done' => true,
|
||||||
|
'legacy' => false,
|
||||||
|
'whisper' => $whisper,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
($type === null || $type === 'segment')
|
||||||
|
&& isset($data['text'])
|
||||||
|
&& is_string($data['text'])
|
||||||
|
&& $data['text'] !== ''
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
'append' => $data['text'],
|
'append' => $data['text'],
|
||||||
'replace' => null,
|
'replace' => null,
|
||||||
'end' => $this->nullableFloat($data['end'] ?? null),
|
'end' => $end,
|
||||||
'done' => false,
|
'done' => false,
|
||||||
'legacy' => true,
|
'legacy' => true,
|
||||||
|
'whisper' => $whisper,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($whisper !== []) {
|
||||||
|
return [
|
||||||
|
'append' => null,
|
||||||
|
'replace' => null,
|
||||||
|
'end' => $end,
|
||||||
|
'done' => false,
|
||||||
|
'legacy' => false,
|
||||||
|
'whisper' => $whisper,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +145,7 @@ class WhisperTranscriptionStream
|
|||||||
/**
|
/**
|
||||||
* Apply a parsed event to the accumulated transcript.
|
* Apply a parsed event to the accumulated transcript.
|
||||||
*
|
*
|
||||||
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool} $event
|
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
|
||||||
*/
|
*/
|
||||||
public function applyEvent(string $accumulated, array $event): string
|
public function applyEvent(string $accumulated, array $event): string
|
||||||
{
|
{
|
||||||
@@ -134,6 +172,108 @@ class WhisperTranscriptionStream
|
|||||||
return $accumulated.$chunk;
|
return $accumulated.$chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function extractWhisperMeta(array $data): array
|
||||||
|
{
|
||||||
|
$meta = [];
|
||||||
|
|
||||||
|
if (isset($data['language']) && is_string($data['language']) && $data['language'] !== '') {
|
||||||
|
$meta['language'] = $data['language'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($data['duration'] ?? null)) {
|
||||||
|
$meta['duration'] = (float) $data['duration'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['logprobs']) && is_array($data['logprobs'])) {
|
||||||
|
$logprobs = $this->normalizeLogprobs($data['logprobs']);
|
||||||
|
|
||||||
|
if ($logprobs !== []) {
|
||||||
|
$meta['logprobs'] = $logprobs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$segment = $this->normalizeSegment($data);
|
||||||
|
|
||||||
|
if ($segment !== null && ($data['type'] ?? null) !== 'transcript.text.delta') {
|
||||||
|
$meta['segment'] = $segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['segments']) && is_array($data['segments'])) {
|
||||||
|
$segments = [];
|
||||||
|
|
||||||
|
foreach ($data['segments'] as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeSegment($row);
|
||||||
|
|
||||||
|
if ($normalized !== null) {
|
||||||
|
$segments[] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($segments !== []) {
|
||||||
|
$meta['segments'] = $segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['words']) && is_array($data['words']) && ! isset($meta['segment']) && ! isset($meta['segments'])) {
|
||||||
|
$words = $this->normalizeWords($data['words']);
|
||||||
|
|
||||||
|
if ($words !== []) {
|
||||||
|
$meta['words'] = $words;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function normalizeSegment(array $data): ?array
|
||||||
|
{
|
||||||
|
$hasDetail = isset($data['start'])
|
||||||
|
|| isset($data['end'])
|
||||||
|
|| isset($data['words'])
|
||||||
|
|| isset($data['avg_logprob'])
|
||||||
|
|| isset($data['tokens'])
|
||||||
|
|| isset($data['no_speech_prob'])
|
||||||
|
|| array_key_exists('id', $data);
|
||||||
|
|
||||||
|
if (! $hasDetail) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $data['text'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($text) || $text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segment = [
|
||||||
|
'id' => is_numeric($data['id'] ?? null) ? (int) $data['id'] : null,
|
||||||
|
'seek' => is_numeric($data['seek'] ?? null) ? (int) $data['seek'] : null,
|
||||||
|
'start' => $this->nullableFloat($data['start'] ?? null),
|
||||||
|
'end' => $this->nullableFloat($data['end'] ?? null),
|
||||||
|
'text' => $text,
|
||||||
|
'tokens' => $this->normalizeTokens($data['tokens'] ?? null),
|
||||||
|
'temperature' => $this->nullableFloat($data['temperature'] ?? null),
|
||||||
|
'avg_logprob' => $this->nullableFloat($data['avg_logprob'] ?? null),
|
||||||
|
'compression_ratio' => $this->nullableFloat($data['compression_ratio'] ?? null),
|
||||||
|
'no_speech_prob' => $this->nullableFloat($data['no_speech_prob'] ?? null),
|
||||||
|
'words' => $this->normalizeWords($data['words'] ?? null),
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_filter($segment, fn (mixed $value): bool => $value !== null && $value !== []);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@@ -150,6 +290,60 @@ class WhisperTranscriptionStream
|
|||||||
return $payloads;
|
return $payloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest media timestamp in this event (segment/word `end`). Never file `duration`.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<string, mixed> $whisper
|
||||||
|
*/
|
||||||
|
public function latestAudioEnd(array $data, array $whisper): ?float
|
||||||
|
{
|
||||||
|
$ends = [];
|
||||||
|
|
||||||
|
$direct = $this->nullableFloat($data['end'] ?? null);
|
||||||
|
|
||||||
|
if ($direct !== null) {
|
||||||
|
$ends[] = $direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->collectAudioEnds($ends, $whisper);
|
||||||
|
|
||||||
|
if (isset($data['words']) && is_array($data['words'])) {
|
||||||
|
$this->collectAudioEnds($ends, ['words' => $data['words']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ends === [] ? null : max($ends);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<float> $ends
|
||||||
|
* @param array<string, mixed> $node
|
||||||
|
*/
|
||||||
|
private function collectAudioEnds(array &$ends, array $node): void
|
||||||
|
{
|
||||||
|
$end = $this->nullableFloat($node['end'] ?? null);
|
||||||
|
|
||||||
|
if ($end !== null) {
|
||||||
|
$ends[] = $end;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['words', 'segments'] as $key) {
|
||||||
|
if (! isset($node[$key]) || ! is_array($node[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($node[$key] as $child) {
|
||||||
|
if (is_array($child)) {
|
||||||
|
$this->collectAudioEnds($ends, $child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($node['segment']) && is_array($node['segment'])) {
|
||||||
|
$this->collectAudioEnds($ends, $node['segment']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function nullableFloat(mixed $value): ?float
|
private function nullableFloat(mixed $value): ?float
|
||||||
{
|
{
|
||||||
if (! is_numeric($value)) {
|
if (! is_numeric($value)) {
|
||||||
@@ -158,4 +352,86 @@ class WhisperTranscriptionStream
|
|||||||
|
|
||||||
return (float) $value;
|
return (float) $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>|null
|
||||||
|
*/
|
||||||
|
private function normalizeTokens(mixed $tokens): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($tokens) || $tokens === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if (is_numeric($token)) {
|
||||||
|
$normalized[] = (int) $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized === [] ? null : $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{word: string, start: ?float, end: ?float, probability: ?float}>|null
|
||||||
|
*/
|
||||||
|
private function normalizeWords(mixed $words): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($words) || $words === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($words as $word) {
|
||||||
|
if (! is_array($word)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $word['word'] ?? $word['text'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($text) || $text === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = array_filter([
|
||||||
|
'word' => $text,
|
||||||
|
'start' => $this->nullableFloat($word['start'] ?? null),
|
||||||
|
'end' => $this->nullableFloat($word['end'] ?? null),
|
||||||
|
'probability' => $this->nullableFloat($word['probability'] ?? $word['prob'] ?? null),
|
||||||
|
], fn (mixed $value): bool => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized === [] ? null : $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{token: ?string, logprob: ?float}>
|
||||||
|
*/
|
||||||
|
private function normalizeLogprobs(array $logprobs): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($logprobs as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $row['token'] ?? $row['bytes'] ?? null;
|
||||||
|
$token = is_string($token) ? $token : null;
|
||||||
|
$logprob = $this->nullableFloat($row['logprob'] ?? $row['avg_logprob'] ?? null);
|
||||||
|
|
||||||
|
if ($token === null && $logprob === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = array_filter([
|
||||||
|
'token' => $token,
|
||||||
|
'logprob' => $logprob,
|
||||||
|
], fn (mixed $value): bool => $value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('recordings', function (Blueprint $table) {
|
||||||
|
$table->json('transcription_verbose')
|
||||||
|
->nullable()
|
||||||
|
->after('transcript');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('recordings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('transcription_verbose');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+246
-33
@@ -55,39 +55,105 @@ export function formatTimestamp(value) {
|
|||||||
+ ':' + pad(date.getMinutes());
|
+ ':' + pad(date.getMinutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeToRecording(recordingId, handler) {
|
function unwrapBroadcast(event) {
|
||||||
|
if (! event || typeof event !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.transcript_delta === undefined
|
||||||
|
&& event.transcriptDelta === undefined
|
||||||
|
&& event.data
|
||||||
|
&& typeof event.data === 'object'
|
||||||
|
) {
|
||||||
|
return event.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToRecording({ recordingId, userId }, handler) {
|
||||||
if (! window.Echo) {
|
if (! window.Echo) {
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelName = 'recording.' + recordingId;
|
const channels = [window.Echo.private('recording.' + recordingId)];
|
||||||
const channel = window.Echo.private(channelName);
|
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
channels.push(window.Echo.private('user.' + userId + '.recordings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
channels.forEach((channel) => {
|
||||||
channel.listen('.RecordingTranscriptionUpdated', handler);
|
channel.listen('.RecordingTranscriptionUpdated', handler);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Prefer stopListening over leave() so a remount does not drop other subscribers.
|
channels.forEach((channel) => {
|
||||||
if (typeof channel.stopListening === 'function') {
|
if (typeof channel.stopListening === 'function') {
|
||||||
channel.stopListening('.RecordingTranscriptionUpdated');
|
channel.stopListening('.RecordingTranscriptionUpdated');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function whisperSnapshot(snapshot) {
|
||||||
|
return {
|
||||||
|
language: snapshot?.language ?? null,
|
||||||
|
duration: snapshot?.duration ?? null,
|
||||||
|
segments: Array.isArray(snapshot?.segments) ? snapshot.segments : [],
|
||||||
|
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentKey(segment) {
|
||||||
|
return [segment?.id ?? '', segment?.start ?? '', segment?.end ?? '', segment?.text ?? ''].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClock(seconds) {
|
||||||
|
if (seconds == null || Number.isNaN(Number(seconds))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Math.max(0, Number(seconds));
|
||||||
|
const minutes = Math.floor(value / 60);
|
||||||
|
const rest = value - minutes * 60;
|
||||||
|
|
||||||
|
return minutes + ':' + rest.toFixed(1).padStart(4, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordConfidenceClass(probability) {
|
||||||
|
if (probability == null) {
|
||||||
|
return 'bg-zinc-400/20 text-zinc-700 dark:text-zinc-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probability >= 0.85) {
|
||||||
|
return 'bg-teal-400/25 text-teal-800 dark:text-teal-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probability >= 0.6) {
|
||||||
|
return 'bg-amber-400/25 text-amber-800 dark:text-amber-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-red-400/20 text-red-700 dark:text-red-300';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
|
* Hydrate once at start and again when the run finishes; do not poll the full transcript.
|
||||||
* Livewire also listens on the user recordings channel and polls while active.
|
|
||||||
*/
|
*/
|
||||||
export function transcriptionMonitor({ statusUrl, initial }) {
|
export function transcriptionMonitor({ statusUrl, initial, userId }) {
|
||||||
return {
|
return {
|
||||||
statusUrl,
|
statusUrl,
|
||||||
|
userId,
|
||||||
status: {
|
status: {
|
||||||
...initial,
|
...initial,
|
||||||
badge_color: badgeColorFor(initial.status),
|
badge_color: badgeColorFor(initial.status),
|
||||||
},
|
},
|
||||||
pollError: null,
|
pollError: null,
|
||||||
tickTimer: null,
|
tickTimer: null,
|
||||||
hydrateTimer: null,
|
|
||||||
leaveChannel: null,
|
leaveChannel: null,
|
||||||
|
liveFromEcho: false,
|
||||||
|
lastDeltaStamp: null,
|
||||||
|
whisper: whisperSnapshot(initial?.whisper),
|
||||||
|
|
||||||
get badgeColor() {
|
get badgeColor() {
|
||||||
return badgeColorFor(this.status.status);
|
return badgeColorFor(this.status.status);
|
||||||
@@ -102,24 +168,27 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
this.leaveChannel = subscribeToRecording(this.status.id, (event) => {
|
this.leaveChannel = subscribeToRecording({
|
||||||
if (Number(event.id) !== Number(this.status.id)) {
|
recordingId: this.status.id,
|
||||||
|
userId: this.userId,
|
||||||
|
}, (event) => {
|
||||||
|
const payload = unwrapBroadcast(event);
|
||||||
|
|
||||||
|
if (Number(payload.id) !== Number(this.status.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.applyPayload(event);
|
this.applyPayload(payload, { fromEcho: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.status.is_active) {
|
if (this.status.is_active) {
|
||||||
this.beginTick();
|
this.beginTick();
|
||||||
this.hydrateOnce();
|
this.hydrateOnce();
|
||||||
this.beginHydratePoll();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.stopTick();
|
this.stopTick();
|
||||||
this.stopHydratePoll();
|
|
||||||
|
|
||||||
if (this.leaveChannel) {
|
if (this.leaveChannel) {
|
||||||
this.leaveChannel();
|
this.leaveChannel();
|
||||||
@@ -127,21 +196,45 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
applyPayload(payload) {
|
applyPayload(payload, { fromEcho = false } = {}) {
|
||||||
const wasActive = this.status.is_active;
|
const wasActive = this.status.is_active;
|
||||||
|
const transcript = this.mergeTranscript(payload, fromEcho);
|
||||||
|
const nextStatus = payload.status ?? this.status.status;
|
||||||
|
|
||||||
this.status = {
|
this.status = {
|
||||||
...this.status,
|
...this.status,
|
||||||
...payload,
|
...payload,
|
||||||
badge_color: badgeColorFor(payload.status ?? this.status.status),
|
transcript,
|
||||||
|
has_transcript: Boolean(transcript),
|
||||||
|
percent: this.mergePercent(payload, nextStatus),
|
||||||
|
badge_color: badgeColorFor(nextStatus),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.transcript_replace
|
||||||
|
|| (
|
||||||
|
fromEcho
|
||||||
|
&& payload.status === 'processing'
|
||||||
|
&& payload.percent === 0
|
||||||
|
&& ! payload.whisper_delta
|
||||||
|
&& ! payload.transcript_delta
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.whisper = whisperSnapshot(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.whisper = this.mergeWhisper(payload, fromEcho);
|
||||||
this.pollError = null;
|
this.pollError = null;
|
||||||
|
|
||||||
if (this.status.is_active) {
|
if (this.status.is_active) {
|
||||||
this.beginTick();
|
this.beginTick();
|
||||||
this.beginHydratePoll();
|
|
||||||
} else {
|
} else {
|
||||||
this.stopTick();
|
this.stopTick();
|
||||||
this.stopHydratePoll();
|
|
||||||
|
if (wasActive) {
|
||||||
|
this.hydrateOnce();
|
||||||
|
this.refreshLivewire();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasActive && ! this.status.is_active
|
if (wasActive && ! this.status.is_active
|
||||||
@@ -152,6 +245,139 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshLivewire() {
|
||||||
|
if (typeof this.$wire?.$refresh === 'function') {
|
||||||
|
this.$wire.$refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeTranscript(payload, fromEcho = false) {
|
||||||
|
const delta = payload.transcript_delta || payload.transcriptDelta;
|
||||||
|
const replace = payload.transcript_replace ?? payload.transcriptReplace ?? false;
|
||||||
|
|
||||||
|
if (fromEcho && delta) {
|
||||||
|
const stamp = String(payload.percent ?? '') + ':' + delta;
|
||||||
|
|
||||||
|
if (this.lastDeltaStamp === stamp) {
|
||||||
|
return this.status.transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastDeltaStamp = stamp;
|
||||||
|
this.liveFromEcho = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
return delta || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
this.liveFromEcho = this.liveFromEcho || fromEcho;
|
||||||
|
|
||||||
|
return (this.status.transcript || '') + delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.liveFromEcho && (payload.status ?? this.status.status) === 'processing') {
|
||||||
|
return this.status.transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.transcript === 'string') {
|
||||||
|
const current = this.status.transcript || '';
|
||||||
|
|
||||||
|
if (payload.transcript.length < current.length) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.status.transcript;
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeWhisper(payload, fromEcho = false) {
|
||||||
|
const snapshot = payload.whisper;
|
||||||
|
const delta = payload.whisper_delta || payload.whisperDelta;
|
||||||
|
|
||||||
|
if (snapshot && Array.isArray(snapshot.segments) && ! delta) {
|
||||||
|
if (fromEcho || (this.liveFromEcho && this.whisper.segments.length > snapshot.segments.length)) {
|
||||||
|
return this.whisper;
|
||||||
|
}
|
||||||
|
|
||||||
|
return whisperSnapshot(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! delta) {
|
||||||
|
return this.whisper;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromEcho) {
|
||||||
|
this.liveFromEcho = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = whisperSnapshot(this.whisper);
|
||||||
|
|
||||||
|
if (delta.language) {
|
||||||
|
next.language = delta.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta.duration != null) {
|
||||||
|
next.duration = delta.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(delta.segments) && delta.segments.length) {
|
||||||
|
next.segments = delta.segments;
|
||||||
|
} else if (delta.segment) {
|
||||||
|
next.segments = this.appendSegment(next.segments, delta.segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(delta.logprobs) && delta.logprobs.length) {
|
||||||
|
next.logprobs = next.logprobs.concat(delta.logprobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(delta.words) && delta.words.length && next.segments.length) {
|
||||||
|
const last = { ...next.segments[next.segments.length - 1] };
|
||||||
|
last.words = (last.words || []).concat(delta.words);
|
||||||
|
next.segments = next.segments.slice(0, -1).concat([last]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
|
||||||
|
appendSegment(segments, segment) {
|
||||||
|
const key = segmentKey(segment);
|
||||||
|
|
||||||
|
if (segments.some((row) => segmentKey(row) === key)) {
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.concat([segment]);
|
||||||
|
},
|
||||||
|
|
||||||
|
seekTo(seconds) {
|
||||||
|
const player = this.$refs.player;
|
||||||
|
|
||||||
|
if (! player || seconds == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.currentTime = Number(seconds);
|
||||||
|
player.play().catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
mergePercent(payload, status) {
|
||||||
|
if (status !== 'processing') {
|
||||||
|
return payload.percent !== undefined ? payload.percent : this.status.percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incoming = payload.percent;
|
||||||
|
const current = Number(this.status.percent) || 0;
|
||||||
|
|
||||||
|
if (incoming == null) {
|
||||||
|
return this.status.percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(current, Number(incoming) || 0);
|
||||||
|
},
|
||||||
|
|
||||||
beginTick() {
|
beginTick() {
|
||||||
if (this.tickTimer) {
|
if (this.tickTimer) {
|
||||||
return;
|
return;
|
||||||
@@ -167,21 +393,6 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
beginHydratePoll() {
|
|
||||||
if (this.hydrateTimer || ! this.statusUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000);
|
|
||||||
},
|
|
||||||
|
|
||||||
stopHydratePoll() {
|
|
||||||
if (this.hydrateTimer) {
|
|
||||||
clearInterval(this.hydrateTimer);
|
|
||||||
this.hydrateTimer = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
tickElapsed() {
|
tickElapsed() {
|
||||||
if (! this.status.is_active || this.status.elapsed_seconds == null) {
|
if (! this.status.is_active || this.status.elapsed_seconds == null) {
|
||||||
return;
|
return;
|
||||||
@@ -220,12 +431,14 @@ export function transcriptionMonitor({ statusUrl, initial }) {
|
|||||||
formatElapsed,
|
formatElapsed,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
|
formatClock,
|
||||||
|
wordConfidenceClass,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Index-page Alpine component: inline audio player only.
|
* Index-page Alpine component: inline audio player only.
|
||||||
* Status/progress refresh via Livewire Echo + wire:poll.
|
* Status/progress refresh via wire:poll. Live transcript on the show page uses Echo.
|
||||||
*/
|
*/
|
||||||
export function recordingsIndex() {
|
export function recordingsIndex() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
:status="$recording->transcription_status"
|
:status="$recording->transcription_status"
|
||||||
:label="$recording->transcriptionStatusLabel()"
|
:label="$recording->transcriptionStatusLabel()"
|
||||||
/>
|
/>
|
||||||
@if ($recording->transcription_status === 'processing' && $recording->transcription_percent !== null)
|
@if ($recording->transcription_status === 'processing' && $recording->transcription_percent > 0)
|
||||||
<span class="tabular-nums text-xs text-zinc-500 dark:text-zinc-400">
|
<span class="tabular-nums text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
{{ $recording->transcription_percent }}%
|
{{ $recording->transcription_percent }}%
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<div
|
<div
|
||||||
@if ($recording->isTranscribing())
|
@if ($recording->transcription_status === 'pending')
|
||||||
wire:poll.2s.visible
|
wire:poll.2s.visible
|
||||||
@endif
|
@endif
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}-{{ md5((string) $recording->transcription_progress) }}-{{ $recording->transcribed_at?->timestamp }}"
|
wire:ignore.self
|
||||||
|
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcribed_at?->timestamp }}"
|
||||||
x-data="transcriptionMonitor(@js([
|
x-data="transcriptionMonitor(@js([
|
||||||
'statusUrl' => route('recordings.transcription-status', $recording),
|
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||||
|
'userId' => (int) $recording->user_id,
|
||||||
'initial' => $recording->transcriptionStatusPayload(),
|
'initial' => $recording->transcriptionStatusPayload(),
|
||||||
]))"
|
]))"
|
||||||
x-init="
|
x-init="
|
||||||
@@ -164,7 +166,7 @@
|
|||||||
<flux:callout.text>
|
<flux:callout.text>
|
||||||
<span
|
<span
|
||||||
class="tabular-nums"
|
class="tabular-nums"
|
||||||
x-show="status.status === 'processing' && status.percent != null"
|
x-show="status.status === 'processing' && Number(status.percent) > 0"
|
||||||
x-cloak
|
x-cloak
|
||||||
>
|
>
|
||||||
<span x-text="status.percent + '%'"></span>
|
<span x-text="status.percent + '%'"></span>
|
||||||
@@ -174,22 +176,30 @@
|
|||||||
</flux:callout.text>
|
</flux:callout.text>
|
||||||
</flux:callout>
|
</flux:callout>
|
||||||
|
|
||||||
<div class="mt-3" x-show="status.status === 'processing'" x-cloak>
|
<div class="mt-3" x-show="status.status === 'processing' && Number(status.percent) > 0" x-cloak>
|
||||||
<flux:progress color="amber" x-bind:value="status.percent || 0" />
|
<flux:progress color="amber" x-bind:value="status.percent || 0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<div
|
||||||
|
wire:ignore
|
||||||
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
|
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
|
||||||
x-show="status.transcript"
|
x-show="status.transcript && !whisper.segments.length"
|
||||||
x-cloak
|
|
||||||
x-text="status.transcript"
|
x-text="status.transcript"
|
||||||
></p>
|
></div>
|
||||||
|
|
||||||
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
<li>
|
<li>
|
||||||
Engine:
|
Engine:
|
||||||
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
<span class="font-medium" x-text="status.driver_label || '—'"></span>
|
||||||
</li>
|
</li>
|
||||||
|
<li x-show="whisper.language">
|
||||||
|
Detected language:
|
||||||
|
<span class="font-medium uppercase" x-text="whisper.language"></span>
|
||||||
|
</li>
|
||||||
|
<li x-show="whisper.duration">
|
||||||
|
Whisper duration:
|
||||||
|
<span class="font-medium" x-text="formatClock(whisper.duration)"></span>
|
||||||
|
</li>
|
||||||
<template x-if="status.duration_seconds">
|
<template x-if="status.duration_seconds">
|
||||||
<li>
|
<li>
|
||||||
Audio length:
|
Audio length:
|
||||||
@@ -219,7 +229,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="!status.is_active && status.has_transcript" x-cloak>
|
<div x-show="!status.is_active && status.has_transcript" x-cloak>
|
||||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="status.transcript"></p>
|
<p
|
||||||
|
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
|
||||||
|
x-show="!whisper.segments.length"
|
||||||
|
x-text="status.transcript"
|
||||||
|
></p>
|
||||||
<flux:text
|
<flux:text
|
||||||
class="mt-4 text-xs"
|
class="mt-4 text-xs"
|
||||||
x-show="status.transcribed_at"
|
x-show="status.transcribed_at"
|
||||||
@@ -230,6 +244,83 @@
|
|||||||
></flux:text>
|
></flux:text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
wire:ignore
|
||||||
|
class="mt-4 space-y-4"
|
||||||
|
x-show="whisper.language || whisper.segments.length || whisper.logprobs.length"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-400"
|
||||||
|
x-show="!status.is_active && (whisper.language || whisper.duration)"
|
||||||
|
>
|
||||||
|
<span x-show="whisper.language">
|
||||||
|
Language
|
||||||
|
<span class="font-medium uppercase text-zinc-800 dark:text-zinc-100" x-text="whisper.language"></span>
|
||||||
|
</span>
|
||||||
|
<span x-show="whisper.duration">
|
||||||
|
· Duration
|
||||||
|
<span class="font-medium text-zinc-800 dark:text-zinc-100" x-text="formatClock(whisper.duration)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="(segment, index) in whisper.segments" :key="segment.id ?? (segment.start + '-' + index)">
|
||||||
|
<div class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span class="tabular-nums">
|
||||||
|
<span x-text="formatClock(segment.start)"></span>
|
||||||
|
–
|
||||||
|
<span x-text="formatClock(segment.end)"></span>
|
||||||
|
</span>
|
||||||
|
<span x-show="segment.avg_logprob != null">
|
||||||
|
avg logprob
|
||||||
|
<span class="font-medium text-zinc-700 dark:text-zinc-200" x-text="Number(segment.avg_logprob).toFixed(3)"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="segment.text"></p>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1" x-show="segment.words && segment.words.length">
|
||||||
|
<template x-for="(word, wordIndex) in (segment.words || [])" :key="wordIndex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-xs tabular-nums"
|
||||||
|
:class="wordConfidenceClass(word.probability)"
|
||||||
|
:title="(word.start != null ? formatClock(word.start) : '') + (word.probability != null ? (' · ' + Math.round(word.probability * 100) + '%') : '')"
|
||||||
|
@click="seekTo(word.start)"
|
||||||
|
x-text="word.word"
|
||||||
|
></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-2 text-xs text-zinc-500 dark:text-zinc-400" x-show="segment.tokens && segment.tokens.length">
|
||||||
|
<summary class="cursor-pointer select-none">Tokens</summary>
|
||||||
|
<p class="mt-1 break-all font-mono" x-text="(segment.tokens || []).join(' ')"></p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400" x-show="segment.no_speech_prob != null">
|
||||||
|
no-speech
|
||||||
|
<span class="font-medium" x-text="Number(segment.no_speech_prob).toFixed(3)"></span>
|
||||||
|
<span x-show="segment.compression_ratio != null">
|
||||||
|
· compression
|
||||||
|
<span class="font-medium" x-text="Number(segment.compression_ratio).toFixed(2)"></span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<details class="text-xs text-zinc-500 dark:text-zinc-400" x-show="whisper.logprobs.length">
|
||||||
|
<summary class="cursor-pointer select-none">Token logprobs</summary>
|
||||||
|
<ul class="mt-2 space-y-1 font-mono">
|
||||||
|
<template x-for="(row, index) in whisper.logprobs" :key="index">
|
||||||
|
<li>
|
||||||
|
<span x-text="row.token || '—'"></span>
|
||||||
|
<span x-show="row.logprob != null" x-text="' ' + Number(row.logprob).toFixed(3)"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<flux:text
|
<flux:text
|
||||||
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
@@ -204,6 +204,35 @@ class RecordingUploadTest extends TestCase
|
|||||||
->assertJsonPath('driver_label', 'Local (faster-whisper)');
|
->assertJsonPath('driver_label', 'Local (faster-whisper)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_transcription_status_endpoint_includes_whisper_snapshot(): void
|
||||||
|
{
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Verbose status',
|
||||||
|
'original_filename' => 'live.mp3',
|
||||||
|
'file_path' => 'recordings/live.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_progress' => 'Transcribing locally…',
|
||||||
|
'transcription_percent' => 40,
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
'transcription_verbose' => [
|
||||||
|
'language' => 'en',
|
||||||
|
'duration' => 8.5,
|
||||||
|
'segments' => [
|
||||||
|
['text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson(route('recordings.transcription-status', $recording))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('whisper.language', 'en')
|
||||||
|
->assertJsonPath('whisper.duration', 8.5)
|
||||||
|
->assertJsonPath('whisper.segments.0.text', 'Hello');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_transcription_service_calls_local_whisper_http(): void
|
public function test_transcription_service_calls_local_whisper_http(): void
|
||||||
{
|
{
|
||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
@@ -232,7 +261,10 @@ class RecordingUploadTest extends TestCase
|
|||||||
|
|
||||||
return str_contains($request->url(), '/audio/transcriptions')
|
return str_contains($request->url(), '/audio/transcriptions')
|
||||||
&& str_contains($body, 'name="stream"')
|
&& str_contains($body, 'name="stream"')
|
||||||
&& str_contains($body, 'true');
|
&& str_contains($body, 'true')
|
||||||
|
&& str_contains($body, 'verbose_json')
|
||||||
|
&& str_contains($body, 'timestamp_granularities[]')
|
||||||
|
&& str_contains($body, 'word');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +273,9 @@ class RecordingUploadTest extends TestCase
|
|||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||||
|
|
||||||
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \"}\n\n"
|
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \",\"end\":10}\n\n"
|
||||||
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\"}\n\n"
|
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\",\"end\":30}\n\n"
|
||||||
."data: {\"type\":\"transcript.text.done\",\"text\":\"Hello from whisper.\"}\n\n";
|
."data: {\"type\":\"transcript.text.done\",\"text\":\"Hello from whisper.\",\"end\":40}\n\n";
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||||
@@ -275,7 +307,10 @@ class RecordingUploadTest extends TestCase
|
|||||||
$this->assertSame('Hello from whisper.', $text);
|
$this->assertSame('Hello from whisper.', $text);
|
||||||
$this->assertNotEmpty(array_filter($partials, fn (array $row): bool => $row['partial'] === 'Hello '));
|
$this->assertNotEmpty(array_filter($partials, fn (array $row): bool => $row['partial'] === 'Hello '));
|
||||||
$this->assertSame('Hello from whisper.', $partials[array_key_last($partials)]['partial'] ?? $text);
|
$this->assertSame('Hello from whisper.', $partials[array_key_last($partials)]['partial'] ?? $text);
|
||||||
$this->assertGreaterThanOrEqual(50, max(array_column($partials, 'percent')));
|
$this->assertContains(0, array_column($partials, 'percent'));
|
||||||
|
$this->assertContains(25, array_column($partials, 'percent'));
|
||||||
|
$this->assertContains(99, array_column($partials, 'percent'));
|
||||||
|
$this->assertLessThan(100, max(array_column($partials, 'percent')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_transcription_service_streams_legacy_segments_with_timestamp_percent(): void
|
public function test_transcription_service_streams_legacy_segments_with_timestamp_percent(): void
|
||||||
@@ -320,6 +355,157 @@ class RecordingUploadTest extends TestCase
|
|||||||
$this->assertContains(99, $percents);
|
$this->assertContains(99, $percents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_transcription_service_streams_verbose_segment_metadata(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||||
|
|
||||||
|
$segment = [
|
||||||
|
'id' => 0,
|
||||||
|
'start' => 0.0,
|
||||||
|
'end' => 20.0,
|
||||||
|
'text' => 'Hello from whisper.',
|
||||||
|
'tokens' => [50364, 2425],
|
||||||
|
'avg_logprob' => -0.18,
|
||||||
|
'compression_ratio' => 1.2,
|
||||||
|
'no_speech_prob' => 0.02,
|
||||||
|
'language' => 'en',
|
||||||
|
'duration' => 40.0,
|
||||||
|
'words' => [
|
||||||
|
['word' => 'Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.96],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$sse = 'data: '.json_encode($segment)."\n\n";
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Verbose stream',
|
||||||
|
'original_filename' => 'long.mp3',
|
||||||
|
'file_path' => 'recordings/long.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'duration_seconds' => 40,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$whispers = [];
|
||||||
|
|
||||||
|
$text = app(TranscriptionService::class)->transcribe(
|
||||||
|
$recording,
|
||||||
|
function (string $message, int $percent, ?string $partial = null, ?array $whisper = null) use (&$whispers): void {
|
||||||
|
if ($whisper !== null) {
|
||||||
|
$whispers[] = $whisper;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('Hello from whisper.', $text);
|
||||||
|
$this->assertNotEmpty($whispers);
|
||||||
|
$this->assertSame('en', $whispers[0]['language']);
|
||||||
|
$this->assertSame(-0.18, $whispers[0]['segment']['avg_logprob']);
|
||||||
|
$this->assertSame('Hello', $whispers[0]['segment']['words'][0]['word']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_transcription_service_does_not_invent_percent_when_events_lack_timestamps(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||||
|
|
||||||
|
$this->freezeTime();
|
||||||
|
|
||||||
|
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \"}\n\n"
|
||||||
|
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\"}\n\n";
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'No timestamp percent',
|
||||||
|
'original_filename' => 'long.mp3',
|
||||||
|
'file_path' => 'recordings/long.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'duration_seconds' => 40,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now()->subSeconds(20),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$partials = [];
|
||||||
|
|
||||||
|
$text = app(TranscriptionService::class)->transcribe(
|
||||||
|
$recording,
|
||||||
|
function (string $message, int $percent, ?string $partial = null) use (&$partials): void {
|
||||||
|
$partials[] = [
|
||||||
|
'percent' => $percent,
|
||||||
|
'partial' => $partial,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('Hello from whisper.', $text);
|
||||||
|
$this->assertContains('Hello ', array_column($partials, 'partial'));
|
||||||
|
$this->assertContains('Hello from whisper.', array_column($partials, 'partial'));
|
||||||
|
$this->assertSame([0], array_values(array_unique(array_column($partials, 'percent'))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_transcription_service_uses_word_timestamps_for_percent(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
|
||||||
|
|
||||||
|
$sse = 'data: '.json_encode([
|
||||||
|
'type' => 'transcript.text.delta',
|
||||||
|
'delta' => 'Hello ',
|
||||||
|
'words' => [
|
||||||
|
['word' => 'Hello', 'start' => 0.0, 'end' => 10.0],
|
||||||
|
],
|
||||||
|
])."\n\n"
|
||||||
|
.'data: '.json_encode([
|
||||||
|
'type' => 'transcript.text.delta',
|
||||||
|
'delta' => 'from whisper.',
|
||||||
|
'words' => [
|
||||||
|
['word' => 'from', 'start' => 10.0, 'end' => 20.0],
|
||||||
|
['word' => 'whisper.', 'start' => 20.0, 'end' => 30.0],
|
||||||
|
],
|
||||||
|
])."\n\n";
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Word timestamps',
|
||||||
|
'original_filename' => 'long.mp3',
|
||||||
|
'file_path' => 'recordings/long.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'duration_seconds' => 40,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$percents = [];
|
||||||
|
|
||||||
|
$text = app(TranscriptionService::class)->transcribe(
|
||||||
|
$recording,
|
||||||
|
function (string $message, int $percent, ?string $partial = null) use (&$percents): void {
|
||||||
|
$percents[] = $percent;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('Hello from whisper.', $text);
|
||||||
|
$this->assertContains(25, $percents);
|
||||||
|
$this->assertContains(75, $percents);
|
||||||
|
$this->assertLessThan(100, max($percents));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_report_progress_persists_partial_transcript_without_completing(): void
|
public function test_report_progress_persists_partial_transcript_without_completing(): void
|
||||||
{
|
{
|
||||||
$recording = Recording::query()->create([
|
$recording = Recording::query()->create([
|
||||||
|
|||||||
@@ -125,12 +125,37 @@ class IndexTest extends TestCase
|
|||||||
|
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
->assertSee('wire:poll', false)
|
->assertSee('wire:poll', false)
|
||||||
|
->assertDontSee('echo-private', false)
|
||||||
->assertSee('Transcribing')
|
->assertSee('Transcribing')
|
||||||
->assertSee('40%')
|
->assertSee('40%')
|
||||||
->assertDontSee('Transcribing locally…')
|
->assertDontSee('Transcribing locally…')
|
||||||
->assertSeeHtml('bg-amber-400');
|
->assertSeeHtml('bg-amber-400');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_processing_recordings_do_not_show_zero_percent(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Recording::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Just started',
|
||||||
|
'original_filename' => 'fresh.mp3',
|
||||||
|
'file_path' => 'recordings/fresh.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_progress' => 'Transcribing locally…',
|
||||||
|
'transcription_percent' => 0,
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->assertSee('Just started')
|
||||||
|
->assertSee('Transcribing')
|
||||||
|
->assertDontSee('0%');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_queued_recordings_do_not_show_fake_percent(): void
|
public function test_queued_recordings_do_not_show_fake_percent(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class ShowTest extends TestCase
|
|||||||
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
|
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_show_polls_while_transcription_is_active(): void
|
public function test_show_polls_while_transcription_is_queued(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@@ -71,7 +71,7 @@ class ShowTest extends TestCase
|
|||||||
'original_filename' => 'active.mp3',
|
'original_filename' => 'active.mp3',
|
||||||
'file_path' => 'recordings/active.mp3',
|
'file_path' => 'recordings/active.mp3',
|
||||||
'file_size_bytes' => 100,
|
'file_size_bytes' => 100,
|
||||||
'transcription_status' => 'processing',
|
'transcription_status' => 'pending',
|
||||||
'transcription_progress' => 'Queued — waiting to start…',
|
'transcription_progress' => 'Queued — waiting to start…',
|
||||||
'transcription_percent' => null,
|
'transcription_percent' => null,
|
||||||
'transcription_driver' => 'local',
|
'transcription_driver' => 'local',
|
||||||
@@ -80,9 +80,33 @@ class ShowTest extends TestCase
|
|||||||
|
|
||||||
Livewire::test(Show::class, ['recording' => $recording])
|
Livewire::test(Show::class, ['recording' => $recording])
|
||||||
->assertSee('wire:poll', false)
|
->assertSee('wire:poll', false)
|
||||||
|
->assertDontSee('echo-private', false)
|
||||||
->assertSee('Queued — waiting to start…');
|
->assertSee('Queued — waiting to start…');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_show_does_not_poll_while_transcription_is_processing(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Live stream',
|
||||||
|
'original_filename' => 'active.mp3',
|
||||||
|
'file_path' => 'recordings/active.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_progress' => 'Transcribing locally…',
|
||||||
|
'transcription_percent' => 20,
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Show::class, ['recording' => $recording])
|
||||||
|
->assertDontSee('wire:poll', false)
|
||||||
|
->assertSee('userId', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_show_page_includes_partial_transcript_while_processing(): void
|
public function test_show_page_includes_partial_transcript_while_processing(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -107,6 +131,44 @@ class ShowTest extends TestCase
|
|||||||
->assertSee('status.transcript', false);
|
->assertSee('status.transcript', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_show_page_includes_live_whisper_segment_bindings(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Verbose live',
|
||||||
|
'original_filename' => 'active.mp3',
|
||||||
|
'file_path' => 'recordings/active.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
'transcript' => 'Hello',
|
||||||
|
'transcription_verbose' => [
|
||||||
|
'language' => 'en',
|
||||||
|
'duration' => 4.0,
|
||||||
|
'segments' => [
|
||||||
|
[
|
||||||
|
'text' => 'Hello',
|
||||||
|
'start' => 0.0,
|
||||||
|
'end' => 1.0,
|
||||||
|
'avg_logprob' => -0.1,
|
||||||
|
'words' => [
|
||||||
|
['word' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'probability' => 0.9],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Show::class, ['recording' => $recording])
|
||||||
|
->assertSee('whisper.segments', false)
|
||||||
|
->assertSee('whisper.language', false)
|
||||||
|
->assertSee('Hello');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_show_does_not_poll_when_transcription_is_idle(): void
|
public function test_show_does_not_poll_when_transcription_is_idle(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -126,7 +188,7 @@ class ShowTest extends TestCase
|
|||||||
->assertDontSee('wire:poll', false);
|
->assertDontSee('wire:poll', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_show_refreshes_recording_on_transcription_broadcast(): void
|
public function test_show_poll_refresh_picks_up_processing_status(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@@ -152,12 +214,68 @@ class ShowTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$component
|
$component
|
||||||
->call('onTranscriptionUpdated', [
|
->call('$refresh')
|
||||||
'id' => $recording->id,
|
|
||||||
'status' => 'processing',
|
|
||||||
])
|
|
||||||
->assertSet('recording.transcription_status', 'processing')
|
->assertSet('recording.transcription_status', 'processing')
|
||||||
->assertSet('recording.transcription_percent', 40)
|
->assertSet('recording.transcription_percent', 40)
|
||||||
->assertSee('Transcribing locally…');
|
->assertSee('Transcribing locally…');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_show_poll_refresh_picks_up_completed_transcript(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Almost done',
|
||||||
|
'original_filename' => 'live.mp3',
|
||||||
|
'file_path' => 'recordings/live.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_progress' => 'Transcribing locally…',
|
||||||
|
'transcription_percent' => 90,
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
'transcript' => 'Partial',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::test(Show::class, ['recording' => $recording]);
|
||||||
|
|
||||||
|
$recording->update([
|
||||||
|
'transcription_status' => 'done',
|
||||||
|
'transcription_percent' => 100,
|
||||||
|
'transcript' => 'Finished live transcript',
|
||||||
|
'transcribed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->call('$refresh')
|
||||||
|
->assertSet('recording.transcription_status', 'done')
|
||||||
|
->assertSet('recording.transcription_percent', 100)
|
||||||
|
->assertSee('Finished live transcript');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_show_alpine_root_key_does_not_include_percent(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Stable key',
|
||||||
|
'original_filename' => 'live.mp3',
|
||||||
|
'file_path' => 'recordings/live.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_progress' => 'Transcribing locally…',
|
||||||
|
'transcription_percent' => 62,
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Show::class, ['recording' => $recording])
|
||||||
|
->assertSee('wire:ignore.self', false)
|
||||||
|
->assertSee('transcription-ui-'.$recording->id.'-processing-', false)
|
||||||
|
->assertDontSee('transcription-ui-'.$recording->id.'-processing-62', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,4 +160,192 @@ class TranscriptionBroadcastTest extends TestCase
|
|||||||
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
|
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
|
||||||
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
|
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_broadcast_payload_omits_transcript_text(): void
|
||||||
|
{
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Long text',
|
||||||
|
'original_filename' => 'long.mp3',
|
||||||
|
'file_path' => 'recordings/long.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_percent' => 70,
|
||||||
|
'transcript' => str_repeat('word ', 5000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = (new RecordingTranscriptionUpdated($recording))->broadcastWith();
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('transcript', $payload);
|
||||||
|
$this->assertArrayNotHasKey('whisper', $payload);
|
||||||
|
$this->assertTrue($payload['has_transcript']);
|
||||||
|
$this->assertSame(70, $payload['percent']);
|
||||||
|
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_report_progress_broadcasts_transcript_deltas_for_append(): void
|
||||||
|
{
|
||||||
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Deltas',
|
||||||
|
'original_filename' => 'delta.mp3',
|
||||||
|
'file_path' => 'recordings/delta.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
'transcript' => 'Previous finished transcript',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording->reportProgress('Transcribing locally…', 55, partialTranscript: 'Hello ');
|
||||||
|
|
||||||
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||||
|
$payload = $event->broadcastWith();
|
||||||
|
|
||||||
|
return ($payload['transcript_delta'] ?? null) === 'Hello '
|
||||||
|
&& ($payload['transcript_replace'] ?? false) === true
|
||||||
|
&& ! array_key_exists('transcript', $payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
$recording->refresh()->reportProgress('Transcribing locally…', 60, partialTranscript: 'Hello from whisper.');
|
||||||
|
|
||||||
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||||
|
$payload = $event->broadcastWith();
|
||||||
|
|
||||||
|
return ($payload['transcript_delta'] ?? null) === 'from whisper.'
|
||||||
|
&& ($payload['transcript_replace'] ?? true) === false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_report_progress_broadcasts_whisper_delta_without_full_verbose_snapshot(): void
|
||||||
|
{
|
||||||
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Verbose live',
|
||||||
|
'original_filename' => 'delta.mp3',
|
||||||
|
'file_path' => 'recordings/delta.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: 'Hello', whisperDelta: [
|
||||||
|
'language' => 'en',
|
||||||
|
'duration' => 12.5,
|
||||||
|
'segment' => [
|
||||||
|
'id' => 0,
|
||||||
|
'start' => 0.0,
|
||||||
|
'end' => 1.2,
|
||||||
|
'text' => 'Hello',
|
||||||
|
'avg_logprob' => -0.2,
|
||||||
|
'words' => [
|
||||||
|
['word' => 'Hello', 'start' => 0.0, 'end' => 1.2, 'probability' => 0.91],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording->refresh();
|
||||||
|
$this->assertSame('en', $recording->transcription_verbose['language']);
|
||||||
|
$this->assertCount(1, $recording->transcription_verbose['segments']);
|
||||||
|
|
||||||
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||||
|
$payload = $event->broadcastWith();
|
||||||
|
|
||||||
|
return ($payload['whisper_delta']['language'] ?? null) === 'en'
|
||||||
|
&& ($payload['whisper_delta']['segment']['text'] ?? null) === 'Hello'
|
||||||
|
&& ! array_key_exists('whisper', $payload)
|
||||||
|
&& ! array_key_exists('transcript', $payload)
|
||||||
|
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_full_whisper_segments_snapshot_is_persisted_but_not_broadcast(): void
|
||||||
|
{
|
||||||
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Snapshot',
|
||||||
|
'original_filename' => 'snap.mp3',
|
||||||
|
'file_path' => 'recordings/snap.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording->reportProgress('Transcribing locally…', 90, partialTranscript: 'Hello world', whisperDelta: [
|
||||||
|
'language' => 'en',
|
||||||
|
'duration' => 4.2,
|
||||||
|
'segments' => [
|
||||||
|
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
||||||
|
['id' => 1, 'text' => 'world', 'start' => 1.0, 'end' => 2.0],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording->refresh();
|
||||||
|
$this->assertCount(2, $recording->transcription_verbose['segments']);
|
||||||
|
|
||||||
|
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
|
||||||
|
$payload = $event->broadcastWith();
|
||||||
|
|
||||||
|
return ($payload['whisper_delta']['language'] ?? null) === 'en'
|
||||||
|
&& ($payload['whisper_delta']['duration'] ?? null) === 4.2
|
||||||
|
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? [])
|
||||||
|
&& ! array_key_exists('whisper', $payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_report_progress_replaces_verbose_segments_when_snapshot_arrives(): void
|
||||||
|
{
|
||||||
|
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||||
|
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Replace segments',
|
||||||
|
'original_filename' => 'replace.mp3',
|
||||||
|
'file_path' => 'recordings/replace.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
'transcription_driver' => 'local',
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recording->reportProgress('Transcribing locally…', 40, whisperDelta: [
|
||||||
|
'segment' => ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
|
||||||
|
]);
|
||||||
|
$recording->refresh()->reportProgress('Transcribing locally…', 99, whisperDelta: [
|
||||||
|
'language' => 'en',
|
||||||
|
'segments' => [
|
||||||
|
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'avg_logprob' => -0.1],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $recording->refresh()->transcription_verbose['segments']);
|
||||||
|
$this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_oversized_transcript_delta_is_not_broadcast(): void
|
||||||
|
{
|
||||||
|
$recording = Recording::query()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'title' => 'Huge delta',
|
||||||
|
'original_filename' => 'huge.mp3',
|
||||||
|
'file_path' => 'recordings/huge.mp3',
|
||||||
|
'file_size_bytes' => 100,
|
||||||
|
'transcription_status' => 'processing',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$huge = str_repeat('a', RecordingTranscriptionUpdated::MAX_DELTA_BYTES + 1);
|
||||||
|
$payload = (new RecordingTranscriptionUpdated($recording, $huge, true))->broadcastWith();
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('transcript_delta', $payload);
|
||||||
|
$this->assertArrayNotHasKey('transcript', $payload);
|
||||||
|
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,74 @@ class WhisperTranscriptionStreamTest extends TestCase
|
|||||||
$this->assertTrue($event['legacy']);
|
$this->assertTrue($event['legacy']);
|
||||||
$this->assertSame('First segment', $event['append']);
|
$this->assertSame('First segment', $event['append']);
|
||||||
$this->assertSame(12.5, $event['end']);
|
$this->assertSame(12.5, $event['end']);
|
||||||
|
$this->assertSame('First segment', $event['whisper']['segment']['text']);
|
||||||
|
$this->assertSame(12.5, $event['whisper']['segment']['end']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_parses_typed_segment_events_as_legacy_chunks(): void
|
||||||
|
{
|
||||||
|
$event = $this->stream->parseEvent(json_encode([
|
||||||
|
'type' => 'segment',
|
||||||
|
'start' => 0.0,
|
||||||
|
'end' => 2.4,
|
||||||
|
'text' => 'Hello, how are you?',
|
||||||
|
'avg_logprob' => -0.12,
|
||||||
|
'words' => [
|
||||||
|
['word' => 'Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.99],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertTrue($event['legacy']);
|
||||||
|
$this->assertSame('Hello, how are you?', $event['append']);
|
||||||
|
$this->assertSame(2.4, $event['end']);
|
||||||
|
$this->assertSame(-0.12, $event['whisper']['segment']['avg_logprob']);
|
||||||
|
$this->assertSame('Hello', $event['whisper']['segment']['words'][0]['word']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_parses_verbose_segment_words_language_and_logprobs(): void
|
||||||
|
{
|
||||||
|
$json = json_encode([
|
||||||
|
'language' => 'en',
|
||||||
|
'duration' => 8.5,
|
||||||
|
'text' => 'Hello world',
|
||||||
|
'id' => 0,
|
||||||
|
'start' => 0.0,
|
||||||
|
'end' => 1.6,
|
||||||
|
'tokens' => [50364, 2425, 1002],
|
||||||
|
'avg_logprob' => -0.21,
|
||||||
|
'compression_ratio' => 1.15,
|
||||||
|
'no_speech_prob' => 0.01,
|
||||||
|
'temperature' => 0.0,
|
||||||
|
'words' => [
|
||||||
|
['word' => 'Hello', 'start' => 0.0, 'end' => 0.6, 'probability' => 0.94],
|
||||||
|
['word' => 'world', 'start' => 0.7, 'end' => 1.5, 'probability' => 0.88],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event = $this->stream->parseEvent($json);
|
||||||
|
|
||||||
|
$this->assertSame('en', $event['whisper']['language']);
|
||||||
|
$this->assertSame(8.5, $event['whisper']['duration']);
|
||||||
|
$this->assertSame([50364, 2425, 1002], $event['whisper']['segment']['tokens']);
|
||||||
|
$this->assertSame(-0.21, $event['whisper']['segment']['avg_logprob']);
|
||||||
|
$this->assertCount(2, $event['whisper']['segment']['words']);
|
||||||
|
$this->assertSame('Hello', $event['whisper']['segment']['words'][0]['word']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_parses_openai_delta_logprobs_without_treating_them_as_a_segment(): void
|
||||||
|
{
|
||||||
|
$event = $this->stream->parseEvent(json_encode([
|
||||||
|
'type' => 'transcript.text.delta',
|
||||||
|
'delta' => 'Hel',
|
||||||
|
'logprobs' => [
|
||||||
|
['token' => 'Hel', 'logprob' => -0.05],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('Hel', $event['append']);
|
||||||
|
$this->assertArrayNotHasKey('segment', $event['whisper']);
|
||||||
|
$this->assertSame('Hel', $event['whisper']['logprobs'][0]['token']);
|
||||||
|
$this->assertSame(-0.05, $event['whisper']['logprobs'][0]['logprob']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_applies_delta_then_done_replace(): void
|
public function test_applies_delta_then_done_replace(): void
|
||||||
@@ -58,6 +126,35 @@ class WhisperTranscriptionStreamTest extends TestCase
|
|||||||
$this->assertSame('Hello world', $text);
|
$this->assertSame('Hello world', $text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_parses_verbose_json_segments_array(): void
|
||||||
|
{
|
||||||
|
$event = $this->stream->parseEvent(json_encode([
|
||||||
|
'task' => 'transcribe',
|
||||||
|
'language' => 'en',
|
||||||
|
'duration' => 4.2,
|
||||||
|
'text' => 'Hello world',
|
||||||
|
'segments' => [
|
||||||
|
[
|
||||||
|
'id' => 0,
|
||||||
|
'start' => 0.0,
|
||||||
|
'end' => 4.2,
|
||||||
|
'text' => ' Hello world',
|
||||||
|
'avg_logprob' => -0.3,
|
||||||
|
'tokens' => [1, 2],
|
||||||
|
'words' => [
|
||||||
|
['word' => ' Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.9],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame('Hello world', $event['replace']);
|
||||||
|
$this->assertSame(4.2, $event['end']);
|
||||||
|
$this->assertSame('en', $event['whisper']['language']);
|
||||||
|
$this->assertCount(1, $event['whisper']['segments']);
|
||||||
|
$this->assertSame(-0.3, $event['whisper']['segments'][0]['avg_logprob']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_joins_legacy_segments_with_spaces(): void
|
public function test_joins_legacy_segments_with_spaces(): void
|
||||||
{
|
{
|
||||||
$first = $this->stream->parseEvent('{"text":"Hello from","end":10}');
|
$first = $this->stream->parseEvent('{"text":"Hello from","end":10}');
|
||||||
@@ -88,4 +185,30 @@ class WhisperTranscriptionStreamTest extends TestCase
|
|||||||
$this->assertSame(['{"text":"Last"}'], $payloads);
|
$this->assertSame(['{"text":"Last"}'], $payloads);
|
||||||
$this->assertSame('', $buffer);
|
$this->assertSame('', $buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_openai_delta_uses_word_end_as_audio_position(): void
|
||||||
|
{
|
||||||
|
$event = $this->stream->parseEvent(json_encode([
|
||||||
|
'type' => 'transcript.text.delta',
|
||||||
|
'delta' => 'Hello',
|
||||||
|
'words' => [
|
||||||
|
['word' => 'Hello', 'start' => 0.0, 'end' => 1.6, 'probability' => 0.9],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertSame(1.6, $event['end']);
|
||||||
|
$this->assertSame('Hello', $event['whisper']['words'][0]['word']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_file_duration_is_not_used_as_audio_position(): void
|
||||||
|
{
|
||||||
|
$event = $this->stream->parseEvent(json_encode([
|
||||||
|
'type' => 'transcript.text.delta',
|
||||||
|
'delta' => 'Hello',
|
||||||
|
'duration' => 40.0,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertNull($event['end']);
|
||||||
|
$this->assertSame(40.0, $event['whisper']['duration']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user