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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user