Stream Whisper verbose metadata live without overflowing Reverb.

Persist accumulated verbose_json on the recording and broadcast only transcript/whisper deltas so the show page can render language, segments, word timestamps, and confidence while a run is in progress.
This commit is contained in:
ben
2026-08-13 15:55:51 +02:00
parent 4c73620458
commit 187b6b5d12
16 changed files with 1646 additions and 189 deletions
+191 -5
View File
@@ -204,6 +204,35 @@ class RecordingUploadTest extends TestCase
->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
{
Storage::fake('local');
@@ -232,7 +261,10 @@ class RecordingUploadTest extends TestCase
return str_contains($request->url(), '/audio/transcriptions')
&& 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::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \"}\n\n"
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\"}\n\n"
."data: {\"type\":\"transcript.text.done\",\"text\":\"Hello from whisper.\"}\n\n";
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \",\"end\":10}\n\n"
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\",\"end\":30}\n\n"
."data: {\"type\":\"transcript.text.done\",\"text\":\"Hello from whisper.\",\"end\":40}\n\n";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
@@ -275,7 +307,10 @@ class RecordingUploadTest extends TestCase
$this->assertSame('Hello from whisper.', $text);
$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->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
@@ -320,6 +355,157 @@ class RecordingUploadTest extends TestCase
$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
{
$recording = Recording::query()->create([