*/ 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 */ 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}|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 (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 ( ($type === null || $type === 'segment') && isset($data['text']) && is_string($data['text']) && $data['text'] !== '' ) { return [ 'append' => $data['text'], 'replace' => null, 'end' => $end, 'done' => false, 'legacy' => true, '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} $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 $data * @return array */ 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 !== []) { $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 $data * @return array|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 !== []); } /** * @return list */ 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 $data * @param array $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 $ends * @param array $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|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|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 */ 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; } }