Implement a new method for calculating elapsed transcription time in the Recording model. Enhance the TranscriptionService to handle local Whisper requests, including asynchronous processing and progress reporting. Update the recordings index view to display transcription progress percentage. Add unit tests for the new transcription service functionality.
150 lines
4.9 KiB
PHP
150 lines
4.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Recording;
|
|
use Closure;
|
|
use GuzzleHttp\Promise\PromiseInterface;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\Client\Promises\LazyPromise;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Laravel\Ai\Transcription;
|
|
use RuntimeException;
|
|
|
|
class TranscriptionService
|
|
{
|
|
/**
|
|
* Transcribe a recording with the local faster-whisper server.
|
|
*
|
|
* @param (Closure(string, int): void)|null $onProgress
|
|
*/
|
|
public function transcribe(Recording $recording, ?Closure $onProgress = null): string
|
|
{
|
|
$report = $onProgress ?? static fn (string $message, int $percent) => 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);
|
|
}
|
|
|
|
$report('Received transcript from local Whisper…', 85);
|
|
|
|
return (string) $transcript;
|
|
}
|
|
|
|
/**
|
|
* Call local Whisper and pulse progress while waiting.
|
|
*
|
|
* @param Closure(string, int): void $report
|
|
*/
|
|
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model): 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);
|
|
$request = $this->localWhisperRequest($filename, $path);
|
|
|
|
if ($this->shouldTranscribeSynchronously()) {
|
|
return $this->extractTranscriptText($request->post('audio/transcriptions', [
|
|
'model' => $model,
|
|
'response_format' => 'json',
|
|
]));
|
|
}
|
|
|
|
/** @var LazyPromise $promise */
|
|
$promise = $request
|
|
->async()
|
|
->post('audio/transcriptions', [
|
|
'model' => $model,
|
|
'response_format' => 'json',
|
|
]);
|
|
|
|
$promise->buildPromise();
|
|
|
|
$this->pulseProgressWhilePending($promise, $report, $message);
|
|
|
|
/** @var Response $response */
|
|
$response = $promise->wait();
|
|
|
|
return $this->extractTranscriptText($response);
|
|
}
|
|
|
|
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)
|
|
->attach('file', fopen($path, 'r'), $filename);
|
|
}
|
|
|
|
/**
|
|
* Laravel's HTTP fake does not resolve async promises; use sync in tests.
|
|
*/
|
|
private function shouldTranscribeSynchronously(): bool
|
|
{
|
|
return app()->runningUnitTests();
|
|
}
|
|
|
|
private function extractTranscriptText(Response $response): string
|
|
{
|
|
if (! $response->successful()) {
|
|
throw new RuntimeException(
|
|
'Local transcription failed (HTTP '.$response->status().'): '.$response->body()
|
|
);
|
|
}
|
|
|
|
$text = $response->json('text');
|
|
|
|
if (! is_string($text) || $text === '') {
|
|
throw new RuntimeException('Local transcription returned an empty transcript.');
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* Broadcast incremental progress while Whisper is working.
|
|
*
|
|
* @param Closure(string, int): void $report
|
|
*/
|
|
private function pulseProgressWhilePending(LazyPromise $promise, Closure $report, string $message, int $floor = 50, int $ceiling = 84): void
|
|
{
|
|
$lastPercent = $floor;
|
|
$lastPulseAt = microtime(true);
|
|
$startedAt = microtime(true);
|
|
|
|
while ($promise->getState() === PromiseInterface::PENDING) {
|
|
$now = microtime(true);
|
|
|
|
if ($now - $lastPulseAt >= 2.0) {
|
|
$elapsed = $now - $startedAt;
|
|
$percent = $floor + (int) floor(($ceiling - $floor) * (1 - exp(-$elapsed / 90)));
|
|
$percent = max($lastPercent + 1, min($ceiling, $percent));
|
|
|
|
$report($message, $percent);
|
|
$lastPercent = $percent;
|
|
$lastPulseAt = $now;
|
|
}
|
|
|
|
usleep(250_000);
|
|
}
|
|
}
|
|
}
|