Files
AndyTranscribe/app/Services/WhisperTranscriptionStream.php
T
ben 4c73620458 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.
2026-08-13 12:34:47 +02:00

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;
}
}