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}" VITE_APP_NAME="${APP_NAME}"
# AndyTranscribe / Laravel AI # AndyTranscribe / local faster-whisper
OPENAI_API_KEY=
OPENAI_URL=https://api.openai.com/v1
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1 LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
LOCAL_WHISPER_API_KEY=not-needed LOCAL_WHISPER_API_KEY=not-needed
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
TRANSCRIPTION_TIMEOUT=600 TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run # Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660 DB_QUEUE_RETRY_AFTER=660
+10 -31
View File
@@ -1,6 +1,6 @@
# AndyTranscribe # 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). 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) - 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) - Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
- Search recordings by title, artist, or transcript - Search recordings by title, artist, or transcript
- Queued transcription with three engines: - Queued local transcription (faster-whisper in Docker)
- **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`
- Live transcription progress (stage, %, elapsed time) - Live transcription progress (stage, %, elapsed time)
- Stop or restart a run anytime
- Copy finished transcripts from the recording detail page - Copy finished transcripts from the recording detail page
## Requirements ## Requirements
@@ -22,9 +20,7 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
- Composer - Composer
- Node.js & npm - Node.js & npm
- SQLite (default) or another supported database - SQLite (default) or another supported database
- For **cloud** transcription: an OpenAI API key - [Docker](https://docs.docker.com/get-docker/) for the Whisper container
- 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
## Setup ## Setup
@@ -48,7 +44,7 @@ npm run build
## Local Whisper (Docker) ## 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 ```bash
# CPU (works everywhere; slower on long files) # CPU (works everywhere; slower on long files)
@@ -74,20 +70,15 @@ Stop:
docker compose down docker compose down
``` ```
Without this container, **Cloud** and **Ollama host** still work; only **Local** needs Docker.
## Configuration ## Configuration
Copy values from `.env.example`. The transcription-related settings are: Copy values from `.env.example`. The transcription-related settings are:
| Variable | Purpose | | 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_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_API_KEY` | API key for local server (often unused) |
| `LOCAL_WHISPER_MODEL` | Model name for local transcription | | `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`) | | `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) | | `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) | | `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 ## Running locally
Start the app, queue worker, and Vite together: Start Whisper, then the app stack:
```bash
composer run dev
```
For confidential local transcription, also start Whisper:
```bash ```bash
docker compose up -d whisper docker compose up -d whisper
composer run dev
``` ```
Or separately: Or separately:
```bash ```bash
docker compose up -d whisper
php artisan serve php artisan serve
php artisan queue:work php artisan queue:work
npm run dev npm run dev
@@ -126,18 +113,10 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
## Usage ## Usage
1. **Upload** audio from Recordings → Upload (optional title override). 1. **Upload** audio from Recordings → Upload (optional title override).
2. Open the recording and choose a transcription engine. 2. Open the recording and start transcription.
3. Watch live progress on the recording page until the transcript appears. 3. Watch live progress until the transcript appears (or stop and restart).
4. Search the list by title, artist, or transcript text. 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 ## Tests
```bash ```bash
@@ -10,9 +10,9 @@ use Illuminate\Http\RedirectResponse;
class TranscribeController extends Controller 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 public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{ {
@@ -21,11 +21,9 @@ class TranscribeController extends Controller
$recording->refresh(); $recording->refresh();
} }
$driver = $request->validated('driver');
$recording->update([ $recording->update([
'transcription_driver' => $driver, 'transcription_driver' => 'local',
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null, 'ollama_url' => null,
'transcription_status' => 'pending', 'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…', 'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5, 'transcription_percent' => 5,
@@ -3,7 +3,6 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TranscribeRecordingRequest extends FormRequest class TranscribeRecordingRequest extends FormRequest
{ {
@@ -17,27 +16,6 @@ class TranscribeRecordingRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
return [ 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://',
];
} }
} }
+31 -13
View File
@@ -78,10 +78,8 @@ class Recording extends Model
{ {
return Attribute::get(function (): ?string { return Attribute::get(function (): ?string {
return match ($this->transcription_driver) { return match ($this->transcription_driver) {
'cloud' => 'Cloud (OpenAI Whisper)',
'local' => 'Local (faster-whisper)', 'local' => 'Local (faster-whisper)',
'ollama' => 'Ollama host', default => $this->transcription_driver ?: 'Local (faster-whisper)',
default => $this->transcription_driver,
}; };
}); });
} }
@@ -160,7 +158,7 @@ class Recording extends Model
try { try {
$job = unserialize($command); $job = unserialize($command);
} catch (Throwable) { } catch (Throwable) {
return (bool) preg_match('/id";i:'.$this->id.';/', $payload); return $this->payloadMentionsRecording($payload);
} }
return $job instanceof TranscribeRecording 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.). * 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; $reference = $this->transcription_started_at ?? $this->updated_at;
// Allow a short window after dispatch before the row appears / worker claims it. if ($reference === null) {
if ($reference !== null && $reference->gt(now()->subSeconds(15))) { 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 false;
} }
return true; return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
} }
/** /**
@@ -202,11 +224,7 @@ class Recording extends Model
return false; return false;
} }
if ($runStartedAt === null || $this->transcription_started_at === null) { return $this->matchesTranscriptionRun($runStartedAt);
return false;
}
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
} }
/** /**
@@ -360,7 +378,7 @@ class Recording extends Model
public function transcriptionStatusPayload(): array public function transcriptionStatusPayload(): array
{ {
$startedAt = $this->transcription_started_at; $startedAt = $this->transcription_started_at;
$elapsed = $startedAt ? $startedAt->diffInSeconds(now()) : null; $elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null;
return [ return [
'id' => $this->id, 'id' => $this->id,
+1 -99
View File
@@ -4,52 +4,18 @@ namespace App\Services;
use App\Models\Recording; use App\Models\Recording;
use Closure; use Closure;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Ai\Transcription; use Laravel\Ai\Transcription;
use RuntimeException;
class TranscriptionService 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 * @param (Closure(string, int): void)|null $onProgress
*/ */
public function transcribe(Recording $recording, ?Closure $onProgress = null): string public function transcribe(Recording $recording, ?Closure $onProgress = null): string
{ {
$report = $onProgress ?? static fn (string $message, int $percent) => null; $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'); $model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
$report('Connecting to local faster-whisper server…', 30); $report('Connecting to local faster-whisper server…', 30);
@@ -63,68 +29,4 @@ class TranscriptionService
return (string) $transcript; 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), 'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'), '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')),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -37,7 +37,6 @@
<th class="px-4 py-3">Title</th> <th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th> <th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Status</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> <th class="px-4 py-3">Uploaded</th>
</tr> </tr>
</thead> </thead>
@@ -66,13 +65,6 @@
</div> </div>
@endif @endif
</td> </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> <td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr> </tr>
@endforeach @endforeach
+14 -57
View File
@@ -7,7 +7,6 @@
x-data="transcriptionMonitor(@js([ x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording), 'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(), 'initial' => $recording->transcriptionStatusPayload(),
'driver' => old('driver', $recording->transcription_driver ?: 'cloud'),
]))" ]))"
x-init="start()" x-init="start()"
> >
@@ -67,68 +66,25 @@
<dt class="text-stone-500">Uploaded</dt> <dt class="text-stone-500">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd> <dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div> </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> </dl>
</section> </section>
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm"> <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> <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 @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 <button
type="submit" type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800" class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
> >
<span x-text="startButtonLabel"></span> <span x-text="startButtonLabel"></span>
</button> </button>
</div>
</form> </form>
<form <form
@@ -227,11 +183,10 @@
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>[x-cloak]{display:none!important}</style> <style>[x-cloak]{display:none!important}</style>
<script> <script>
function transcriptionMonitor({ statusUrl, initial, driver }) { function transcriptionMonitor({ statusUrl, initial }) {
return { return {
statusUrl, statusUrl,
status: initial, status: initial,
driver: driver || 'cloud',
pollError: null, pollError: null,
timer: null, timer: null,
tickTimer: null, tickTimer: null,
@@ -250,7 +205,7 @@
get startButtonLabel() { get startButtonLabel() {
if (this.status.is_active) { if (this.status.is_active) {
return 'Switch engine / restart'; return 'Restart transcription';
} }
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription'; return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
@@ -313,15 +268,17 @@
}, },
formatElapsed(seconds) { formatElapsed(seconds) {
const minutes = Math.floor(seconds / 60); const total = Math.max(0, Math.round(Number(seconds) || 0));
const remain = seconds % 60; const minutes = Math.floor(total / 60);
const remain = total % 60;
if (minutes === 0) return remain + 's'; if (minutes === 0) return remain + 's';
return minutes + 'm ' + String(remain).padStart(2, '0') + 's'; return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
}, },
formatDuration(seconds) { formatDuration(seconds) {
const minutes = Math.floor(seconds / 60); const total = Math.max(0, Math.round(Number(seconds) || 0));
const remain = seconds % 60; const minutes = Math.floor(total / 60);
const remain = total % 60;
return minutes + ':' + String(remain).padStart(2, '0'); return minutes + ':' + String(remain).padStart(2, '0');
}, },
+48 -46
View File
@@ -84,7 +84,7 @@ class RecordingUploadTest extends TestCase
->assertSessionHasErrors('audio'); ->assertSessionHasErrors('audio');
} }
public function test_user_can_queue_cloud_transcription(): void public function test_user_can_queue_local_transcription(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
'title' => 'Dictation', 'title' => 'Dictation',
@@ -97,34 +97,16 @@ class RecordingUploadTest extends TestCase
// Fake AI so the afterResponse job (sync) does not call a real provider. // Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']); Transcription::fake(['Queued transcription text.']);
$this->post(route('recordings.transcribe', $recording), [ $this->post(route('recordings.transcribe', $recording))
'driver' => 'cloud', ->assertRedirect();
])->assertRedirect();
$recording->refresh(); $recording->refresh();
$this->assertSame('cloud', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']); $this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
$this->assertNotNull($recording->transcription_started_at); $this->assertNotNull($recording->transcription_started_at);
$this->assertNotNull($recording->transcription_progress); $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 public function test_transcription_status_endpoint_returns_progress(): void
{ {
$recording = Recording::query()->create([ $recording = Recording::query()->create([
@@ -133,19 +115,19 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/live.mp3', 'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100, 'file_size_bytes' => 100,
'transcription_status' => 'processing', 'transcription_status' => 'processing',
'transcription_progress' => 'Waiting for cloud transcript…', 'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 55, 'transcription_percent' => 55,
'transcription_driver' => 'cloud', 'transcription_driver' => 'local',
'transcription_started_at' => now()->subSeconds(12), 'transcription_started_at' => now()->subSeconds(12),
]); ]);
$this->getJson(route('recordings.transcription-status', $recording)) $this->getJson(route('recordings.transcription-status', $recording))
->assertOk() ->assertOk()
->assertJsonPath('status', 'processing') ->assertJsonPath('status', 'processing')
->assertJsonPath('progress', 'Waiting for cloud transcript…') ->assertJsonPath('progress', 'Transcribing locally…')
->assertJsonPath('percent', 55) ->assertJsonPath('percent', 55)
->assertJsonPath('is_active', true) ->assertJsonPath('is_active', true)
->assertJsonPath('driver_label', 'Cloud (OpenAI Whisper)'); ->assertJsonPath('driver_label', 'Local (faster-whisper)');
} }
public function test_transcription_job_stores_transcript(): void public function test_transcription_job_stores_transcript(): void
@@ -161,7 +143,7 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/sample.mp3', 'file_path' => 'recordings/sample.mp3',
'file_size_bytes' => 12, 'file_size_bytes' => 12,
'transcription_status' => 'pending', 'transcription_status' => 'pending',
'transcription_driver' => 'cloud', 'transcription_driver' => 'local',
]); ]);
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class)); (new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
@@ -189,7 +171,7 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/bad.mp3', 'file_path' => 'recordings/bad.mp3',
'file_size_bytes' => 12, 'file_size_bytes' => 12,
'transcription_status' => 'pending', 'transcription_status' => 'pending',
'transcription_driver' => 'cloud', 'transcription_driver' => 'local',
]); ]);
try { try {
@@ -215,8 +197,8 @@ class RecordingUploadTest extends TestCase
'transcription_progress' => 'Transcribing locally…', 'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 50, 'transcription_percent' => 50,
'transcription_driver' => 'local', 'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(5), 'transcription_started_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(5), 'updated_at' => now()->subMinutes(20),
]); ]);
$this->getJson(route('recordings.transcription-status', $recording)) $this->getJson(route('recordings.transcription-status', $recording))
@@ -229,6 +211,30 @@ class RecordingUploadTest extends TestCase
$this->assertStringContainsString('worker stopped', $recording->transcription_error); $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 public function test_orphaned_processing_can_be_restarted(): void
{ {
Transcription::fake(['Recovered transcript.']); Transcription::fake(['Recovered transcript.']);
@@ -244,12 +250,11 @@ class RecordingUploadTest extends TestCase
'updated_at' => now()->subMinutes(10), 'updated_at' => now()->subMinutes(10),
]); ]);
$this->post(route('recordings.transcribe', $recording), [ $this->post(route('recordings.transcribe', $recording))
'driver' => 'cloud', ->assertRedirect();
])->assertRedirect();
$recording->refresh(); $recording->refresh();
$this->assertSame('cloud', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']); $this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
} }
@@ -277,12 +282,12 @@ class RecordingUploadTest extends TestCase
$this->assertFalse($recording->isTranscribing()); $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([ $recording = Recording::query()->create([
'title' => 'Switch me', 'title' => 'Restart me while busy',
'original_filename' => 'switch.mp3', 'original_filename' => 'switch.mp3',
'file_path' => 'recordings/switch.mp3', 'file_path' => 'recordings/switch.mp3',
'file_size_bytes' => 100, 'file_size_bytes' => 100,
@@ -291,14 +296,12 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now()->subMinute(), 'transcription_started_at' => now()->subMinute(),
]); ]);
$this->post(route('recordings.transcribe', $recording), [ $this->post(route('recordings.transcribe', $recording))
'driver' => 'cloud',
])
->assertRedirect() ->assertRedirect()
->assertSessionHas('success'); ->assertSessionHas('success');
$recording->refresh(); $recording->refresh();
$this->assertSame('cloud', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']); $this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
$this->assertNull($recording->transcription_error); $this->assertNull($recording->transcription_error);
} }
@@ -316,7 +319,7 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/ignore.mp3', 'file_path' => 'recordings/ignore.mp3',
'file_size_bytes' => 12, 'file_size_bytes' => 12,
'transcription_status' => 'processing', 'transcription_status' => 'processing',
'transcription_driver' => 'cloud', 'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinute(), 'transcription_started_at' => now()->subMinute(),
'transcript' => 'Previous transcript stays.', 'transcript' => 'Previous transcript stays.',
]); ]);
@@ -368,14 +371,13 @@ class RecordingUploadTest extends TestCase
'file_path' => 'recordings/keep.mp3', 'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 100, 'file_size_bytes' => 100,
'transcription_status' => 'done', 'transcription_status' => 'done',
'transcription_driver' => 'cloud', 'transcription_driver' => 'local',
'transcript' => 'Old transcript text.', 'transcript' => 'Old transcript text.',
'transcribed_at' => now()->subHour(), 'transcribed_at' => now()->subHour(),
]); ]);
$this->post(route('recordings.transcribe', $recording), [ $this->post(route('recordings.transcribe', $recording))
'driver' => 'local', ->assertRedirect();
])->assertRedirect();
$recording->refresh(); $recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript); $this->assertSame('Old transcript text.', $recording->transcript);