Consume faster-whisper SSE instead of blocking on one JSON response, persist growing text and progress over Reverb, and drop the fake percent heartbeat.
162 lines
3.9 KiB
PHP
162 lines
3.9 KiB
PHP
<?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;
|
|
}
|
|
}
|