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
-63
View File
@@ -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);
}
}