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:
@@ -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');
|
||||
|
||||
@@ -83,6 +83,30 @@ class ShowTest extends TestCase
|
||||
->assertSee('Queued — waiting to start…');
|
||||
}
|
||||
|
||||
public function test_show_page_includes_partial_transcript_while_processing(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Live words',
|
||||
'original_filename' => 'active.mp3',
|
||||
'file_path' => 'recordings/active.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 62,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now(),
|
||||
'transcript' => 'Live partial sentence from whisper',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['recording' => $recording])
|
||||
->assertSee('Live partial sentence from whisper')
|
||||
->assertSee('status.transcript', false);
|
||||
}
|
||||
|
||||
public function test_show_does_not_poll_when_transcription_is_idle(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\TranscriptionService;
|
||||
use GuzzleHttp\Promise\PromiseInterface;
|
||||
use Illuminate\Http\Client\Promises\LazyPromise;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
|
||||
class TranscriptionServiceTest extends TestCase
|
||||
{
|
||||
public function test_pulse_progress_increments_while_whisper_is_pending(): void
|
||||
{
|
||||
$service = new TranscriptionService;
|
||||
$checks = 0;
|
||||
|
||||
$promise = new LazyPromise(function () use (&$checks): PromiseInterface {
|
||||
return new class($checks) implements PromiseInterface
|
||||
{
|
||||
public function __construct(private int &$checks) {}
|
||||
|
||||
public function then(?callable $onFulfilled = null, ?callable $onRejected = null): PromiseInterface
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function otherwise(callable $onRejected): PromiseInterface
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getState(): string
|
||||
{
|
||||
return ++$this->checks < 12 ? self::PENDING : self::FULFILLED;
|
||||
}
|
||||
|
||||
public function resolve($value): void {}
|
||||
|
||||
public function reject($reason): void {}
|
||||
|
||||
public function cancel(): void {}
|
||||
|
||||
public function wait(bool $unwrap = true): mixed
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$promise->buildPromise();
|
||||
|
||||
$percents = [];
|
||||
$method = new ReflectionMethod(TranscriptionService::class, 'pulseProgressWhilePending');
|
||||
$method->invoke($service, $promise, function (string $message, int $percent) use (&$percents): void {
|
||||
$percents[] = $percent;
|
||||
}, 'Transcribing…');
|
||||
|
||||
$this->assertNotEmpty($percents);
|
||||
$this->assertGreaterThan(50, max($percents));
|
||||
$this->assertLessThanOrEqual(84, max($percents));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\WhisperTranscriptionStream;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class WhisperTranscriptionStreamTest extends TestCase
|
||||
{
|
||||
private WhisperTranscriptionStream $stream;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->stream = new WhisperTranscriptionStream;
|
||||
}
|
||||
|
||||
public function test_extracts_sse_data_payloads_from_buffer(): void
|
||||
{
|
||||
$buffer = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello\"}\n\npartial";
|
||||
|
||||
$payloads = $this->stream->extractPayloads($buffer);
|
||||
|
||||
$this->assertSame(['{"type":"transcript.text.delta","delta":"Hello"}'], $payloads);
|
||||
$this->assertSame('partial', $buffer);
|
||||
}
|
||||
|
||||
public function test_parses_openai_delta_and_done_events(): void
|
||||
{
|
||||
$delta = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"Hello "}');
|
||||
$done = $this->stream->parseEvent('{"type":"transcript.text.done","text":"Hello world"}');
|
||||
|
||||
$this->assertSame('Hello ', $delta['append']);
|
||||
$this->assertFalse($delta['done']);
|
||||
$this->assertSame('Hello world', $done['replace']);
|
||||
$this->assertTrue($done['done']);
|
||||
}
|
||||
|
||||
public function test_parses_legacy_segment_events(): void
|
||||
{
|
||||
$event = $this->stream->parseEvent('{"text":"First segment","start":0,"end":12.5}');
|
||||
|
||||
$this->assertTrue($event['legacy']);
|
||||
$this->assertSame('First segment', $event['append']);
|
||||
$this->assertSame(12.5, $event['end']);
|
||||
}
|
||||
|
||||
public function test_applies_delta_then_done_replace(): void
|
||||
{
|
||||
$delta = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"Hel"}');
|
||||
$text = $this->stream->applyEvent('', $delta);
|
||||
$delta2 = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"lo"}');
|
||||
$text = $this->stream->applyEvent($text, $delta2);
|
||||
$done = $this->stream->parseEvent('{"type":"transcript.text.done","text":"Hello world"}');
|
||||
$text = $this->stream->applyEvent($text, $done);
|
||||
|
||||
$this->assertSame('Hello world', $text);
|
||||
}
|
||||
|
||||
public function test_joins_legacy_segments_with_spaces(): void
|
||||
{
|
||||
$first = $this->stream->parseEvent('{"text":"Hello from","end":10}');
|
||||
$second = $this->stream->parseEvent('{"text":"whisper.","end":20}');
|
||||
|
||||
$text = $this->stream->applyEvent('', $first);
|
||||
$text = $this->stream->applyEvent($text, $second);
|
||||
|
||||
$this->assertSame('Hello from whisper.', $text);
|
||||
}
|
||||
|
||||
public function test_ignores_done_sentinel_and_non_json(): void
|
||||
{
|
||||
$buffer = "data: [DONE]\n\ndata: not-json\n\n";
|
||||
|
||||
$payloads = $this->stream->extractPayloads($buffer);
|
||||
|
||||
$this->assertSame(['not-json'], $payloads);
|
||||
$this->assertNull($this->stream->parseEvent('not-json'));
|
||||
}
|
||||
|
||||
public function test_flush_buffer_emits_trailing_frame(): void
|
||||
{
|
||||
$buffer = 'data: {"text":"Last"}';
|
||||
|
||||
$payloads = $this->stream->flushBuffer($buffer);
|
||||
|
||||
$this->assertSame(['{"text":"Last"}'], $payloads);
|
||||
$this->assertSame('', $buffer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user