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:
+1
-4
@@ -64,13 +64,10 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# AndyTranscribe / Laravel AI
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_URL=https://api.openai.com/v1
|
||||
# AndyTranscribe / local faster-whisper
|
||||
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
|
||||
LOCAL_WHISPER_API_KEY=not-needed
|
||||
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
|
||||
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
|
||||
TRANSCRIPTION_TIMEOUT=600
|
||||
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
|
||||
DB_QUEUE_RETRY_AFTER=660
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AndyTranscribe
|
||||
|
||||
Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe with OpenAI Whisper, a local faster-whisper server, or a remote OpenAI-compatible endpoint.
|
||||
Upload pocket-recorder audio (MP3, WAV, OGG, and more), extract embedded metadata, and transcribe locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker. Audio never leaves your machine.
|
||||
|
||||
Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai).
|
||||
|
||||
@@ -9,11 +9,9 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
|
||||
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 100 MB)
|
||||
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
|
||||
- Search recordings by title, artist, or transcript
|
||||
- Queued transcription with three engines:
|
||||
- **Cloud** — OpenAI Whisper (`whisper-1`)
|
||||
- **Local** — confidential; OpenAI-compatible [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) via Docker
|
||||
- **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions`
|
||||
- Queued local transcription (faster-whisper in Docker)
|
||||
- Live transcription progress (stage, %, elapsed time)
|
||||
- Stop or restart a run anytime
|
||||
- Copy finished transcripts from the recording detail page
|
||||
|
||||
## Requirements
|
||||
@@ -22,9 +20,7 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
|
||||
- Composer
|
||||
- Node.js & npm
|
||||
- SQLite (default) or another supported database
|
||||
- For **cloud** transcription: an OpenAI API key
|
||||
- For **local** transcription: [Docker](https://docs.docker.com/get-docker/) (runs Whisper in a container)
|
||||
- For **remote** transcription: a host with an OpenAI-compatible transcription API
|
||||
- [Docker](https://docs.docker.com/get-docker/) for the Whisper container
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -48,7 +44,7 @@ npm run build
|
||||
|
||||
## Local Whisper (Docker)
|
||||
|
||||
The **Local** engine does not run Whisper inside PHP. It calls an OpenAI-compatible HTTP API. This project ships Compose for that:
|
||||
Transcription calls an OpenAI-compatible HTTP API. This project ships Compose for that:
|
||||
|
||||
```bash
|
||||
# CPU (works everywhere; slower on long files)
|
||||
@@ -74,20 +70,15 @@ Stop:
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Without this container, **Cloud** and **Ollama host** still work; only **Local** needs Docker.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy values from `.env.example`. The transcription-related settings are:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `OPENAI_API_KEY` | Required for cloud Whisper |
|
||||
| `OPENAI_URL` | OpenAI API base URL (default `https://api.openai.com/v1`) |
|
||||
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://127.0.0.1:8090/v1`) |
|
||||
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
|
||||
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
|
||||
| `REMOTE_WHISPER_MODEL` | Model name for Ollama-host transcription |
|
||||
| `WHISPER_HOST_PORT` | Host port published by Compose (default `8090`) |
|
||||
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
|
||||
| `DB_QUEUE_RETRY_AFTER` | Database queue retry window; must exceed `TRANSCRIPTION_TIMEOUT` (default `660`) |
|
||||
@@ -99,21 +90,17 @@ Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`
|
||||
|
||||
## Running locally
|
||||
|
||||
Start the app, queue worker, and Vite together:
|
||||
|
||||
```bash
|
||||
composer run dev
|
||||
```
|
||||
|
||||
For confidential local transcription, also start Whisper:
|
||||
Start Whisper, then the app stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d whisper
|
||||
composer run dev
|
||||
```
|
||||
|
||||
Or separately:
|
||||
|
||||
```bash
|
||||
docker compose up -d whisper
|
||||
php artisan serve
|
||||
php artisan queue:work
|
||||
npm run dev
|
||||
@@ -126,18 +113,10 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
|
||||
## Usage
|
||||
|
||||
1. **Upload** audio from Recordings → Upload (optional title override).
|
||||
2. Open the recording and choose a transcription engine.
|
||||
3. Watch live progress on the recording page until the transcript appears.
|
||||
2. Open the recording and start transcription.
|
||||
3. Watch live progress until the transcript appears (or stop and restart).
|
||||
4. Search the list by title, artist, or transcript text.
|
||||
|
||||
## Transcription engines
|
||||
|
||||
| Driver | When to use | Needs |
|
||||
| --- | --- | --- |
|
||||
| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` |
|
||||
| `local` | Confidential; audio stays on this machine | `docker compose up -d whisper` |
|
||||
| `ollama` | Another machine on your network | Host URL + OpenAI-compatible `/v1/audio/transcriptions` |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,9 +10,9 @@ use Illuminate\Http\RedirectResponse;
|
||||
class TranscribeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Queue transcription for the recording with the chosen engine.
|
||||
* Queue local faster-whisper transcription for the recording.
|
||||
*
|
||||
* Always allowed: stops any current run first, then starts the new engine.
|
||||
* Always allowed: stops any current run first, then starts a new one.
|
||||
*/
|
||||
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
|
||||
{
|
||||
@@ -21,11 +21,9 @@ class TranscribeController extends Controller
|
||||
$recording->refresh();
|
||||
}
|
||||
|
||||
$driver = $request->validated('driver');
|
||||
|
||||
$recording->update([
|
||||
'transcription_driver' => $driver,
|
||||
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
|
||||
'transcription_driver' => 'local',
|
||||
'ollama_url' => null,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_progress' => 'Queued — waiting to start…',
|
||||
'transcription_percent' => 5,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TranscribeRecordingRequest extends FormRequest
|
||||
{
|
||||
@@ -17,27 +16,6 @@ class TranscribeRecordingRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'driver' => ['required', Rule::in(['cloud', 'local', 'ollama'])],
|
||||
'ollama_url' => [
|
||||
Rule::requiredIf(fn () => $this->input('driver') === 'ollama'),
|
||||
'nullable',
|
||||
'url',
|
||||
'regex:/^https?:\/\//i',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'driver.required' => 'Choose a transcription engine.',
|
||||
'ollama_url.required' => 'Enter the URL of the Ollama host (OpenAI-compatible Whisper endpoint).',
|
||||
'ollama_url.url' => 'Enter a valid URL, e.g. http://192.168.1.50:8000',
|
||||
'ollama_url.regex' => 'The host URL must start with http:// or https://',
|
||||
];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
+31
-13
@@ -78,10 +78,8 @@ class Recording extends Model
|
||||
{
|
||||
return Attribute::get(function (): ?string {
|
||||
return match ($this->transcription_driver) {
|
||||
'cloud' => 'Cloud (OpenAI Whisper)',
|
||||
'local' => 'Local (faster-whisper)',
|
||||
'ollama' => 'Ollama host',
|
||||
default => $this->transcription_driver,
|
||||
default => $this->transcription_driver ?: 'Local (faster-whisper)',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -160,7 +158,7 @@ class Recording extends Model
|
||||
try {
|
||||
$job = unserialize($command);
|
||||
} catch (Throwable) {
|
||||
return (bool) preg_match('/id";i:'.$this->id.';/', $payload);
|
||||
return $this->payloadMentionsRecording($payload);
|
||||
}
|
||||
|
||||
return $job instanceof TranscribeRecording
|
||||
@@ -168,6 +166,15 @@ class Recording extends Model
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback payload match when unserialize is unavailable.
|
||||
*/
|
||||
private function payloadMentionsRecording(string $payload): bool
|
||||
{
|
||||
return (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|
||||
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing/pending with no worker job left (crashed worker, bad retry_after, etc.).
|
||||
*/
|
||||
@@ -183,12 +190,27 @@ class Recording extends Model
|
||||
|
||||
$reference = $this->transcription_started_at ?? $this->updated_at;
|
||||
|
||||
// Allow a short window after dispatch before the row appears / worker claims it.
|
||||
if ($reference !== null && $reference->gt(now()->subSeconds(15))) {
|
||||
if ($reference === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only treat as orphaned after the job could not possibly still be running.
|
||||
// (A short grace caused false failures while Whisper was still working.)
|
||||
$orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60);
|
||||
|
||||
return $reference->lte(now()->subSeconds($orphanAfterSeconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a payload/job belongs to this recording's transcription run start time.
|
||||
*/
|
||||
public function matchesTranscriptionRun(?string $runStartedAt): bool
|
||||
{
|
||||
if ($runStartedAt === null || $this->transcription_started_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,11 +224,7 @@ class Recording extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($runStartedAt === null || $this->transcription_started_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
|
||||
return $this->matchesTranscriptionRun($runStartedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,7 +378,7 @@ class Recording extends Model
|
||||
public function transcriptionStatusPayload(): array
|
||||
{
|
||||
$startedAt = $this->transcription_started_at;
|
||||
$elapsed = $startedAt ? $startedAt->diffInSeconds(now()) : null;
|
||||
$elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
|
||||
@@ -4,52 +4,18 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Recording;
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Ai\Transcription;
|
||||
use RuntimeException;
|
||||
|
||||
class TranscriptionService
|
||||
{
|
||||
/**
|
||||
* Run transcription for a recording using the selected driver.
|
||||
* Transcribe a recording with the local faster-whisper server.
|
||||
*
|
||||
* @param (Closure(string, int): void)|null $onProgress
|
||||
*/
|
||||
public function transcribe(Recording $recording, ?Closure $onProgress = null): string
|
||||
{
|
||||
$report = $onProgress ?? static fn (string $message, int $percent) => null;
|
||||
|
||||
return match ($recording->transcription_driver) {
|
||||
'cloud' => $this->viaCloud($recording, $report),
|
||||
'local' => $this->viaLocal($recording, $report),
|
||||
'ollama' => $this->viaRemoteCompatible($recording, $report),
|
||||
default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string, int): void $report
|
||||
*/
|
||||
private function viaCloud(Recording $recording, Closure $report): string
|
||||
{
|
||||
$report('Sending audio to OpenAI Whisper…', 35);
|
||||
$report('Waiting for cloud transcript (this can take a while for long recordings)…', 55);
|
||||
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('openai', 'whisper-1');
|
||||
|
||||
$report('Received transcript from OpenAI…', 85);
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string, int): void $report
|
||||
*/
|
||||
private function viaLocal(Recording $recording, Closure $report): string
|
||||
{
|
||||
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
||||
|
||||
$report('Connecting to local faster-whisper server…', 30);
|
||||
@@ -63,68 +29,4 @@ class TranscriptionService
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
|
||||
*
|
||||
* @param Closure(string, int): void $report
|
||||
*/
|
||||
private function viaRemoteCompatible(Recording $recording, Closure $report): string
|
||||
{
|
||||
if (! filled($recording->ollama_url)) {
|
||||
throw new RuntimeException('Ollama host URL is required for remote transcription.');
|
||||
}
|
||||
|
||||
$base = $this->normalizeBaseUrl($recording->ollama_url);
|
||||
$model = config('ai.remote_whisper_model', config('ai.local_whisper_model', 'Systran/faster-whisper-base'));
|
||||
$path = $recording->absolutePath();
|
||||
|
||||
if (! is_readable($path)) {
|
||||
throw new RuntimeException('Recording audio file is not readable.');
|
||||
}
|
||||
|
||||
$report('Connecting to remote host '.$recording->ollama_url.'…', 30);
|
||||
$report("Uploading audio and waiting for transcript ({$model})…", 50);
|
||||
|
||||
$response = Http::timeout((int) config('ai.transcription_timeout', 600))
|
||||
->attach(
|
||||
'file',
|
||||
fopen($path, 'r'),
|
||||
$recording->original_filename ?: basename($path),
|
||||
)
|
||||
->post($base.'/audio/transcriptions', [
|
||||
'model' => $model,
|
||||
'response_format' => 'json',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(
|
||||
'Remote transcription failed (HTTP '.$response->status().'): '.$response->body()
|
||||
);
|
||||
}
|
||||
|
||||
$text = $response->json('text');
|
||||
|
||||
if (! is_string($text) || $text === '') {
|
||||
throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.');
|
||||
}
|
||||
|
||||
$report('Received transcript from remote host…', 85);
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user URL to an OpenAI-style base ending in /v1.
|
||||
*/
|
||||
private function normalizeBaseUrl(string $url): string
|
||||
{
|
||||
$url = rtrim(trim($url), '/');
|
||||
|
||||
if (Str::endsWith($url, '/v1')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.'/v1';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ return [
|
||||
|
||||
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
|
||||
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
|
||||
'remote_whisper_model' => env('REMOTE_WHISPER_MODEL', env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
<th class="px-4 py-3">Title</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Engine</th>
|
||||
<th class="px-4 py-3">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -58,7 +57,7 @@
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-4 py-3">
|
||||
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
||||
@if ($recording->isTranscribing() && $recording->transcription_progress)
|
||||
<div class="mt-1 max-w-[14rem] truncate text-xs text-amber-700" title="{{ $recording->transcription_progress }}">
|
||||
@@ -66,13 +65,6 @@
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">
|
||||
@if ($recording->transcription_driver)
|
||||
<span class="uppercase tracking-wide text-xs">{{ $recording->transcription_driver }}</span>
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
x-data="transcriptionMonitor(@js([
|
||||
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||
'initial' => $recording->transcriptionStatusPayload(),
|
||||
'driver' => old('driver', $recording->transcription_driver ?: 'cloud'),
|
||||
]))"
|
||||
x-init="start()"
|
||||
>
|
||||
@@ -67,68 +66,25 @@
|
||||
<dt class="text-stone-500">Uploaded</dt>
|
||||
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
@if ($recording->ollama_url)
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-stone-500">Ollama host</dt>
|
||||
<dd class="max-w-[60%] break-all text-right font-medium">{{ $recording->ollama_url }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Transcribe</h2>
|
||||
<p class="mt-2 text-sm text-stone-600">Choose an engine anytime — starting a new run stops the current one.</p>
|
||||
<p class="mt-2 text-sm text-stone-600">
|
||||
Audio stays on this machine. Requires
|
||||
<code class="text-[11px]">docker compose up -d whisper</code>
|
||||
(port 8090).
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4 space-y-4">
|
||||
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
|
||||
@csrf
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="cloud" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Cloud (OpenAI Whisper)</span>
|
||||
<span class="block text-xs text-stone-500">Fast; audio is sent to OpenAI.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="local" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Local — confidential (faster-whisper)</span>
|
||||
<span class="block text-xs text-stone-500">Runs via Docker Compose (<code class="text-[11px]">docker compose up -d whisper</code>) on port 8090.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
||||
<input type="radio" name="driver" value="ollama" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
||||
<span>
|
||||
<span class="block text-sm font-medium">Ollama host</span>
|
||||
<span class="block text-xs text-stone-500">
|
||||
Remote host URL. Must expose OpenAI-compatible <code class="text-[11px]">/v1/audio/transcriptions</code>.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div x-show="driver === 'ollama'" x-cloak class="space-y-2">
|
||||
<label for="ollama_url" class="block text-sm font-medium text-stone-700">Host URL</label>
|
||||
<input
|
||||
id="ollama_url"
|
||||
type="url"
|
||||
name="ollama_url"
|
||||
value="{{ old('ollama_url', $recording->ollama_url) }}"
|
||||
placeholder="http://192.168.1.50:8000"
|
||||
class="w-full rounded border border-stone-300 px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
|
||||
>
|
||||
<span x-text="startButtonLabel"></span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
|
||||
>
|
||||
<span x-text="startButtonLabel"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
@@ -227,11 +183,10 @@
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
<script>
|
||||
function transcriptionMonitor({ statusUrl, initial, driver }) {
|
||||
function transcriptionMonitor({ statusUrl, initial }) {
|
||||
return {
|
||||
statusUrl,
|
||||
status: initial,
|
||||
driver: driver || 'cloud',
|
||||
pollError: null,
|
||||
timer: null,
|
||||
tickTimer: null,
|
||||
@@ -250,7 +205,7 @@
|
||||
|
||||
get startButtonLabel() {
|
||||
if (this.status.is_active) {
|
||||
return 'Switch engine / restart';
|
||||
return 'Restart transcription';
|
||||
}
|
||||
|
||||
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
|
||||
@@ -313,15 +268,17 @@
|
||||
},
|
||||
|
||||
formatElapsed(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remain = seconds % 60;
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const remain = total % 60;
|
||||
if (minutes === 0) return remain + 's';
|
||||
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
|
||||
},
|
||||
|
||||
formatDuration(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remain = seconds % 60;
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const remain = total % 60;
|
||||
return minutes + ':' + String(remain).padStart(2, '0');
|
||||
},
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class RecordingUploadTest extends TestCase
|
||||
->assertSessionHasErrors('audio');
|
||||
}
|
||||
|
||||
public function test_user_can_queue_cloud_transcription(): void
|
||||
public function test_user_can_queue_local_transcription(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'title' => 'Dictation',
|
||||
@@ -97,34 +97,16 @@ class RecordingUploadTest extends TestCase
|
||||
// Fake AI so the afterResponse job (sync) does not call a real provider.
|
||||
Transcription::fake(['Queued transcription text.']);
|
||||
|
||||
$this->post(route('recordings.transcribe', $recording), [
|
||||
'driver' => 'cloud',
|
||||
])->assertRedirect();
|
||||
$this->post(route('recordings.transcribe', $recording))
|
||||
->assertRedirect();
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('cloud', $recording->transcription_driver);
|
||||
$this->assertSame('local', $recording->transcription_driver);
|
||||
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
|
||||
$this->assertNotNull($recording->transcription_started_at);
|
||||
$this->assertNotNull($recording->transcription_progress);
|
||||
}
|
||||
|
||||
public function test_ollama_driver_requires_url(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'title' => 'Secret call',
|
||||
'original_filename' => 'call.mp3',
|
||||
'file_path' => 'recordings/call.mp3',
|
||||
'file_size_bytes' => 2048,
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->from(route('recordings.show', $recording))
|
||||
->post(route('recordings.transcribe', $recording), [
|
||||
'driver' => 'ollama',
|
||||
])
|
||||
->assertSessionHasErrors('ollama_url');
|
||||
}
|
||||
|
||||
public function test_transcription_status_endpoint_returns_progress(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
@@ -133,19 +115,19 @@ class RecordingUploadTest extends TestCase
|
||||
'file_path' => 'recordings/live.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Waiting for cloud transcript…',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 55,
|
||||
'transcription_driver' => 'cloud',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now()->subSeconds(12),
|
||||
]);
|
||||
|
||||
$this->getJson(route('recordings.transcription-status', $recording))
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'processing')
|
||||
->assertJsonPath('progress', 'Waiting for cloud transcript…')
|
||||
->assertJsonPath('progress', 'Transcribing locally…')
|
||||
->assertJsonPath('percent', 55)
|
||||
->assertJsonPath('is_active', true)
|
||||
->assertJsonPath('driver_label', 'Cloud (OpenAI Whisper)');
|
||||
->assertJsonPath('driver_label', 'Local (faster-whisper)');
|
||||
}
|
||||
|
||||
public function test_transcription_job_stores_transcript(): void
|
||||
@@ -161,7 +143,7 @@ class RecordingUploadTest extends TestCase
|
||||
'file_path' => 'recordings/sample.mp3',
|
||||
'file_size_bytes' => 12,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_driver' => 'cloud',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
|
||||
@@ -189,7 +171,7 @@ class RecordingUploadTest extends TestCase
|
||||
'file_path' => 'recordings/bad.mp3',
|
||||
'file_size_bytes' => 12,
|
||||
'transcription_status' => 'pending',
|
||||
'transcription_driver' => 'cloud',
|
||||
'transcription_driver' => 'local',
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -215,8 +197,8 @@ class RecordingUploadTest extends TestCase
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 50,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now()->subMinutes(5),
|
||||
'updated_at' => now()->subMinutes(5),
|
||||
'transcription_started_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
$this->getJson(route('recordings.transcription-status', $recording))
|
||||
@@ -229,6 +211,30 @@ class RecordingUploadTest extends TestCase
|
||||
$this->assertStringContainsString('worker stopped', $recording->transcription_error);
|
||||
}
|
||||
|
||||
public function test_recent_processing_is_not_marked_orphaned(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'title' => 'Still working',
|
||||
'original_filename' => 'working.mp3',
|
||||
'file_path' => 'recordings/working.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_progress' => 'Transcribing locally…',
|
||||
'transcription_percent' => 50,
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now()->subMinutes(2),
|
||||
'updated_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
$this->getJson(route('recordings.transcription-status', $recording))
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'processing')
|
||||
->assertJsonPath('is_active', true);
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('processing', $recording->transcription_status);
|
||||
}
|
||||
|
||||
public function test_orphaned_processing_can_be_restarted(): void
|
||||
{
|
||||
Transcription::fake(['Recovered transcript.']);
|
||||
@@ -244,12 +250,11 @@ class RecordingUploadTest extends TestCase
|
||||
'updated_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$this->post(route('recordings.transcribe', $recording), [
|
||||
'driver' => 'cloud',
|
||||
])->assertRedirect();
|
||||
$this->post(route('recordings.transcribe', $recording))
|
||||
->assertRedirect();
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('cloud', $recording->transcription_driver);
|
||||
$this->assertSame('local', $recording->transcription_driver);
|
||||
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
|
||||
}
|
||||
|
||||
@@ -277,12 +282,12 @@ class RecordingUploadTest extends TestCase
|
||||
$this->assertFalse($recording->isTranscribing());
|
||||
}
|
||||
|
||||
public function test_user_can_start_a_different_engine_while_processing(): void
|
||||
public function test_user_can_restart_transcription_while_processing(): void
|
||||
{
|
||||
Transcription::fake(['Switched engine transcript.']);
|
||||
Transcription::fake(['Restarted transcript.']);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'title' => 'Switch me',
|
||||
'title' => 'Restart me while busy',
|
||||
'original_filename' => 'switch.mp3',
|
||||
'file_path' => 'recordings/switch.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
@@ -291,14 +296,12 @@ class RecordingUploadTest extends TestCase
|
||||
'transcription_started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->post(route('recordings.transcribe', $recording), [
|
||||
'driver' => 'cloud',
|
||||
])
|
||||
$this->post(route('recordings.transcribe', $recording))
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('cloud', $recording->transcription_driver);
|
||||
$this->assertSame('local', $recording->transcription_driver);
|
||||
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
|
||||
$this->assertNull($recording->transcription_error);
|
||||
}
|
||||
@@ -316,7 +319,7 @@ class RecordingUploadTest extends TestCase
|
||||
'file_path' => 'recordings/ignore.mp3',
|
||||
'file_size_bytes' => 12,
|
||||
'transcription_status' => 'processing',
|
||||
'transcription_driver' => 'cloud',
|
||||
'transcription_driver' => 'local',
|
||||
'transcription_started_at' => now()->subMinute(),
|
||||
'transcript' => 'Previous transcript stays.',
|
||||
]);
|
||||
@@ -368,14 +371,13 @@ class RecordingUploadTest extends TestCase
|
||||
'file_path' => 'recordings/keep.mp3',
|
||||
'file_size_bytes' => 100,
|
||||
'transcription_status' => 'done',
|
||||
'transcription_driver' => 'cloud',
|
||||
'transcription_driver' => 'local',
|
||||
'transcript' => 'Old transcript text.',
|
||||
'transcribed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->post(route('recordings.transcribe', $recording), [
|
||||
'driver' => 'local',
|
||||
])->assertRedirect();
|
||||
$this->post(route('recordings.transcribe', $recording))
|
||||
->assertRedirect();
|
||||
|
||||
$recording->refresh();
|
||||
$this->assertSame('Old transcript text.', $recording->transcript);
|
||||
|
||||
Reference in New Issue
Block a user