Files
AndyTranscribe/app/Services/TranscriptionService.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

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