Consume faster-whisper SSE instead of blocking on one JSON response, persist growing text and progress over Reverb, and drop the fake percent heartbeat.
245 lines
8.2 KiB
PHP
245 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Recording;
|
|
use Closure;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Laravel\Ai\Transcription;
|
|
use RuntimeException;
|
|
|
|
class TranscriptionService
|
|
{
|
|
public function __construct(private WhisperTranscriptionStream $stream) {}
|
|
|
|
/**
|
|
* Transcribe a recording with the local faster-whisper server.
|
|
*
|
|
* @param (Closure(string, int, ?string): void)|null $onProgress
|
|
* @param (Closure(): bool)|null $shouldContinue
|
|
*/
|
|
public function transcribe(Recording $recording, ?Closure $onProgress = null, ?Closure $shouldContinue = null): string
|
|
{
|
|
$report = $onProgress ?? static fn (string $message, int $percent, ?string $partial = null) => null;
|
|
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
|
|
|
$report('Connecting to local faster-whisper server…', 30);
|
|
$report("Transcribing locally with {$model} (audio stays on this machine)…", 50);
|
|
|
|
if (Transcription::isFaked()) {
|
|
$transcript = Transcription::fromStorage($recording->file_path)
|
|
->timeout((int) config('ai.transcription_timeout', 600))
|
|
->generate('local-whisper', $model);
|
|
} else {
|
|
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
|
|
}
|
|
|
|
$report('Received transcript from local Whisper…', 85);
|
|
|
|
return (string) $transcript;
|
|
}
|
|
|
|
/**
|
|
* Call local Whisper, streaming SSE when the server supports it.
|
|
*
|
|
* @param Closure(string, int, ?string): void $report
|
|
* @param (Closure(): bool)|null $shouldContinue
|
|
*/
|
|
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): string
|
|
{
|
|
$path = $recording->absolutePath();
|
|
|
|
if (! is_readable($path)) {
|
|
throw new RuntimeException('Recording audio file is not readable.');
|
|
}
|
|
|
|
$message = "Transcribing locally with {$model} (audio stays on this machine)…";
|
|
$filename = $recording->original_filename ?: basename($path);
|
|
|
|
$response = $this->localWhisperRequest($filename, $path)
|
|
->withOptions(['stream' => true])
|
|
->post('audio/transcriptions', [
|
|
'model' => $model,
|
|
'response_format' => 'json',
|
|
'stream' => 'true',
|
|
'without_timestamps' => 'false',
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
throw new RuntimeException(
|
|
'Local transcription failed (HTTP '.$response->status().'): '.$response->body()
|
|
);
|
|
}
|
|
|
|
$contentType = strtolower((string) $response->header('Content-Type'));
|
|
|
|
if (! str_contains($contentType, 'text/event-stream')) {
|
|
return $this->extractTranscriptText($response);
|
|
}
|
|
|
|
return $this->consumeWhisperStream($response, $report, $message, $recording->duration_seconds, $shouldContinue);
|
|
}
|
|
|
|
/**
|
|
* @param Closure(string, int, ?string): void $report
|
|
* @param (Closure(): bool)|null $shouldContinue
|
|
*/
|
|
private function consumeWhisperStream(
|
|
Response $response,
|
|
Closure $report,
|
|
string $message,
|
|
?int $durationSeconds,
|
|
?Closure $shouldContinue,
|
|
): string {
|
|
$body = $response->toPsrResponse()->getBody();
|
|
$buffer = '';
|
|
$accumulated = '';
|
|
$lastPercent = 50;
|
|
$eventsWithoutTimestamp = 0;
|
|
$lastFlushAt = 0.0;
|
|
$pending = false;
|
|
|
|
try {
|
|
while (! $body->eof()) {
|
|
if ($shouldContinue !== null && ! $shouldContinue()) {
|
|
$body->close();
|
|
|
|
break;
|
|
}
|
|
|
|
$chunk = $body->read(8192);
|
|
|
|
if ($chunk === '') {
|
|
break;
|
|
}
|
|
|
|
$buffer .= $chunk;
|
|
|
|
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
|
[$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent(
|
|
$payload,
|
|
$accumulated,
|
|
$lastPercent,
|
|
$eventsWithoutTimestamp,
|
|
$durationSeconds,
|
|
$report,
|
|
$message,
|
|
$lastFlushAt,
|
|
$pending,
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
|
[$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending] = $this->ingestEvent(
|
|
$payload,
|
|
$accumulated,
|
|
$lastPercent,
|
|
$eventsWithoutTimestamp,
|
|
$durationSeconds,
|
|
$report,
|
|
$message,
|
|
$lastFlushAt,
|
|
$pending,
|
|
);
|
|
}
|
|
|
|
if ($pending && $accumulated !== '') {
|
|
$report($message, $lastPercent, $accumulated);
|
|
}
|
|
} finally {
|
|
$response->close();
|
|
}
|
|
|
|
if ($accumulated === '') {
|
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
|
}
|
|
|
|
return $accumulated;
|
|
}
|
|
|
|
/**
|
|
* @param Closure(string, int, ?string): void $report
|
|
* @return array{0: string, 1: int, 2: int, 3: bool}
|
|
*/
|
|
private function ingestEvent(
|
|
string $payload,
|
|
string $accumulated,
|
|
int $lastPercent,
|
|
int $eventsWithoutTimestamp,
|
|
?int $durationSeconds,
|
|
Closure $report,
|
|
string $message,
|
|
float &$lastFlushAt,
|
|
bool $pending,
|
|
): array {
|
|
$event = $this->stream->parseEvent($payload);
|
|
|
|
if ($event === null) {
|
|
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending];
|
|
}
|
|
|
|
$wasEmpty = $accumulated === '';
|
|
$accumulated = $this->stream->applyEvent($accumulated, $event);
|
|
|
|
if ($accumulated === '') {
|
|
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, $pending];
|
|
}
|
|
|
|
$percent = $this->percentForEvent($event, $durationSeconds, $lastPercent, $eventsWithoutTimestamp);
|
|
$lastPercent = max($lastPercent, $percent);
|
|
|
|
$now = microtime(true);
|
|
$shouldFlush = $wasEmpty || $event['done'] || ($now - $lastFlushAt) >= 1.0;
|
|
|
|
if ($shouldFlush) {
|
|
$report($message, $lastPercent, $accumulated);
|
|
$lastFlushAt = $now;
|
|
|
|
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, false];
|
|
}
|
|
|
|
return [$accumulated, $lastPercent, $eventsWithoutTimestamp, true];
|
|
}
|
|
|
|
/**
|
|
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool} $event
|
|
*/
|
|
private function percentForEvent(array $event, ?int $durationSeconds, int $lastPercent, int &$eventsWithoutTimestamp): int
|
|
{
|
|
if ($event['end'] !== null && $durationSeconds !== null && $durationSeconds > 0) {
|
|
return (int) min(99, max(50, round(100 * $event['end'] / $durationSeconds)));
|
|
}
|
|
|
|
$eventsWithoutTimestamp++;
|
|
|
|
return min(84, max($lastPercent, 50 + $eventsWithoutTimestamp));
|
|
}
|
|
|
|
private function localWhisperRequest(string $filename, string $path): PendingRequest
|
|
{
|
|
$config = config('ai.providers.local-whisper');
|
|
$baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/');
|
|
$timeout = (int) config('ai.transcription_timeout', 600);
|
|
|
|
return Http::baseUrl($baseUrl)
|
|
->withHeaders(['Authorization' => 'Bearer '.($config['key'] ?? 'not-needed')])
|
|
->timeout($timeout)
|
|
->connectTimeout(10)
|
|
->attach('file', fopen($path, 'r'), $filename);
|
|
}
|
|
|
|
private function extractTranscriptText(Response $response): string
|
|
{
|
|
$text = $response->json('text');
|
|
|
|
if (! is_string($text) || $text === '') {
|
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
}
|