Use local faster-whisper only for transcription.

Drop cloud and Ollama engine choices so audio stays on-machine via Docker Whisper, and tighten orphan detection plus UI around a single local flow.
This commit is contained in:
ben
2026-08-12 15:34:16 +02:00
parent bc6cb5efa4
commit 21b17c7657
10 changed files with 117 additions and 295 deletions
@@ -10,9 +10,9 @@ use Illuminate\Http\RedirectResponse;
class TranscribeController extends Controller
{
/**
* Queue transcription for the recording with the chosen engine.
* Queue local faster-whisper transcription for the recording.
*
* Always allowed: stops any current run first, then starts the new engine.
* Always allowed: stops any current run first, then starts a new one.
*/
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{
@@ -21,11 +21,9 @@ class TranscribeController extends Controller
$recording->refresh();
}
$driver = $request->validated('driver');
$recording->update([
'transcription_driver' => $driver,
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
'transcription_driver' => 'local',
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5,
@@ -3,7 +3,6 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TranscribeRecordingRequest extends FormRequest
{
@@ -17,27 +16,6 @@ class TranscribeRecordingRequest extends FormRequest
*/
public function rules(): array
{
return [
'driver' => ['required', Rule::in(['cloud', 'local', 'ollama'])],
'ollama_url' => [
Rule::requiredIf(fn () => $this->input('driver') === 'ollama'),
'nullable',
'url',
'regex:/^https?:\/\//i',
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'driver.required' => 'Choose a transcription engine.',
'ollama_url.required' => 'Enter the URL of the Ollama host (OpenAI-compatible Whisper endpoint).',
'ollama_url.url' => 'Enter a valid URL, e.g. http://192.168.1.50:8000',
'ollama_url.regex' => 'The host URL must start with http:// or https://',
];
return [];
}
}
+31 -13
View File
@@ -78,10 +78,8 @@ class Recording extends Model
{
return Attribute::get(function (): ?string {
return match ($this->transcription_driver) {
'cloud' => 'Cloud (OpenAI Whisper)',
'local' => 'Local (faster-whisper)',
'ollama' => 'Ollama host',
default => $this->transcription_driver,
default => $this->transcription_driver ?: 'Local (faster-whisper)',
};
});
}
@@ -160,7 +158,7 @@ class Recording extends Model
try {
$job = unserialize($command);
} catch (Throwable) {
return (bool) preg_match('/id";i:'.$this->id.';/', $payload);
return $this->payloadMentionsRecording($payload);
}
return $job instanceof TranscribeRecording
@@ -168,6 +166,15 @@ class Recording extends Model
});
}
/**
* Fallback payload match when unserialize is unavailable.
*/
private function payloadMentionsRecording(string $payload): bool
{
return (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"');
}
/**
* Processing/pending with no worker job left (crashed worker, bad retry_after, etc.).
*/
@@ -183,12 +190,27 @@ class Recording extends Model
$reference = $this->transcription_started_at ?? $this->updated_at;
// Allow a short window after dispatch before the row appears / worker claims it.
if ($reference !== null && $reference->gt(now()->subSeconds(15))) {
if ($reference === null) {
return true;
}
// Only treat as orphaned after the job could not possibly still be running.
// (A short grace caused false failures while Whisper was still working.)
$orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60);
return $reference->lte(now()->subSeconds($orphanAfterSeconds));
}
/**
* Whether a payload/job belongs to this recording's transcription run start time.
*/
public function matchesTranscriptionRun(?string $runStartedAt): bool
{
if ($runStartedAt === null || $this->transcription_started_at === null) {
return false;
}
return true;
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
}
/**
@@ -202,11 +224,7 @@ class Recording extends Model
return false;
}
if ($runStartedAt === null || $this->transcription_started_at === null) {
return false;
}
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
return $this->matchesTranscriptionRun($runStartedAt);
}
/**
@@ -360,7 +378,7 @@ class Recording extends Model
public function transcriptionStatusPayload(): array
{
$startedAt = $this->transcription_started_at;
$elapsed = $startedAt ? $startedAt->diffInSeconds(now()) : null;
$elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null;
return [
'id' => $this->id,
+1 -99
View File
@@ -4,52 +4,18 @@ namespace App\Services;
use App\Models\Recording;
use Closure;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Ai\Transcription;
use RuntimeException;
class TranscriptionService
{
/**
* Run transcription for a recording using the selected driver.
* 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;
return match ($recording->transcription_driver) {
'cloud' => $this->viaCloud($recording, $report),
'local' => $this->viaLocal($recording, $report),
'ollama' => $this->viaRemoteCompatible($recording, $report),
default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver),
};
}
/**
* @param Closure(string, int): void $report
*/
private function viaCloud(Recording $recording, Closure $report): string
{
$report('Sending audio to OpenAI Whisper…', 35);
$report('Waiting for cloud transcript (this can take a while for long recordings)…', 55);
$transcript = Transcription::fromStorage($recording->file_path)
->timeout((int) config('ai.transcription_timeout', 600))
->generate('openai', 'whisper-1');
$report('Received transcript from OpenAI…', 85);
return (string) $transcript;
}
/**
* @param Closure(string, int): void $report
*/
private function viaLocal(Recording $recording, Closure $report): string
{
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
$report('Connecting to local faster-whisper server…', 30);
@@ -63,68 +29,4 @@ class TranscriptionService
return (string) $transcript;
}
/**
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
*
* @param Closure(string, int): void $report
*/
private function viaRemoteCompatible(Recording $recording, Closure $report): string
{
if (! filled($recording->ollama_url)) {
throw new RuntimeException('Ollama host URL is required for remote transcription.');
}
$base = $this->normalizeBaseUrl($recording->ollama_url);
$model = config('ai.remote_whisper_model', config('ai.local_whisper_model', 'Systran/faster-whisper-base'));
$path = $recording->absolutePath();
if (! is_readable($path)) {
throw new RuntimeException('Recording audio file is not readable.');
}
$report('Connecting to remote host '.$recording->ollama_url.'…', 30);
$report("Uploading audio and waiting for transcript ({$model})…", 50);
$response = Http::timeout((int) config('ai.transcription_timeout', 600))
->attach(
'file',
fopen($path, 'r'),
$recording->original_filename ?: basename($path),
)
->post($base.'/audio/transcriptions', [
'model' => $model,
'response_format' => 'json',
]);
if (! $response->successful()) {
throw new RuntimeException(
'Remote transcription failed (HTTP '.$response->status().'): '.$response->body()
);
}
$text = $response->json('text');
if (! is_string($text) || $text === '') {
throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.');
}
$report('Received transcript from remote host…', 85);
return $text;
}
/**
* Normalize a user URL to an OpenAI-style base ending in /v1.
*/
private function normalizeBaseUrl(string $url): string
{
$url = rtrim(trim($url), '/');
if (Str::endsWith($url, '/v1')) {
return $url;
}
return $url.'/v1';
}
}