Stream Whisper segments so the UI can show live transcript text.

Consume faster-whisper SSE instead of blocking on one JSON response, persist growing text and progress over Reverb, and drop the fake percent heartbeat.
This commit is contained in:
ben
2026-08-13 12:34:47 +02:00
parent 5cea5192c2
commit 4c73620458
9 changed files with 574 additions and 138 deletions
+114 -1
View File
@@ -228,10 +228,123 @@ class RecordingUploadTest extends TestCase
$this->assertSame('Hello from whisper.', $text);
Http::assertSent(function ($request): bool {
return str_contains($request->url(), '/audio/transcriptions');
$body = $request->body();
return str_contains($request->url(), '/audio/transcriptions')
&& str_contains($body, 'name="stream"')
&& str_contains($body, 'true');
});
}
public function test_transcription_service_streams_sse_progress_and_text(): void
{
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";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Streamed whisper',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'duration_seconds' => 40,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$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->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')));
}
public function test_transcription_service_streams_legacy_segments_with_timestamp_percent(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
$sse = "data: {\"text\":\"Hello from\",\"start\":0,\"end\":20}\n\n"
."data: {\"text\":\"whisper.\",\"start\":20,\"end\":40}\n\n";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Legacy stream',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'duration_seconds' => 40,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$percents = [];
$partials = [];
$text = app(TranscriptionService::class)->transcribe(
$recording,
function (string $message, int $percent, ?string $partial = null) use (&$percents, &$partials): void {
$percents[] = $percent;
if ($partial !== null) {
$partials[] = $partial;
}
},
);
$this->assertSame('Hello from whisper.', $text);
$this->assertContains('Hello from', $partials);
$this->assertContains(50, $percents);
$this->assertContains(99, $percents);
}
public function test_report_progress_persists_partial_transcript_without_completing(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Partial',
'original_filename' => 'partial.mp3',
'file_path' => 'recordings/partial.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcript' => 'Previous transcript',
]);
$recording->reportProgress('Transcribing locally…', 62, partialTranscript: 'Hello from the ');
$recording->refresh();
$this->assertSame('processing', $recording->transcription_status);
$this->assertSame(62, $recording->transcription_percent);
$this->assertSame('Hello from the ', $recording->transcript);
$this->assertNull($recording->transcribed_at);
$this->assertTrue($recording->transcriptionStatusPayload()['has_transcript']);
$this->assertSame('Hello from the ', $recording->transcriptionStatusPayload()['transcript']);
}
public function test_transcription_job_stores_transcript(): void
{
Storage::fake('local');