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
+6 -4
View File
@@ -76,9 +76,11 @@ class TranscribeRecording implements ShouldQueue
try { try {
$text = $transcription->transcribe( $text = $transcription->transcribe(
$this->recording, $this->recording,
function (string $message, int $percent): void { function (string $message, int $percent, ?string $partialTranscript = null): void {
$this->reportIfOwned($message, $percent); $this->reportIfOwned($message, $percent, $partialTranscript);
}, },
fn (): bool => Recording::query()->find($this->recording->id)
?->ownsTranscriptionRun($this->runStartedAt) ?? false,
); );
if (! $this->claimSuccessfulTranscript($text)) { if (! $this->claimSuccessfulTranscript($text)) {
@@ -170,12 +172,12 @@ class TranscribeRecording implements ShouldQueue
return false; return false;
} }
private function reportIfOwned(string $message, int $percent): void private function reportIfOwned(string $message, int $percent, ?string $partialTranscript = null): void
{ {
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) { if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
return; return;
} }
$this->recording->reportProgress($message, $percent); $this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript);
} }
} }
+9 -3
View File
@@ -502,14 +502,20 @@ class Recording extends Model
/** /**
* Update the live progress fields shown in the UI. * Update the live progress fields shown in the UI.
*/ */
public function reportProgress(string $message, int $percent, string $status = 'processing'): void public function reportProgress(string $message, int $percent, string $status = 'processing', ?string $partialTranscript = null): void
{ {
$this->forceFill([ $attributes = [
'transcription_status' => $status, 'transcription_status' => $status,
'transcription_progress' => $message, 'transcription_progress' => $message,
'transcription_percent' => max(0, min(100, $percent)), 'transcription_percent' => max(0, min(100, $percent)),
'transcription_error' => null, 'transcription_error' => null,
])->save(); ];
if ($partialTranscript !== null) {
$attributes['transcript'] = $partialTranscript;
}
$this->forceFill($attributes)->save();
RecordingTranscriptionUpdated::dispatch($this->fresh()); RecordingTranscriptionUpdated::dispatch($this->fresh());
} }
+162 -67
View File
@@ -4,9 +4,7 @@ namespace App\Services;
use App\Models\Recording; use App\Models\Recording;
use Closure; use Closure;
use GuzzleHttp\Promise\PromiseInterface;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Promises\LazyPromise;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Laravel\Ai\Transcription; use Laravel\Ai\Transcription;
@@ -14,14 +12,17 @@ use RuntimeException;
class TranscriptionService class TranscriptionService
{ {
public function __construct(private WhisperTranscriptionStream $stream) {}
/** /**
* Transcribe a recording with the local faster-whisper server. * Transcribe a recording with the local faster-whisper server.
* *
* @param (Closure(string, int): void)|null $onProgress * @param (Closure(string, int, ?string): void)|null $onProgress
* @param (Closure(): bool)|null $shouldContinue
*/ */
public function transcribe(Recording $recording, ?Closure $onProgress = null): string public function transcribe(Recording $recording, ?Closure $onProgress = null, ?Closure $shouldContinue = null): string
{ {
$report = $onProgress ?? static fn (string $message, int $percent) => null; $report = $onProgress ?? static fn (string $message, int $percent, ?string $partial = 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('Connecting to local faster-whisper server…', 30);
@@ -32,7 +33,7 @@ class TranscriptionService
->timeout((int) config('ai.transcription_timeout', 600)) ->timeout((int) config('ai.transcription_timeout', 600))
->generate('local-whisper', $model); ->generate('local-whisper', $model);
} else { } else {
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model); $transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
} }
$report('Received transcript from local Whisper…', 85); $report('Received transcript from local Whisper…', 85);
@@ -41,11 +42,12 @@ class TranscriptionService
} }
/** /**
* Call local Whisper and pulse progress while waiting. * Call local Whisper, streaming SSE when the server supports it.
* *
* @param Closure(string, int): void $report * @param Closure(string, int, ?string): void $report
* @param (Closure(): bool)|null $shouldContinue
*/ */
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model): string private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): string
{ {
$path = $recording->absolutePath(); $path = $recording->absolutePath();
@@ -55,31 +57,165 @@ class TranscriptionService
$message = "Transcribing locally with {$model} (audio stays on this machine)…"; $message = "Transcribing locally with {$model} (audio stays on this machine)…";
$filename = $recording->original_filename ?: basename($path); $filename = $recording->original_filename ?: basename($path);
$request = $this->localWhisperRequest($filename, $path);
if ($this->shouldTranscribeSynchronously()) { $response = $this->localWhisperRequest($filename, $path)
return $this->extractTranscriptText($request->post('audio/transcriptions', [ ->withOptions(['stream' => true])
'model' => $model,
'response_format' => 'json',
]));
}
/** @var LazyPromise $promise */
$promise = $request
->async()
->post('audio/transcriptions', [ ->post('audio/transcriptions', [
'model' => $model, 'model' => $model,
'response_format' => 'json', 'response_format' => 'json',
'stream' => 'true',
'without_timestamps' => 'false',
]); ]);
$promise->buildPromise(); if (! $response->successful()) {
throw new RuntimeException(
'Local transcription failed (HTTP '.$response->status().'): '.$response->body()
);
}
$this->pulseProgressWhilePending($promise, $report, $message); $contentType = strtolower((string) $response->header('Content-Type'));
/** @var Response $response */ if (! str_contains($contentType, 'text/event-stream')) {
$response = $promise->wait(); return $this->extractTranscriptText($response);
}
return $this->extractTranscriptText($response); return $this->consumeWhisperStream($response, $report, $message, $recording->duration_seconds, $shouldContinue);
}
/**
* @param Closure(string, int, ?string): void $report
* @param (Closure(): bool)|null $shouldContinue
*/
private function consumeWhisperStream(
Response $response,
Closure $report,
string $message,
?int $durationSeconds,
?Closure $shouldContinue,
): string {
$body = $response->toPsrResponse()->getBody();
$buffer = '';
$accumulated = '';
$lastPercent = 50;
$eventsWithoutTimestamp = 0;
$lastFlushAt = 0.0;
$pending = false;
try {
while (! $body->eof()) {
if ($shouldContinue !== null && ! $shouldContinue()) {
$body->close();
break;
}
$chunk = $body->read(8192);
if ($chunk === '') {
break;
}
$buffer .= $chunk;
foreach ($this->stream->extractPayloads($buffer) as $payload) {
[$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$eventsWithoutTimestamp,
$durationSeconds,
$report,
$message,
$lastFlushAt,
$pending,
);
}
}
foreach ($this->stream->flushBuffer($buffer) as $payload) {
[$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$eventsWithoutTimestamp,
$durationSeconds,
$report,
$message,
$lastFlushAt,
$pending,
);
}
if ($pending && $accumulated !== '') {
$report($message, $lastPercent, $accumulated);
}
} finally {
$response->close();
}
if ($accumulated === '') {
throw new RuntimeException('Local transcription returned an empty transcript.');
}
return $accumulated;
}
/**
* @param Closure(string, int, ?string): void $report
* @return array{0: string, 1: int, 2: int, 3: bool}
*/
private function ingestEvent(
string $payload,
string $accumulated,
int $lastPercent,
int $eventsWithoutTimestamp,
?int $durationSeconds,
Closure $report,
string $message,
float &$lastFlushAt,
bool $pending,
): array {
$event = $this->stream->parseEvent($payload);
if ($event === null) {
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending];
}
$wasEmpty = $accumulated === '';
$accumulated = $this->stream->applyEvent($accumulated, $event);
if ($accumulated === '') {
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending];
}
$percent = $this->percentForEvent($event, $durationSeconds, $lastPercent, $eventsWithoutTimestamp);
$lastPercent = max($lastPercent, $percent);
$now = microtime(true);
$shouldFlush = $wasEmpty || $event['done'] || ($now - $lastFlushAt) >= 1.0;
if ($shouldFlush) {
$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
*/
private function percentForEvent(array $event, ?int $durationSeconds, int $lastPercent, int &$eventsWithoutTimestamp): int
{
if ($event['end'] !== null && $durationSeconds !== null && $durationSeconds > 0) {
return (int) min(99, max(50, round(100 * $event['end'] / $durationSeconds)));
}
$eventsWithoutTimestamp++;
return min(84, max($lastPercent, 50 + $eventsWithoutTimestamp));
} }
private function localWhisperRequest(string $filename, string $path): PendingRequest private function localWhisperRequest(string $filename, string $path): PendingRequest
@@ -91,25 +227,12 @@ class TranscriptionService
return Http::baseUrl($baseUrl) return Http::baseUrl($baseUrl)
->withHeaders(['Authorization' => 'Bearer '.($config['key'] ?? 'not-needed')]) ->withHeaders(['Authorization' => 'Bearer '.($config['key'] ?? 'not-needed')])
->timeout($timeout) ->timeout($timeout)
->connectTimeout(10)
->attach('file', fopen($path, 'r'), $filename); ->attach('file', fopen($path, 'r'), $filename);
} }
/**
* Laravel's HTTP fake does not resolve async promises; use sync in tests.
*/
private function shouldTranscribeSynchronously(): bool
{
return app()->runningUnitTests();
}
private function extractTranscriptText(Response $response): string private function extractTranscriptText(Response $response): string
{ {
if (! $response->successful()) {
throw new RuntimeException(
'Local transcription failed (HTTP '.$response->status().'): '.$response->body()
);
}
$text = $response->json('text'); $text = $response->json('text');
if (! is_string($text) || $text === '') { if (! is_string($text) || $text === '') {
@@ -118,32 +241,4 @@ class TranscriptionService
return $text; return $text;
} }
/**
* Broadcast incremental progress while Whisper is working.
*
* @param Closure(string, int): void $report
*/
private function pulseProgressWhilePending(LazyPromise $promise, Closure $report, string $message, int $floor = 50, int $ceiling = 84): void
{
$lastPercent = $floor;
$lastPulseAt = microtime(true);
$startedAt = microtime(true);
while ($promise->getState() === PromiseInterface::PENDING) {
$now = microtime(true);
if ($now - $lastPulseAt >= 2.0) {
$elapsed = $now - $startedAt;
$percent = $floor + (int) floor(($ceiling - $floor) * (1 - exp(-$elapsed / 90)));
$percent = max($lastPercent + 1, min($ceiling, $percent));
$report($message, $percent);
$lastPercent = $percent;
$lastPulseAt = $now;
}
usleep(250_000);
}
}
} }
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace App\Services;
class WhisperTranscriptionStream
{
/**
* Pull complete SSE `data:` payloads out of a rolling buffer.
*
* @return list<string>
*/
public function extractPayloads(string &$buffer): array
{
$frames = explode("\n\n", str_replace("\r\n", "\n", $buffer));
$buffer = array_pop($frames) ?? '';
$payloads = [];
foreach ($frames as $frame) {
foreach ($this->dataLines($frame) as $payload) {
if ($payload === '[DONE]' || $payload === '') {
continue;
}
$payloads[] = $payload;
}
}
return $payloads;
}
/**
* Flush a trailing incomplete frame at end-of-stream.
*
* @return list<string>
*/
public function flushBuffer(string &$buffer): array
{
$trimmed = trim($buffer);
if ($trimmed === '') {
$buffer = '';
return [];
}
$buffer .= "\n\n";
return $this->extractPayloads($buffer);
}
/**
* Parse one SSE JSON payload into append/replace/end/done fields.
*
* @return array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool}|null
*/
public function parseEvent(string $json): ?array
{
$data = json_decode($json, true);
if (! is_array($data)) {
return null;
}
$type = $data['type'] ?? null;
if ($type === 'transcript.text.delta') {
$delta = $data['delta'] ?? '';
if (! is_string($delta) || $delta === '') {
return null;
}
return [
'append' => $delta,
'replace' => null,
'end' => $this->nullableFloat($data['end'] ?? null),
'done' => false,
'legacy' => false,
];
}
if ($type === 'transcript.text.done') {
$text = $data['text'] ?? '';
return [
'append' => null,
'replace' => is_string($text) ? $text : '',
'end' => $this->nullableFloat($data['end'] ?? null),
'done' => true,
'legacy' => false,
];
}
if ($type === null && isset($data['text']) && is_string($data['text']) && $data['text'] !== '') {
return [
'append' => $data['text'],
'replace' => null,
'end' => $this->nullableFloat($data['end'] ?? null),
'done' => false,
'legacy' => true,
];
}
return null;
}
/**
* Apply a parsed event to the accumulated transcript.
*
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool} $event
*/
public function applyEvent(string $accumulated, array $event): string
{
if (is_string($event['replace'])) {
return $event['replace'];
}
$chunk = $event['append'] ?? '';
if ($chunk === '') {
return $accumulated;
}
if ($event['legacy']) {
$chunk = trim($chunk);
if ($accumulated === '') {
return $chunk;
}
return rtrim($accumulated).' '.$chunk;
}
return $accumulated.$chunk;
}
/**
* @return list<string>
*/
private function dataLines(string $frame): array
{
$payloads = [];
foreach (explode("\n", $frame) as $line) {
if (str_starts_with($line, 'data:')) {
$payloads[] = ltrim(substr($line, 5));
}
}
return $payloads;
}
private function nullableFloat(mixed $value): ?float
{
if (! is_numeric($value)) {
return null;
}
return (float) $value;
}
}
@@ -178,6 +178,13 @@
<flux:progress color="amber" x-bind:value="status.percent || 0" /> <flux:progress color="amber" x-bind:value="status.percent || 0" />
</div> </div>
<p
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
x-show="status.transcript"
x-cloak
x-text="status.transcript"
></p>
<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:
+114 -1
View File
@@ -228,10 +228,123 @@ class RecordingUploadTest extends TestCase
$this->assertSame('Hello from whisper.', $text); $this->assertSame('Hello from whisper.', $text);
Http::assertSent(function ($request): bool { 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 public function test_transcription_job_stores_transcript(): void
{ {
Storage::fake('local'); Storage::fake('local');
+24
View File
@@ -83,6 +83,30 @@ class ShowTest extends TestCase
->assertSee('Queued — waiting to start…'); ->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 public function test_show_does_not_poll_when_transcription_is_idle(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
-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);
}
}