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:
+4
-1
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
|
||||
|
||||
+36
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user