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
+1 -4
View File
@@ -64,13 +64,10 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# AndyTranscribe / Laravel AI
OPENAI_API_KEY=
OPENAI_URL=https://api.openai.com/v1
# AndyTranscribe / local faster-whisper
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
LOCAL_WHISPER_API_KEY=not-needed
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660
+10 -31
View File
@@ -1,6 +1,6 @@
# AndyTranscribe
Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe with OpenAI Whisper, a local faster-whisper server, or a remote OpenAI-compatible endpoint.
Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker. Audio never leaves your machine.
Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai).
@@ -9,11 +9,9 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 100 MB)
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
- Search recordings by title, artist, or transcript
- Queued transcription with three engines:
- **Cloud** — OpenAI Whisper (`whisper-1`)
- **Local** — confidential; OpenAI-compatible [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker
- **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions`
- Queued local transcription (faster-whisper in Docker)
- Live transcription progress (stage, %, elapsed time)
- Stop or restart a run anytime
- Copy finished transcripts from the recording detail page
## Requirements
@@ -22,9 +20,7 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
- Composer
- Node.js & npm
- SQLite (default) or another supported database
- For **cloud** transcription: an OpenAI API key
- For **local** transcription: [Docker](https://docs.docker.com/get-docker/) (runs Whisper in a container)
- For **remote** transcription: a host with an OpenAI-compatible transcription API
- [Docker](https://docs.docker.com/get-docker/) for the Whisper container
## Setup
@@ -48,7 +44,7 @@ npm run build
## Local Whisper (Docker)
The **Local** engine does not run Whisper inside PHP. It calls an OpenAI-compatible HTTP API. This project ships Compose for that:
Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that:
```bash
# CPU (works everywhere; slower on long files)
@@ -74,20 +70,15 @@ Stop:
docker compose down
```
Without this container, **Cloud** and **Ollama host** still work; only **Local** needs Docker.
## Configuration
Copy values from `.env.example`. The transcription-related settings are:
| Variable | Purpose |
| --- | --- |
| `OPENAI_API_KEY` | Required for cloud Whisper |
| `OPENAI_URL` | OpenAI API base URL (default `https://api.openai.com/v1`) |
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) |
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
| `REMOTE_WHISPER_MODEL` | Model name for Ollama-host transcription |
| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) |
@@ -99,21 +90,17 @@ Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`
## Running locally
Start the app, queue worker, and Vite together:
```bash
composer run dev
```
For confidential local transcription, also start Whisper:
Start Whisper, then the app stack:
```bash
docker compose up -d whisper
composer run dev
```
Or separately:
```bash
docker compose up -d whisper
php artisan serve
php artisan queue:work
npm run dev
@@ -126,18 +113,10 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
## Usage
1. **Upload** audio from Recordings → Upload (optional title override).
2. Open the recording and choose a transcription engine.
3. Watch live progress on the recording page until the transcript appears.
2. Open the recording and start transcription.
3. Watch live progress until the transcript appears (or stop and restart).
4. Search the list by title, artist, or transcript text.
## Transcription engines
| Driver | When to use | Needs |
| --- | --- | --- |
| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` |
| `local` | Confidential; audio stays on this machine | `docker compose up -d whisper` |
| `ollama` | Another machine on your network | Host URL + OpenAI-compatible `/v1/audio/transcriptions` |
## Tests
```bash
@@ -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';
}
}
-1
View File
@@ -28,7 +28,6 @@ return [
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
'remote_whisper_model' => env('REMOTE_WHISPER_MODEL', env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base')),
/*
|--------------------------------------------------------------------------
+1 -9
View File
@@ -37,7 +37,6 @@
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Engine</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
</thead>
@@ -58,7 +57,7 @@
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
<td class="px-4 py-3">
<td class="px-4 py-3">
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
@if ($recording->isTranscribing() && $recording->transcription_progress)
<div class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" title="{{ $recording->transcription_progress }}">
@@ -66,13 +65,6 @@
</div>
@endif
</td>
<td class="px-4 py-3 text-stone-600">
@if ($recording->transcription_driver)
<span class="uppercase tracking-wide text-xs">{{ $recording->transcription_driver }}</span>
@else
@endif
</td>
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
+20 -63
View File
@@ -7,7 +7,6 @@
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
'driver' => old('driver', $recording->transcription_driver ?: 'cloud'),
]))"
x-init="start()"
>
@@ -67,68 +66,25 @@
<dt class="text-stone-500">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
@if ($recording->ollama_url)
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Ollama host</dt>
<dd class="max-w-[60%] break-all text-right font-medium">{{ $recording->ollama_url }}</dd>
</div>
@endif
</dl>
</section>
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
<p class="mt-2 text-sm text-stone-600">Choose an engine anytime starting a new run stops the current one.</p>
<p class="mt-2 text-sm text-stone-600">
Audio stays on this machine. Requires
<code class="text-[11px]">docker compose up -d whisper</code>
(port 8090).
</p>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-4">
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
<input type="radio" name="driver" value="cloud" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
<span>
<span class="block text-sm font-medium">Cloud (OpenAI Whisper)</span>
<span class="block text-xs text-stone-500">Fast; audio is sent to OpenAI.</span>
</span>
</label>
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
<input type="radio" name="driver" value="local" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
<span>
<span class="block text-sm font-medium">Local confidential (faster-whisper)</span>
<span class="block text-xs text-stone-500">Runs via Docker Compose (<code class="text-[11px]">docker compose up -d whisper</code>) on port 8090.</span>
</span>
</label>
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
<input type="radio" name="driver" value="ollama" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
<span>
<span class="block text-sm font-medium">Ollama host</span>
<span class="block text-xs text-stone-500">
Remote host URL. Must expose OpenAI-compatible <code class="text-[11px]">/v1/audio/transcriptions</code>.
</span>
</span>
</label>
<div x-show="driver === 'ollama'" x-cloak class="space-y-2">
<label for="ollama_url" class="block text-sm font-medium text-stone-700">Host URL</label>
<input
id="ollama_url"
type="url"
name="ollama_url"
value="{{ old('ollama_url', $recording->ollama_url) }}"
placeholder="http://192.168.1.50:8000"
class="w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
>
<span x-text="startButtonLabel"></span>
</button>
</div>
<button
type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
>
<span x-text="startButtonLabel"></span>
</button>
</form>
<form
@@ -227,11 +183,10 @@
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style>
<script>
function transcriptionMonitor({ statusUrl, initial, driver }) {
function transcriptionMonitor({ statusUrl, initial }) {
return {
statusUrl,
status: initial,
driver: driver || 'cloud',
pollError: null,
timer: null,
tickTimer: null,
@@ -250,7 +205,7 @@
get startButtonLabel() {
if (this.status.is_active) {
return 'Switch engine / restart';
return 'Restart transcription';
}
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
@@ -313,15 +268,17 @@
},
formatElapsed(seconds) {
const minutes = Math.floor(seconds / 60);
const remain = seconds % 60;
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
if (minutes === 0) return remain + 's';
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
},
formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
const remain = seconds % 60;
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
return minutes + ':' + String(remain).padStart(2, '0');
},
+48 -46
View File
@@ -84,7 +84,7 @@ class RecordingUploadTest extends TestCase
->assertSessionHasErrors('audio');
}
public function test_user_can_queue_cloud_transcription(): void
public function test_user_can_queue_local_transcription(): void
{
$recording = Recording::query()->create([
'title' => 'Dictation',
@@ -97,34 +97,16 @@ class RecordingUploadTest extends TestCase
// Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']);
$this->post(route('recordings.transcribe', $recording), [
'driver' => 'cloud',
])->assertRedirect();
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
$recording->refresh();
$this->assertSame('cloud', $recording->transcription_driver);
$this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
$this->assertNotNull($recording->transcription_started_at);
$this->assertNotNull($recording->transcription_progress);
}
public function test_ollama_driver_requires_url(): void
{
$recording = Recording::query()->create([
'title' => 'Secret call',
'original_filename' => 'call.mp3',
'file_path' => 'recordings/call.mp3',
'file_size_bytes' => 2048,
'transcription_status' => 'pending',
]);
$this->from(route('recordings.show', $recording))
->post(route('recordings.transcribe', $recording), [
'driver' => 'ollama',
])
->assertSessionHasErrors('ollama_url');
}
public function test_transcription_status_endpoint_returns_progress(): void
{
$recording = Recording::query()->create([
@@ -133,19 +115,19 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Waiting for cloud transcript…',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 55,
'transcription_driver' => 'cloud',
'transcription_driver' => 'local',
'transcription_started_at' => now()->subSeconds(12),
]);
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'processing')
->assertJsonPath('progress', 'Waiting for cloud transcript…')
->assertJsonPath('progress', 'Transcribing locally…')
->assertJsonPath('percent', 55)
->assertJsonPath('is_active', true)
->assertJsonPath('driver_label', 'Cloud (OpenAI Whisper)');
->assertJsonPath('driver_label', 'Local (faster-whisper)');
}
public function test_transcription_job_stores_transcript(): void
@@ -161,7 +143,7 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/sample.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'cloud',
'transcription_driver' => 'local',
]);
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
@@ -189,7 +171,7 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/bad.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'cloud',
'transcription_driver' => 'local',
]);
try {
@@ -215,8 +197,8 @@ class RecordingUploadTest extends TestCase
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 50,
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(5),
'updated_at' => now()->subMinutes(5),
'transcription_started_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
$this->getJson(route('recordings.transcription-status', $recording))
@@ -229,6 +211,30 @@ class RecordingUploadTest extends TestCase
$this->assertStringContainsString('worker stopped', $recording->transcription_error);
}
public function test_recent_processing_is_not_marked_orphaned(): void
{
$recording = Recording::query()->create([
'title' => 'Still working',
'original_filename' => 'working.mp3',
'file_path' => 'recordings/working.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 50,
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(2),
'updated_at' => now()->subMinutes(2),
]);
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'processing')
->assertJsonPath('is_active', true);
$recording->refresh();
$this->assertSame('processing', $recording->transcription_status);
}
public function test_orphaned_processing_can_be_restarted(): void
{
Transcription::fake(['Recovered transcript.']);
@@ -244,12 +250,11 @@ class RecordingUploadTest extends TestCase
'updated_at' => now()->subMinutes(10),
]);
$this->post(route('recordings.transcribe', $recording), [
'driver' => 'cloud',
])->assertRedirect();
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
$recording->refresh();
$this->assertSame('cloud', $recording->transcription_driver);
$this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
}
@@ -277,12 +282,12 @@ class RecordingUploadTest extends TestCase
$this->assertFalse($recording->isTranscribing());
}
public function test_user_can_start_a_different_engine_while_processing(): void
public function test_user_can_restart_transcription_while_processing(): void
{
Transcription::fake(['Switched engine transcript.']);
Transcription::fake(['Restarted transcript.']);
$recording = Recording::query()->create([
'title' => 'Switch me',
'title' => 'Restart me while busy',
'original_filename' => 'switch.mp3',
'file_path' => 'recordings/switch.mp3',
'file_size_bytes' => 100,
@@ -291,14 +296,12 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now()->subMinute(),
]);
$this->post(route('recordings.transcribe', $recording), [
'driver' => 'cloud',
])
$this->post(route('recordings.transcribe', $recording))
->assertRedirect()
->assertSessionHas('success');
$recording->refresh();
$this->assertSame('cloud', $recording->transcription_driver);
$this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
$this->assertNull($recording->transcription_error);
}
@@ -316,7 +319,7 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/ignore.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'processing',
'transcription_driver' => 'cloud',
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinute(),
'transcript' => 'Previous transcript stays.',
]);
@@ -368,14 +371,13 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcription_driver' => 'cloud',
'transcription_driver' => 'local',
'transcript' => 'Old transcript text.',
'transcribed_at' => now()->subHour(),
]);
$this->post(route('recordings.transcribe', $recording), [
'driver' => 'local',
])->assertRedirect();
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
$recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript);