Treat streamed chunks as appends, stop Livewire from remorphing the Alpine tree, and hydrate from HTTP while a run is active so segment cards appear as they arrive.
461 lines
12 KiB
PHP
461 lines
12 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/whisper fields.
|
|
*
|
|
* @return array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper: array<string, mixed>}|null
|
|
*/
|
|
public function parseEvent(string $json): ?array
|
|
{
|
|
$data = json_decode($json, true);
|
|
|
|
if (! is_array($data)) {
|
|
return null;
|
|
}
|
|
|
|
$type = $data['type'] ?? null;
|
|
$whisper = $this->extractWhisperMeta($data);
|
|
$end = $this->latestAudioEnd($data, $whisper);
|
|
|
|
if ($type === 'transcript.text.delta') {
|
|
$delta = $data['delta'] ?? '';
|
|
|
|
if ((! is_string($delta) || $delta === '') && $whisper === []) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'append' => is_string($delta) && $delta !== '' ? $delta : null,
|
|
'replace' => null,
|
|
'end' => $end,
|
|
'done' => false,
|
|
'legacy' => false,
|
|
'whisper' => $whisper,
|
|
];
|
|
}
|
|
|
|
if ($type === 'transcript.text.done') {
|
|
$text = $data['text'] ?? '';
|
|
|
|
return [
|
|
'append' => null,
|
|
'replace' => is_string($text) ? $text : '',
|
|
'end' => $end,
|
|
'done' => true,
|
|
'legacy' => false,
|
|
'whisper' => $whisper,
|
|
];
|
|
}
|
|
|
|
if ($this->isLiveSegmentEvent($type, $data)) {
|
|
return [
|
|
'append' => $data['text'],
|
|
'replace' => null,
|
|
'end' => $end,
|
|
'done' => false,
|
|
'legacy' => true,
|
|
'whisper' => $whisper,
|
|
];
|
|
}
|
|
|
|
if (isset($data['segments']) && is_array($data['segments'])) {
|
|
$text = $data['text'] ?? '';
|
|
|
|
if ((! is_string($text) || $text === '') && $whisper === []) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'append' => null,
|
|
'replace' => is_string($text) && $text !== '' ? $text : null,
|
|
'end' => $end,
|
|
'done' => true,
|
|
'legacy' => false,
|
|
'whisper' => $whisper,
|
|
];
|
|
}
|
|
|
|
if ($whisper !== []) {
|
|
return [
|
|
'append' => null,
|
|
'replace' => null,
|
|
'end' => $end,
|
|
'done' => false,
|
|
'legacy' => false,
|
|
'whisper' => $whisper,
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Apply a parsed event to the accumulated transcript.
|
|
*
|
|
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $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;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function extractWhisperMeta(array $data): array
|
|
{
|
|
$meta = [];
|
|
|
|
if (isset($data['language']) && is_string($data['language']) && $data['language'] !== '') {
|
|
$meta['language'] = $data['language'];
|
|
}
|
|
|
|
if (is_numeric($data['duration'] ?? null)) {
|
|
$meta['duration'] = (float) $data['duration'];
|
|
}
|
|
|
|
if (isset($data['logprobs']) && is_array($data['logprobs'])) {
|
|
$logprobs = $this->normalizeLogprobs($data['logprobs']);
|
|
|
|
if ($logprobs !== []) {
|
|
$meta['logprobs'] = $logprobs;
|
|
}
|
|
}
|
|
|
|
$segment = $this->normalizeSegment($data);
|
|
|
|
if ($segment !== null && ($data['type'] ?? null) !== 'transcript.text.delta') {
|
|
$meta['segment'] = $segment;
|
|
}
|
|
|
|
if (isset($data['segments']) && is_array($data['segments'])) {
|
|
$segments = [];
|
|
|
|
foreach ($data['segments'] as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$normalized = $this->normalizeSegment($row);
|
|
|
|
if ($normalized !== null) {
|
|
$segments[] = $normalized;
|
|
}
|
|
}
|
|
|
|
if ($segments !== [] && ! isset($meta['segment'])) {
|
|
$meta['segments'] = $segments;
|
|
}
|
|
}
|
|
|
|
if (isset($data['words']) && is_array($data['words']) && ! isset($meta['segment']) && ! isset($meta['segments'])) {
|
|
$words = $this->normalizeWords($data['words']);
|
|
|
|
if ($words !== []) {
|
|
$meta['words'] = $words;
|
|
}
|
|
}
|
|
|
|
return $meta;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function normalizeSegment(array $data): ?array
|
|
{
|
|
$hasDetail = isset($data['start'])
|
|
|| isset($data['end'])
|
|
|| isset($data['words'])
|
|
|| isset($data['avg_logprob'])
|
|
|| isset($data['tokens'])
|
|
|| isset($data['no_speech_prob'])
|
|
|| array_key_exists('id', $data);
|
|
|
|
if (! $hasDetail) {
|
|
return null;
|
|
}
|
|
|
|
$text = $data['text'] ?? null;
|
|
|
|
if (! is_string($text) || $text === '') {
|
|
return null;
|
|
}
|
|
|
|
$segment = [
|
|
'id' => is_numeric($data['id'] ?? null) ? (int) $data['id'] : null,
|
|
'seek' => is_numeric($data['seek'] ?? null) ? (int) $data['seek'] : null,
|
|
'start' => $this->nullableFloat($data['start'] ?? null),
|
|
'end' => $this->nullableFloat($data['end'] ?? null),
|
|
'text' => $text,
|
|
'tokens' => $this->normalizeTokens($data['tokens'] ?? null),
|
|
'temperature' => $this->nullableFloat($data['temperature'] ?? null),
|
|
'avg_logprob' => $this->nullableFloat($data['avg_logprob'] ?? null),
|
|
'compression_ratio' => $this->nullableFloat($data['compression_ratio'] ?? null),
|
|
'no_speech_prob' => $this->nullableFloat($data['no_speech_prob'] ?? null),
|
|
'words' => $this->normalizeWords($data['words'] ?? null),
|
|
];
|
|
|
|
return array_filter($segment, fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
/**
|
|
* One streamed Whisper chunk (not a finished verbose_json document).
|
|
*
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private function isLiveSegmentEvent(mixed $type, array $data): bool
|
|
{
|
|
$text = $data['text'] ?? null;
|
|
|
|
if (! is_string($text) || $text === '') {
|
|
return false;
|
|
}
|
|
|
|
if ($type === 'segment') {
|
|
return true;
|
|
}
|
|
|
|
if ($type !== null && $type !== '') {
|
|
return false;
|
|
}
|
|
|
|
if (isset($data['start']) || isset($data['end'])) {
|
|
return true;
|
|
}
|
|
|
|
return ! (isset($data['segments']) && is_array($data['segments']));
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* Latest media timestamp in this event (segment/word `end`). Never file `duration`.
|
|
*
|
|
* @param array<string, mixed> $data
|
|
* @param array<string, mixed> $whisper
|
|
*/
|
|
public function latestAudioEnd(array $data, array $whisper): ?float
|
|
{
|
|
$ends = [];
|
|
|
|
$direct = $this->nullableFloat($data['end'] ?? null);
|
|
|
|
if ($direct !== null) {
|
|
$ends[] = $direct;
|
|
}
|
|
|
|
$this->collectAudioEnds($ends, $whisper);
|
|
|
|
if (isset($data['words']) && is_array($data['words'])) {
|
|
$this->collectAudioEnds($ends, ['words' => $data['words']]);
|
|
}
|
|
|
|
return $ends === [] ? null : max($ends);
|
|
}
|
|
|
|
/**
|
|
* @param list<float> $ends
|
|
* @param array<string, mixed> $node
|
|
*/
|
|
private function collectAudioEnds(array &$ends, array $node): void
|
|
{
|
|
$end = $this->nullableFloat($node['end'] ?? null);
|
|
|
|
if ($end !== null) {
|
|
$ends[] = $end;
|
|
}
|
|
|
|
foreach (['words', 'segments'] as $key) {
|
|
if (! isset($node[$key]) || ! is_array($node[$key])) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($node[$key] as $child) {
|
|
if (is_array($child)) {
|
|
$this->collectAudioEnds($ends, $child);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isset($node['segment']) && is_array($node['segment'])) {
|
|
$this->collectAudioEnds($ends, $node['segment']);
|
|
}
|
|
}
|
|
|
|
private function nullableFloat(mixed $value): ?float
|
|
{
|
|
if (! is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
return (float) $value;
|
|
}
|
|
|
|
/**
|
|
* @return list<int>|null
|
|
*/
|
|
private function normalizeTokens(mixed $tokens): ?array
|
|
{
|
|
if (! is_array($tokens) || $tokens === []) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($tokens as $token) {
|
|
if (is_numeric($token)) {
|
|
$normalized[] = (int) $token;
|
|
}
|
|
}
|
|
|
|
return $normalized === [] ? null : $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{word: string, start: ?float, end: ?float, probability: ?float}>|null
|
|
*/
|
|
private function normalizeWords(mixed $words): ?array
|
|
{
|
|
if (! is_array($words) || $words === []) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($words as $word) {
|
|
if (! is_array($word)) {
|
|
continue;
|
|
}
|
|
|
|
$text = $word['word'] ?? $word['text'] ?? null;
|
|
|
|
if (! is_string($text) || $text === '') {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = array_filter([
|
|
'word' => $text,
|
|
'start' => $this->nullableFloat($word['start'] ?? null),
|
|
'end' => $this->nullableFloat($word['end'] ?? null),
|
|
'probability' => $this->nullableFloat($word['probability'] ?? $word['prob'] ?? null),
|
|
], fn (mixed $value): bool => $value !== null);
|
|
}
|
|
|
|
return $normalized === [] ? null : $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{token: ?string, logprob: ?float}>
|
|
*/
|
|
private function normalizeLogprobs(array $logprobs): array
|
|
{
|
|
$normalized = [];
|
|
|
|
foreach ($logprobs as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$token = $row['token'] ?? $row['bytes'] ?? null;
|
|
$token = is_string($token) ? $token : null;
|
|
$logprob = $this->nullableFloat($row['logprob'] ?? $row['avg_logprob'] ?? null);
|
|
|
|
if ($token === null && $logprob === null) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = array_filter([
|
|
'token' => $token,
|
|
'logprob' => $logprob,
|
|
], fn (mixed $value): bool => $value !== null);
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
}
|