Persist accumulated verbose_json on the recording and broadcast only transcript/whisper deltas so the show page can render language, segments, word timestamps, and confidence while a run is in progress.
255 lines
8.1 KiB
PHP
255 lines
8.1 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 Illuminate\Support\Facades\Log;
|
|
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, ?array): 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, ?array $whisper = null) => null;
|
|
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
|
|
|
$report("Transcribing locally with {$model} (audio stays on this machine)…", 0);
|
|
|
|
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);
|
|
}
|
|
|
|
return (string) $transcript;
|
|
}
|
|
|
|
/**
|
|
* Call local Whisper, streaming SSE when the server supports it.
|
|
*
|
|
* @param Closure(string, int, ?string, ?array): 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' => 'verbose_json',
|
|
'stream' => 'true',
|
|
'without_timestamps' => 'false',
|
|
'timestamp_granularities[]' => 'word',
|
|
]);
|
|
|
|
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')) {
|
|
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
|
|
'recording_id' => $recording->id,
|
|
'content_type' => $contentType,
|
|
]);
|
|
|
|
return $this->extractTranscriptText($response, $report, $message);
|
|
}
|
|
|
|
return $this->consumeWhisperStream($response, $report, $message, $recording, $shouldContinue);
|
|
}
|
|
|
|
/**
|
|
* @param Closure(string, int, ?string, ?array): void $report
|
|
* @param (Closure(): bool)|null $shouldContinue
|
|
*/
|
|
private function consumeWhisperStream(
|
|
Response $response,
|
|
Closure $report,
|
|
string $message,
|
|
Recording $recording,
|
|
?Closure $shouldContinue,
|
|
): string {
|
|
$body = $response->toPsrResponse()->getBody();
|
|
$buffer = '';
|
|
$accumulated = '';
|
|
$lastPercent = 0;
|
|
$idleReads = 0;
|
|
|
|
try {
|
|
while (! $body->eof()) {
|
|
if ($shouldContinue !== null && ! $shouldContinue()) {
|
|
$body->close();
|
|
|
|
break;
|
|
}
|
|
|
|
$chunk = $body->read(8192);
|
|
|
|
if ($chunk === '') {
|
|
$idleReads++;
|
|
|
|
if ($idleReads >= 40) {
|
|
break;
|
|
}
|
|
|
|
usleep(50_000);
|
|
|
|
continue;
|
|
}
|
|
|
|
$idleReads = 0;
|
|
$buffer .= $chunk;
|
|
|
|
foreach ($this->stream->extractPayloads($buffer) as $payload) {
|
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
|
$payload,
|
|
$accumulated,
|
|
$lastPercent,
|
|
$recording,
|
|
$report,
|
|
$message,
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach ($this->stream->flushBuffer($buffer) as $payload) {
|
|
[$accumulated, $lastPercent] = $this->ingestEvent(
|
|
$payload,
|
|
$accumulated,
|
|
$lastPercent,
|
|
$recording,
|
|
$report,
|
|
$message,
|
|
);
|
|
}
|
|
} finally {
|
|
$response->close();
|
|
}
|
|
|
|
if ($accumulated === '') {
|
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
|
}
|
|
|
|
return $accumulated;
|
|
}
|
|
|
|
/**
|
|
* @param Closure(string, int, ?string, ?array): void $report
|
|
* @return array{0: string, 1: int}
|
|
*/
|
|
private function ingestEvent(
|
|
string $payload,
|
|
string $accumulated,
|
|
int $lastPercent,
|
|
Recording $recording,
|
|
Closure $report,
|
|
string $message,
|
|
): array {
|
|
$event = $this->stream->parseEvent($payload);
|
|
|
|
if ($event === null) {
|
|
return [$accumulated, $lastPercent];
|
|
}
|
|
|
|
$accumulated = $this->stream->applyEvent($accumulated, $event);
|
|
$whisper = $event['whisper'] ?? [];
|
|
|
|
if ($accumulated === '' && $whisper === []) {
|
|
return [$accumulated, $lastPercent];
|
|
}
|
|
|
|
$lastPercent = $this->percentForEvent($event, $recording, $lastPercent);
|
|
|
|
$report(
|
|
$message,
|
|
$lastPercent,
|
|
$accumulated === '' ? null : $accumulated,
|
|
$whisper === [] ? null : $whisper,
|
|
);
|
|
|
|
return [$accumulated, $lastPercent];
|
|
}
|
|
|
|
/**
|
|
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
|
|
*/
|
|
private function percentForEvent(array $event, Recording $recording, int $lastPercent): int
|
|
{
|
|
$duration = $recording->duration_seconds;
|
|
$end = $event['end'] ?? null;
|
|
|
|
if ($end === null || $duration === null || $duration <= 0) {
|
|
return $lastPercent;
|
|
}
|
|
|
|
return (int) min(99, max($lastPercent, round(100 * $end / $duration)));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @param Closure(string, int, ?string, ?array): void $report
|
|
*/
|
|
private function extractTranscriptText(Response $response, ?Closure $report = null, string $message = ''): string
|
|
{
|
|
$json = $response->json();
|
|
|
|
if (! is_array($json)) {
|
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
|
}
|
|
|
|
$text = $json['text'] ?? null;
|
|
|
|
if (! is_string($text) || $text === '') {
|
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
|
}
|
|
|
|
$whisper = $this->stream->extractWhisperMeta($json);
|
|
|
|
if ($report !== null && $whisper !== []) {
|
|
$report($message, 99, $text, $whisper);
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
}
|