Add live transcription progress and Docker Whisper.

Surface stage, percent, and elapsed time while jobs run, and ship Compose so local confidential transcription can use faster-whisper on port 8090.
This commit is contained in:
ben
2026-08-12 14:32:56 +02:00
parent 67c1941833
commit e0400aadef
14 changed files with 695 additions and 177 deletions
+4 -1
View File
@@ -67,8 +67,11 @@ VITE_APP_NAME="${APP_NAME}"
# AndyTranscribe / Laravel AI
OPENAI_API_KEY=
OPENAI_URL=https://api.openai.com/v1
LOCAL_WHISPER_URL=http://localhost:8000/v1
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
# Host port for docker compose whisper service
WHISPER_HOST_PORT=8090
+49 -11
View File
@@ -1,18 +1,19 @@
# AndyTranscribe
Upload pocket-recorder MP3s, extract ID3 metadata, and transcribe them 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 with OpenAI Whisper, a local faster-whisper server, or a remote OpenAI-compatible endpoint.
Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai).
## Features
- Upload MP3s (up to 100 MB) and store them on the local disk
- Automatic ID3 metadata extraction (title, artist, album, duration, recorded date)
- 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) (e.g. Docker on this machine)
- **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)
- Copy finished transcripts from the recording detail page
## Requirements
@@ -21,9 +22,9 @@ 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: a running faster-whisper-server
- For remote transcription: a host with an OpenAI-compatible transcription API
- 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
## Setup
@@ -45,6 +46,36 @@ npm install
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:
```bash
# CPU (works everywhere; slower on long files)
docker compose up -d whisper
# Optional: NVIDIA GPU
docker compose --profile gpu up -d whisper-gpu
```
First start downloads the model into a Docker volume (can take a few minutes).
Check it:
```bash
curl -s http://127.0.0.1:8090/health
```
Laravel talks to it at `LOCAL_WHISPER_URL` (default `http://127.0.0.1:8090/v1`). Port **8090** is used so it does not conflict with `php artisan serve` on 8000.
Stop:
```bash
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:
@@ -53,10 +84,11 @@ Copy values from `.env.example`. The transcription-related settings are:
| --- | --- |
| `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://localhost:8000/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`) |
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
@@ -70,6 +102,12 @@ Start the app, queue worker, and Vite together:
composer run dev
```
For confidential local transcription, also start Whisper:
```bash
docker compose up -d whisper
```
Or separately:
```bash
@@ -84,9 +122,9 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
## Usage
1. **Upload** an MP3 from Recordings → Upload (optional title override).
1. **Upload** audio from Recordings → Upload (optional title override).
2. Open the recording and choose a transcription engine.
3. Wait for the queue job to finish, then refresh to view or copy the transcript.
3. Watch live progress on the recording page until the transcript appears.
4. Search the list by title, artist, or transcript text.
## Transcription engines
@@ -94,7 +132,7 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
| Driver | When to use | Needs |
| --- | --- | --- |
| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` |
| `local` | Confidential; audio stays on this machine | faster-whisper-server at `LOCAL_WHISPER_URL` |
| `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
@@ -14,7 +14,7 @@ class TranscribeController extends Controller
*/
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{
if (in_array($recording->transcription_status, ['processing'], true)) {
if ($recording->transcription_status === 'processing') {
return back()->with('error', 'Transcription is already in progress.');
}
@@ -24,12 +24,16 @@ class TranscribeController extends Controller
'transcription_driver' => $driver,
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5,
'transcription_started_at' => now(),
'transcription_error' => null,
'transcript' => null,
'transcribed_at' => null,
]);
TranscribeRecording::dispatch($recording->fresh());
return back()->with('success', 'Transcription started. Refresh in a moment to see the result.');
return back()->with('success', 'Transcription started. Progress updates below.');
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\JsonResponse;
class TranscriptionStatusController extends Controller
{
/**
* Live transcription progress for polling.
*/
public function __invoke(Recording $recording): JsonResponse
{
return response()->json($recording->fresh()->transcriptionStatusPayload());
}
}
+36 -7
View File
@@ -31,18 +31,32 @@ class TranscribeRecording implements ShouldQueue
*/
public function handle(TranscriptionService $transcription): void
{
$this->recording->update([
$this->recording->refresh();
$this->recording->forceFill([
'transcription_status' => 'processing',
]);
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
'transcription_error' => null,
])->save();
$this->recording->reportProgress('Preparing audio file…', 15);
try {
$text = $transcription->transcribe($this->recording);
$text = $transcription->transcribe(
$this->recording,
fn (string $message, int $percent) => $this->recording->reportProgress($message, $percent),
);
$this->recording->update([
$this->recording->reportProgress('Saving transcript…', 90);
$this->recording->forceFill([
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => now(),
]);
])->save();
} catch (Throwable $e) {
Log::error('Transcription failed', [
'recording_id' => $this->recording->id,
@@ -50,11 +64,26 @@ class TranscribeRecording implements ShouldQueue
'message' => $e->getMessage(),
]);
$this->recording->update([
$this->recording->forceFill([
'transcription_status' => 'failed',
]);
'transcription_progress' => 'Transcription failed',
'transcription_percent' => $this->recording->transcription_percent ?: 0,
'transcription_error' => $e->getMessage(),
])->save();
throw $e;
}
}
/**
* Handle a job failure (timeouts, worker kill, etc.).
*/
public function failed(?Throwable $e): void
{
$this->recording->forceFill([
'transcription_status' => 'failed',
'transcription_progress' => 'Transcription failed',
'transcription_error' => $e?->getMessage() ?: 'Transcription stopped unexpectedly.',
])->save();
}
}
+82
View File
@@ -22,6 +22,10 @@ class Recording extends Model
'file_size_bytes',
'transcript',
'transcription_status',
'transcription_progress',
'transcription_percent',
'transcription_started_at',
'transcription_error',
'transcription_driver',
'ollama_url',
'transcribed_at',
@@ -35,8 +39,10 @@ class Recording extends Model
return [
'recorded_at' => 'datetime',
'transcribed_at' => 'datetime',
'transcription_started_at' => 'datetime',
'duration_seconds' => 'integer',
'file_size_bytes' => 'integer',
'transcription_percent' => 'integer',
];
}
@@ -57,6 +63,42 @@ class Recording extends Model
});
}
/**
* Friendly label for the selected transcription engine.
*/
protected function transcriptionDriverLabel(): Attribute
{
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,
};
});
}
/**
* Whether transcription is actively running or queued.
*/
public function isTranscribing(): bool
{
return in_array($this->transcription_status, ['pending', 'processing'], true);
}
/**
* Update the live progress fields shown in the UI.
*/
public function reportProgress(string $message, int $percent, string $status = 'processing'): void
{
$this->forceFill([
'transcription_status' => $status,
'transcription_progress' => $message,
'transcription_percent' => max(0, min(100, $percent)),
'transcription_error' => null,
])->save();
}
/**
* Absolute filesystem path for the stored audio file.
*/
@@ -74,4 +116,44 @@ class Recording extends Model
Storage::disk('local')->delete($this->file_path);
}
}
/**
* Payload for the live status endpoint / Alpine poller.
*
* @return array<string, mixed>
*/
public function transcriptionStatusPayload(): array
{
$startedAt = $this->transcription_started_at;
$elapsed = $startedAt ? $startedAt->diffInSeconds(now()) : null;
return [
'id' => $this->id,
'status' => $this->transcription_status,
'progress' => $this->transcription_progress,
'percent' => $this->transcription_percent,
'driver' => $this->transcription_driver,
'driver_label' => $this->transcription_driver_label,
'error' => $this->transcription_error,
'started_at' => $startedAt?->toIso8601String(),
'elapsed_seconds' => $elapsed,
'elapsed_human' => $elapsed === null ? null : $this->formatElapsed($elapsed),
'duration_seconds' => $this->duration_seconds,
'is_active' => $this->isTranscribing(),
'has_transcript' => filled($this->transcript),
'transcribed_at' => $this->transcribed_at?->toIso8601String(),
];
}
private function formatElapsed(int $seconds): string
{
$minutes = intdiv($seconds, 60);
$remain = $seconds % 60;
if ($minutes === 0) {
return sprintf('%ds', $remain);
}
return sprintf('%dm %02ds', $minutes, $remain);
}
}
+35 -7
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Recording;
use Closure;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Ai\Transcription;
@@ -12,41 +13,63 @@ class TranscriptionService
{
/**
* Run transcription for a recording using the selected driver.
*
* @param (Closure(string, int): void)|null $onProgress
*/
public function transcribe(Recording $recording): string
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),
'local' => $this->viaLocal($recording),
'ollama' => $this->viaRemoteCompatible($recording),
'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),
};
}
private function viaCloud(Recording $recording): string
/**
* @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;
}
private function viaLocal(Recording $recording): string
/**
* @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);
$report("Transcribing locally with {$model} (audio stays on this machine)…", 50);
$transcript = Transcription::fromStorage($recording->file_path)
->timeout((int) config('ai.transcription_timeout', 600))
->generate('local-whisper', $model);
$report('Received transcript from local Whisper…', 85);
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): string
private function viaRemoteCompatible(Recording $recording, Closure $report): string
{
if (! filled($recording->ollama_url)) {
throw new RuntimeException('Ollama host URL is required for remote transcription.');
@@ -60,6 +83,9 @@ class TranscriptionService
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',
@@ -83,6 +109,8 @@ class TranscriptionService
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;
}
+1 -1
View File
@@ -149,7 +149,7 @@ return [
'local-whisper' => [
'driver' => 'openai',
'key' => env('LOCAL_WHISPER_API_KEY', 'not-needed'),
'url' => env('LOCAL_WHISPER_URL', 'http://localhost:8000/v1'),
'url' => env('LOCAL_WHISPER_URL', 'http://127.0.0.1:8090/v1'),
'store' => false,
],
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->string('transcription_progress')->nullable()->after('transcription_status');
$table->unsignedTinyInteger('transcription_percent')->nullable()->after('transcription_progress');
$table->timestamp('transcription_started_at')->nullable()->after('transcription_percent');
$table->text('transcription_error')->nullable()->after('transcription_started_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropColumn([
'transcription_progress',
'transcription_percent',
'transcription_started_at',
'transcription_error',
]);
});
}
};
+43
View File
@@ -0,0 +1,43 @@
services:
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: andytranscribe-whisper
ports:
# Host 8090 avoids clashing with `php artisan serve` on 8000
- "${WHISPER_HOST_PORT:-8090}:8000"
volumes:
- whisper-huggingface-cache:/root/.cache/huggingface
environment:
# OpenAI-compatible API; model is also passed per request from Laravel
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
# GPU variant (NVIDIA). Start with:
# docker compose --profile gpu up -d whisper-gpu
whisper-gpu:
profiles: ["gpu"]
image: fedirz/faster-whisper-server:latest-cuda
container_name: andytranscribe-whisper-gpu
ports:
- "${WHISPER_HOST_PORT:-8090}:8000"
volumes:
- whisper-huggingface-cache:/root/.cache/huggingface
environment:
WHISPER__MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
whisper-huggingface-cache:
+7 -2
View File
@@ -6,7 +6,7 @@
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder MP3s and transcripts.</p>
<p class="mt-1 text-sm text-stone-600">Manage pocket-recorder audio and transcripts.</p>
</div>
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
@@ -53,8 +53,13 @@
@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 }}">
{{ $recording->transcription_percent ? $recording->transcription_percent.'% · ' : '' }}{{ $recording->transcription_progress }}
</div>
@endif
</td>
<td class="px-4 py-3 text-stone-600">
@if ($recording->transcription_driver)
+282 -141
View File
@@ -3,155 +3,296 @@
@section('title', $recording->title)
@section('content')
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800"> Recordings</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600">
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
@if ($recording->transcription_driver)
<span class="text-xs uppercase tracking-wide text-stone-500">{{ $recording->transcription_driver }}</span>
@endif
<div
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
'driver' => old('driver', $recording->transcription_driver ?: 'cloud'),
]))"
x-init="start()"
>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800"> Recordings</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass"
x-text="status.status"
></span>
<template x-if="status.driver_label">
<span class="text-xs text-stone-500" x-text="status.driver_label"></span>
</template>
</div>
</div>
</div>
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
@csrf
@method('DELETE')
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50">
Delete
</button>
</form>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<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">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Original file</dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Recorded</dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Size</dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<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
x-data="{ driver: '{{ old('driver', $recording->transcription_driver ?: 'cloud') }}' }"
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 how to convert this MP3 to text.</p>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-4">
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
@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">Requires faster-whisper-server on this machine (Docker).</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>
(e.g. Whisper beside Ollama).
</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>
<button
type="submit"
@disabled($recording->transcription_status === 'processing')
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ $recording->transcript ? 'Re-transcribe' : 'Start transcription' }}
@method('DELETE')
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50">
Delete
</button>
</form>
</section>
</div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2>
@if ($recording->transcript)
<button
type="button"
onclick="navigator.clipboard.writeText(@js($recording->transcript))"
class="text-sm text-teal-700 hover:underline"
>
Copy
</button>
@endif
</div>
@if ($recording->transcription_status === 'processing')
<p class="mt-4 text-sm text-amber-700">Transcription in progress… Refresh shortly.</p>
@elseif ($recording->transcription_status === 'failed')
<p class="mt-4 text-sm text-red-700">Transcription failed. Check the logs and try again with another engine.</p>
@elseif ($recording->transcript)
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800">{{ $recording->transcript }}</p>
@if ($recording->transcribed_at)
<p class="mt-4 text-xs text-stone-500">Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}</p>
<div class="grid gap-6 lg:grid-cols-2">
<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">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Original file</dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Recorded</dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500">Size</dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<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 how to convert this recording to text.</p>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-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" :disabled="status.is_active">
<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" :disabled="status.is_active">
<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" :disabled="status.is_active">
<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"
:disabled="status.is_active"
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 disabled:opacity-50"
>
</div>
<button
type="submit"
:disabled="status.is_active"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<span x-text="status.has_transcript ? 'Re-transcribe' : 'Start transcription'"></span>
</button>
</form>
</section>
</div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcript</h2>
@if ($recording->transcript)
<button
type="button"
x-show="!status.is_active && status.has_transcript"
onclick="navigator.clipboard.writeText(@js($recording->transcript))"
class="text-sm text-teal-700 hover:underline"
>
Copy
</button>
@endif
</div>
<div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<p class="font-medium text-amber-900" x-text="status.progress || 'Working…'"></p>
<p class="text-amber-800 tabular-nums">
<span x-text="(status.percent ?? 0) + '%'"></span>
<span class="mx-1 text-amber-600">·</span>
<span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span>
</p>
</div>
<div class="h-2 overflow-hidden rounded-full bg-amber-100">
<div
class="h-full rounded-full bg-amber-500 transition-all duration-500"
:style="`width: ${Math.max(status.percent || 5, 5)}%`"
></div>
</div>
<ul class="space-y-1 text-xs text-amber-900/80">
<li>
Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span>
</li>
<template x-if="status.duration_seconds">
<li>
Audio length:
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
<span class="text-amber-700">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-700" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<p class="font-medium">Transcription failed</p>
<p x-text="status.error || 'Check the logs and try again with another engine.'"></p>
</div>
@if ($recording->transcript)
<div x-show="!status.is_active && status.has_transcript">
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800">{{ $recording->transcript }}</p>
@if ($recording->transcribed_at)
<p class="mt-4 text-xs text-stone-500">Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}</p>
@endif
</div>
@else
<p
x-show="!status.is_active && status.status !== 'failed' && !status.has_transcript"
class="mt-4 text-sm text-stone-500"
>
No transcript yet. Choose an engine above to start.
</p>
@endif
@else
<p class="mt-4 text-sm text-stone-500">No transcript yet. Choose an engine above to start.</p>
@endif
</section>
</section>
</div>
<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 }) {
return {
statusUrl,
status: initial,
driver: driver || 'cloud',
pollError: null,
timer: null,
tickTimer: null,
get badgeClass() {
const map = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20',
failed: 'bg-red-50 text-red-800 ring-red-600/20',
};
return map[this.status.status] || 'bg-stone-100 text-stone-700 ring-stone-500/20';
},
start() {
if (this.status.is_active) {
this.beginPolling();
}
},
beginPolling() {
this.stopPolling();
this.poll();
this.timer = setInterval(() => this.poll(), 1500);
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
},
stopPolling() {
if (this.timer) clearInterval(this.timer);
if (this.tickTimer) clearInterval(this.tickTimer);
this.timer = null;
this.tickTimer = null;
},
tickElapsed() {
if (!this.status.is_active || this.status.elapsed_seconds == null) return;
this.status.elapsed_seconds += 1;
this.status.elapsed_human = this.formatElapsed(this.status.elapsed_seconds);
},
async poll() {
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Status request failed (' + response.status + ')');
}
const wasActive = this.status.is_active;
this.status = await response.json();
this.pollError = null;
if (this.status.is_active && !this.timer) {
this.beginPolling();
}
if (wasActive && !this.status.is_active) {
this.stopPolling();
window.location.reload();
}
} catch (error) {
this.pollError = error.message || 'Could not refresh progress.';
}
},
formatElapsed(seconds) {
const minutes = Math.floor(seconds / 60);
const remain = seconds % 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;
return minutes + ':' + String(remain).padStart(2, '0');
},
};
}
</script>
@endsection
+3
View File
@@ -2,9 +2,12 @@
use App\Http\Controllers\RecordingController;
use App\Http\Controllers\TranscribeController;
use App\Http\Controllers\TranscriptionStatusController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings');
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class)
->name('recordings.transcription-status');
+94 -5
View File
@@ -7,7 +7,6 @@ use App\Models\Recording;
use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription;
use Tests\TestCase;
@@ -51,10 +50,41 @@ class RecordingUploadTest extends TestCase
Storage::disk('local')->assertExists($recording->file_path);
}
public function test_user_can_upload_wav_and_ogg(): void
{
Storage::fake('local');
foreach ([
['memo.wav', 'audio/wav'],
['clip.ogg', 'audio/ogg'],
['talk.m4a', 'audio/mp4'],
] as [$name, $mime]) {
$response = $this->post(route('recordings.store'), [
'audio' => UploadedFile::fake()->create($name, 400, $mime),
'title' => $name,
]);
$recording = Recording::query()->where('title', $name)->first();
$this->assertNotNull($recording, "Failed uploading {$name}");
$response->assertRedirect(route('recordings.show', $recording));
Storage::disk('local')->assertExists($recording->file_path);
}
}
public function test_unsupported_audio_type_is_rejected(): void
{
Storage::fake('local');
$this->from(route('recordings.create'))
->post(route('recordings.store'), [
'audio' => UploadedFile::fake()->create('notes.txt', 10, 'text/plain'),
])
->assertSessionHasErrors('audio');
}
public function test_user_can_queue_cloud_transcription(): void
{
Queue::fake();
$recording = Recording::query()->create([
'title' => 'Dictation',
'original_filename' => 'dictation.mp3',
@@ -63,14 +93,18 @@ class RecordingUploadTest extends TestCase
'transcription_status' => 'pending',
]);
// 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();
Queue::assertPushed(TranscribeRecording::class);
$recording->refresh();
$this->assertSame('cloud', $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
@@ -90,6 +124,29 @@ class RecordingUploadTest extends TestCase
->assertSessionHasErrors('ollama_url');
}
public function test_transcription_status_endpoint_returns_progress(): void
{
$recording = Recording::query()->create([
'title' => 'Live status',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Waiting for cloud transcript…',
'transcription_percent' => 55,
'transcription_driver' => 'cloud',
'transcription_started_at' => now()->subSeconds(12),
]);
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'processing')
->assertJsonPath('progress', 'Waiting for cloud transcript…')
->assertJsonPath('percent', 55)
->assertJsonPath('is_active', true)
->assertJsonPath('driver_label', 'Cloud (OpenAI Whisper)');
}
public function test_transcription_job_stores_transcript(): void
{
Storage::fake('local');
@@ -111,6 +168,38 @@ class RecordingUploadTest extends TestCase
$recording->refresh();
$this->assertSame('done', $recording->transcription_status);
$this->assertSame('Hello from the recorder.', $recording->transcript);
$this->assertSame(100, $recording->transcription_percent);
$this->assertSame('Transcription complete', $recording->transcription_progress);
$this->assertNotNull($recording->transcribed_at);
}
public function test_transcription_job_stores_error_on_failure(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/bad.mp3', 'fake-audio-bytes');
Transcription::fake(function () {
throw new \RuntimeException('Provider unavailable');
});
$recording = Recording::query()->create([
'title' => 'Bad',
'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'cloud',
]);
try {
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
$this->fail('Expected transcription to throw');
} catch (\RuntimeException $e) {
$this->assertSame('Provider unavailable', $e->getMessage());
}
$recording->refresh();
$this->assertSame('failed', $recording->transcription_status);
$this->assertSame('Provider unavailable', $recording->transcription_error);
}
}