Compare commits
2
Commits
771658040b
...
e0400aadef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0400aadef | ||
|
|
67c1941833 |
+4
-1
@@ -67,8 +67,11 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
# AndyTranscribe / Laravel AI
|
# AndyTranscribe / Laravel AI
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_URL=https://api.openai.com/v1
|
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_API_KEY=not-needed
|
||||||
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
|
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
|
||||||
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
|
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
|
||||||
TRANSCRIPTION_TIMEOUT=600
|
TRANSCRIPTION_TIMEOUT=600
|
||||||
|
|
||||||
|
# Host port for docker compose whisper service
|
||||||
|
WHISPER_HOST_PORT=8090
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
# AndyTranscribe
|
# 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).
|
Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Upload MP3s (up to 100 MB) and store them on the local disk
|
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 100 MB)
|
||||||
- Automatic ID3 metadata extraction (title, artist, album, duration, recorded date)
|
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
|
||||||
- Search recordings by title, artist, or transcript
|
- Search recordings by title, artist, or transcript
|
||||||
- Queued transcription with three engines:
|
- Queued transcription with three engines:
|
||||||
- **Cloud** — OpenAI Whisper (`whisper-1`)
|
- **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`
|
- **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions`
|
||||||
|
- Live transcription progress (stage, %, elapsed time)
|
||||||
- Copy finished transcripts from the recording detail page
|
- Copy finished transcripts from the recording detail page
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -21,9 +22,9 @@ Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.co
|
|||||||
- Composer
|
- Composer
|
||||||
- Node.js & npm
|
- Node.js & npm
|
||||||
- SQLite (default) or another supported database
|
- SQLite (default) or another supported database
|
||||||
- For cloud transcription: an OpenAI API key
|
- For **cloud** transcription: an OpenAI API key
|
||||||
- For local transcription: a running faster-whisper-server
|
- 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
|
- For **remote** transcription: a host with an OpenAI-compatible transcription API
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -45,6 +46,36 @@ npm install
|
|||||||
npm run build
|
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
|
## Configuration
|
||||||
|
|
||||||
Copy values from `.env.example`. The transcription-related settings are:
|
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_API_KEY` | Required for cloud Whisper |
|
||||||
| `OPENAI_URL` | OpenAI API base URL (default `https://api.openai.com/v1`) |
|
| `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_API_KEY` | API key for local server (often unused) |
|
||||||
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
|
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
|
||||||
| `REMOTE_WHISPER_MODEL` | Model name for Ollama-host transcription |
|
| `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`) |
|
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
|
||||||
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
|
| `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
|
composer run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For confidential local transcription, also start Whisper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d whisper
|
||||||
|
```
|
||||||
|
|
||||||
Or separately:
|
Or separately:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -84,9 +122,9 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
|
|||||||
|
|
||||||
## Usage
|
## 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.
|
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.
|
4. Search the list by title, artist, or transcript text.
|
||||||
|
|
||||||
## Transcription engines
|
## Transcription engines
|
||||||
@@ -94,7 +132,7 @@ Transcription jobs are queued — keep a queue worker running or jobs will stay
|
|||||||
| Driver | When to use | Needs |
|
| Driver | When to use | Needs |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` |
|
| `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` |
|
| `ollama` | Another machine on your network | Host URL + OpenAI-compatible `/v1/audio/transcriptions` |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class TranscribeController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
|
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.');
|
return back()->with('error', 'Transcription is already in progress.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +24,16 @@ class TranscribeController extends Controller
|
|||||||
'transcription_driver' => $driver,
|
'transcription_driver' => $driver,
|
||||||
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
|
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
|
||||||
'transcription_status' => 'pending',
|
'transcription_status' => 'pending',
|
||||||
|
'transcription_progress' => 'Queued — waiting to start…',
|
||||||
|
'transcription_percent' => 5,
|
||||||
|
'transcription_started_at' => now(),
|
||||||
|
'transcription_error' => null,
|
||||||
'transcript' => null,
|
'transcript' => null,
|
||||||
'transcribed_at' => null,
|
'transcribed_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
TranscribeRecording::dispatch($recording->fresh());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,32 @@
|
|||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\File;
|
||||||
|
|
||||||
class StoreRecordingRequest extends FormRequest
|
class StoreRecordingRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Popular audio extensions Whisper-compatible providers typically accept.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const AUDIO_EXTENSIONS = [
|
||||||
|
'mp3',
|
||||||
|
'mpeg',
|
||||||
|
'mpga',
|
||||||
|
'wav',
|
||||||
|
'ogg',
|
||||||
|
'oga',
|
||||||
|
'flac',
|
||||||
|
'm4a',
|
||||||
|
'mp4',
|
||||||
|
'aac',
|
||||||
|
'webm',
|
||||||
|
'wma',
|
||||||
|
'aiff',
|
||||||
|
'aif',
|
||||||
|
];
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -17,7 +40,10 @@ class StoreRecordingRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'audio' => ['required', 'file', 'mimes:mp3,mpeg', 'max:102400'],
|
'audio' => [
|
||||||
|
'required',
|
||||||
|
File::types(self::AUDIO_EXTENSIONS)->max(102400),
|
||||||
|
],
|
||||||
'title' => ['nullable', 'string', 'max:255'],
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -28,8 +54,8 @@ class StoreRecordingRequest extends FormRequest
|
|||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'audio.required' => 'Please choose an MP3 file to upload.',
|
'audio.required' => 'Please choose an audio file to upload.',
|
||||||
'audio.mimes' => 'Only MP3 files are supported.',
|
'audio' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
|
||||||
'audio.max' => 'The audio file may not be larger than 100 MB.',
|
'audio.max' => 'The audio file may not be larger than 100 MB.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,18 +31,32 @@ class TranscribeRecording implements ShouldQueue
|
|||||||
*/
|
*/
|
||||||
public function handle(TranscriptionService $transcription): void
|
public function handle(TranscriptionService $transcription): void
|
||||||
{
|
{
|
||||||
$this->recording->update([
|
$this->recording->refresh();
|
||||||
|
|
||||||
|
$this->recording->forceFill([
|
||||||
'transcription_status' => 'processing',
|
'transcription_status' => 'processing',
|
||||||
]);
|
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
|
||||||
|
'transcription_error' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->recording->reportProgress('Preparing audio file…', 15);
|
||||||
|
|
||||||
try {
|
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,
|
'transcript' => $text,
|
||||||
'transcription_status' => 'done',
|
'transcription_status' => 'done',
|
||||||
|
'transcription_progress' => 'Transcription complete',
|
||||||
|
'transcription_percent' => 100,
|
||||||
|
'transcription_error' => null,
|
||||||
'transcribed_at' => now(),
|
'transcribed_at' => now(),
|
||||||
]);
|
])->save();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('Transcription failed', [
|
Log::error('Transcription failed', [
|
||||||
'recording_id' => $this->recording->id,
|
'recording_id' => $this->recording->id,
|
||||||
@@ -50,11 +64,26 @@ class TranscribeRecording implements ShouldQueue
|
|||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->recording->update([
|
$this->recording->forceFill([
|
||||||
'transcription_status' => 'failed',
|
'transcription_status' => 'failed',
|
||||||
]);
|
'transcription_progress' => 'Transcription failed',
|
||||||
|
'transcription_percent' => $this->recording->transcription_percent ?: 0,
|
||||||
|
'transcription_error' => $e->getMessage(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
throw $e;
|
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',
|
'file_size_bytes',
|
||||||
'transcript',
|
'transcript',
|
||||||
'transcription_status',
|
'transcription_status',
|
||||||
|
'transcription_progress',
|
||||||
|
'transcription_percent',
|
||||||
|
'transcription_started_at',
|
||||||
|
'transcription_error',
|
||||||
'transcription_driver',
|
'transcription_driver',
|
||||||
'ollama_url',
|
'ollama_url',
|
||||||
'transcribed_at',
|
'transcribed_at',
|
||||||
@@ -35,8 +39,10 @@ class Recording extends Model
|
|||||||
return [
|
return [
|
||||||
'recorded_at' => 'datetime',
|
'recorded_at' => 'datetime',
|
||||||
'transcribed_at' => 'datetime',
|
'transcribed_at' => 'datetime',
|
||||||
|
'transcription_started_at' => 'datetime',
|
||||||
'duration_seconds' => 'integer',
|
'duration_seconds' => 'integer',
|
||||||
'file_size_bytes' => '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.
|
* Absolute filesystem path for the stored audio file.
|
||||||
*/
|
*/
|
||||||
@@ -74,4 +116,44 @@ class Recording extends Model
|
|||||||
Storage::disk('local')->delete($this->file_path);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use getID3;
|
|||||||
class Mp3MetadataService
|
class Mp3MetadataService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Extract ID3 and audio metadata from an MP3 file on disk.
|
* Extract tags and duration from a common audio file (MP3, WAV, OGG, FLAC, M4A, etc.).
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* title: ?string,
|
* title: ?string,
|
||||||
@@ -23,17 +23,15 @@ class Mp3MetadataService
|
|||||||
$analyzer = new getID3;
|
$analyzer = new getID3;
|
||||||
$info = $analyzer->analyze($absolutePath);
|
$info = $analyzer->analyze($absolutePath);
|
||||||
|
|
||||||
$tags = [];
|
$tags = $this->preferredTags($info);
|
||||||
if (isset($info['tags']['id3v2'])) {
|
|
||||||
$tags = $info['tags']['id3v2'];
|
|
||||||
} elseif (isset($info['tags']['id3v1'])) {
|
|
||||||
$tags = $info['tags']['id3v1'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = $this->firstTag($tags, 'title');
|
$title = $this->firstTag($tags, 'title');
|
||||||
$artist = $this->firstTag($tags, 'artist');
|
$artist = $this->firstTag($tags, 'artist');
|
||||||
$album = $this->firstTag($tags, 'album');
|
$album = $this->firstTag($tags, 'album');
|
||||||
$year = $this->firstTag($tags, 'year') ?? $this->firstTag($tags, 'recording_time');
|
$year = $this->firstTag($tags, 'year')
|
||||||
|
?? $this->firstTag($tags, 'date')
|
||||||
|
?? $this->firstTag($tags, 'recording_time')
|
||||||
|
?? $this->firstTag($tags, 'creation_date');
|
||||||
|
|
||||||
$duration = isset($info['playtime_seconds'])
|
$duration = isset($info['playtime_seconds'])
|
||||||
? (int) round((float) $info['playtime_seconds'])
|
? (int) round((float) $info['playtime_seconds'])
|
||||||
@@ -57,6 +55,38 @@ class Mp3MetadataService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the richest tag set getID3 found for this format.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $info
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function preferredTags(array $info): array
|
||||||
|
{
|
||||||
|
$priority = [
|
||||||
|
'id3v2',
|
||||||
|
'id3v1',
|
||||||
|
'vorbiscomment',
|
||||||
|
'quicktime',
|
||||||
|
'riff',
|
||||||
|
'asf',
|
||||||
|
'ape',
|
||||||
|
'matroska',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($priority as $format) {
|
||||||
|
if (! empty($info['tags'][$format]) && is_array($info['tags'][$format])) {
|
||||||
|
return $info['tags'][$format];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($info['comments']) && is_array($info['comments'])) {
|
||||||
|
return $info['comments'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $tags
|
* @param array<string, mixed> $tags
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Recording;
|
use App\Models\Recording;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Ai\Transcription;
|
use Laravel\Ai\Transcription;
|
||||||
@@ -12,41 +13,63 @@ class TranscriptionService
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Run transcription for a recording using the selected driver.
|
* 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) {
|
return match ($recording->transcription_driver) {
|
||||||
'cloud' => $this->viaCloud($recording),
|
'cloud' => $this->viaCloud($recording, $report),
|
||||||
'local' => $this->viaLocal($recording),
|
'local' => $this->viaLocal($recording, $report),
|
||||||
'ollama' => $this->viaRemoteCompatible($recording),
|
'ollama' => $this->viaRemoteCompatible($recording, $report),
|
||||||
default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver),
|
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)
|
$transcript = Transcription::fromStorage($recording->file_path)
|
||||||
->timeout((int) config('ai.transcription_timeout', 600))
|
->timeout((int) config('ai.transcription_timeout', 600))
|
||||||
->generate('openai', 'whisper-1');
|
->generate('openai', 'whisper-1');
|
||||||
|
|
||||||
|
$report('Received transcript from OpenAI…', 85);
|
||||||
|
|
||||||
return (string) $transcript;
|
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');
|
$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)
|
$transcript = Transcription::fromStorage($recording->file_path)
|
||||||
->timeout((int) config('ai.transcription_timeout', 600))
|
->timeout((int) config('ai.transcription_timeout', 600))
|
||||||
->generate('local-whisper', $model);
|
->generate('local-whisper', $model);
|
||||||
|
|
||||||
|
$report('Received transcript from local Whisper…', 85);
|
||||||
|
|
||||||
return (string) $transcript;
|
return (string) $transcript;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
|
* 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)) {
|
if (! filled($recording->ollama_url)) {
|
||||||
throw new RuntimeException('Ollama host URL is required for remote transcription.');
|
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.');
|
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))
|
$response = Http::timeout((int) config('ai.transcription_timeout', 600))
|
||||||
->attach(
|
->attach(
|
||||||
'file',
|
'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.');
|
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;
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -149,7 +149,7 @@ return [
|
|||||||
'local-whisper' => [
|
'local-whisper' => [
|
||||||
'driver' => 'openai',
|
'driver' => 'openai',
|
||||||
'key' => env('LOCAL_WHISPER_API_KEY', 'not-needed'),
|
'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,
|
'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:
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Upload recording</h1>
|
||||||
<p class="mt-1 text-sm text-stone-600">Upload an MP3 from your pocket recorder. ID3 metadata is extracted automatically.</p>
|
<p class="mt-1 text-sm text-stone-600">Upload audio from your pocket recorder. Embedded metadata is extracted when available.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -17,15 +17,16 @@
|
|||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="audio" class="block text-sm font-medium text-stone-700">MP3 file</label>
|
<label for="audio" class="block text-sm font-medium text-stone-700">Audio file</label>
|
||||||
<input
|
<input
|
||||||
id="audio"
|
id="audio"
|
||||||
type="file"
|
type="file"
|
||||||
name="audio"
|
name="audio"
|
||||||
accept=".mp3,audio/mpeg"
|
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
|
||||||
required
|
required
|
||||||
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100"
|
class="mt-2 block w-full text-sm text-stone-600 file:mr-4 file:rounded file:border-0 file:bg-teal-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-teal-800 hover:file:bg-teal-100"
|
||||||
>
|
>
|
||||||
|
<p class="mt-2 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF (max 100 MB).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="title"
|
||||||
value="{{ old('title') }}"
|
value="{{ old('title') }}"
|
||||||
placeholder="Leave blank to use ID3 title or filename"
|
placeholder="Leave blank to use embedded title or filename"
|
||||||
class="mt-2 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"
|
class="mt-2 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>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
|
<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>
|
</div>
|
||||||
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
|
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -53,8 +53,13 @@
|
|||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-stone-600">{{ $recording->duration_formatted }}</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])
|
@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>
|
||||||
<td class="px-4 py-3 text-stone-600">
|
<td class="px-4 py-3 text-stone-600">
|
||||||
@if ($recording->transcription_driver)
|
@if ($recording->transcription_driver)
|
||||||
|
|||||||
@@ -3,155 +3,296 @@
|
|||||||
@section('title', $recording->title)
|
@section('title', $recording->title)
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div
|
||||||
<div>
|
x-data="transcriptionMonitor(@js([
|
||||||
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800">← Recordings</a>
|
'statusUrl' => route('recordings.transcription-status', $recording),
|
||||||
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
|
'initial' => $recording->transcriptionStatusPayload(),
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600">
|
'driver' => old('driver', $recording->transcription_driver ?: 'cloud'),
|
||||||
@include('recordings.partials.status-badge', ['status' => $recording->transcription_status])
|
]))"
|
||||||
@if ($recording->transcription_driver)
|
x-init="start()"
|
||||||
<span class="text-xs uppercase tracking-wide text-stone-500">{{ $recording->transcription_driver }}</span>
|
>
|
||||||
@endif
|
<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>
|
||||||
</div>
|
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
|
||||||
<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">
|
|
||||||
@csrf
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
<label class="flex cursor-pointer gap-3 rounded border border-stone-200 p-3 hover:bg-stone-50">
|
<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">
|
||||||
<input type="radio" name="driver" value="cloud" x-model="driver" class="mt-1 text-teal-700 focus:ring-teal-600">
|
Delete
|
||||||
<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' }}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if ($recording->transcription_status === 'processing')
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<p class="mt-4 text-sm text-amber-700">Transcription in progress… Refresh shortly.</p>
|
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
@elseif ($recording->transcription_status === 'failed')
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500">Metadata</h2>
|
||||||
<p class="mt-4 text-sm text-red-700">Transcription failed. Check the logs and try again with another engine.</p>
|
<dl class="mt-4 space-y-3 text-sm">
|
||||||
@elseif ($recording->transcript)
|
<div class="flex justify-between gap-4">
|
||||||
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800">{{ $recording->transcript }}</p>
|
<dt class="text-stone-500">Original file</dt>
|
||||||
@if ($recording->transcribed_at)
|
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
|
||||||
<p class="mt-4 text-xs text-stone-500">Transcribed {{ $recording->transcribed_at->format('Y-m-d H:i') }}</p>
|
</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
|
@endif
|
||||||
@else
|
</section>
|
||||||
<p class="mt-4 text-sm text-stone-500">No transcript yet. Choose an engine above to start.</p>
|
</div>
|
||||||
@endif
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<style>[x-cloak]{display:none!important}</style>
|
<style>[x-cloak]{display:none!important}</style>
|
||||||
|
<script>
|
||||||
|
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
|
@endsection
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\RecordingController;
|
use App\Http\Controllers\RecordingController;
|
||||||
use App\Http\Controllers\TranscribeController;
|
use App\Http\Controllers\TranscribeController;
|
||||||
|
use App\Http\Controllers\TranscriptionStatusController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::redirect('/', '/recordings');
|
Route::redirect('/', '/recordings');
|
||||||
|
|
||||||
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
|
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
|
||||||
Route::post('recordings/{recording}/transcribe', TranscribeController::class)->name('recordings.transcribe');
|
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 App\Services\TranscriptionService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Queue;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Ai\Transcription;
|
use Laravel\Ai\Transcription;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -51,10 +50,41 @@ class RecordingUploadTest extends TestCase
|
|||||||
Storage::disk('local')->assertExists($recording->file_path);
|
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
|
public function test_user_can_queue_cloud_transcription(): void
|
||||||
{
|
{
|
||||||
Queue::fake();
|
|
||||||
|
|
||||||
$recording = Recording::query()->create([
|
$recording = Recording::query()->create([
|
||||||
'title' => 'Dictation',
|
'title' => 'Dictation',
|
||||||
'original_filename' => 'dictation.mp3',
|
'original_filename' => 'dictation.mp3',
|
||||||
@@ -63,14 +93,18 @@ class RecordingUploadTest extends TestCase
|
|||||||
'transcription_status' => 'pending',
|
'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), [
|
$this->post(route('recordings.transcribe', $recording), [
|
||||||
'driver' => 'cloud',
|
'driver' => 'cloud',
|
||||||
])->assertRedirect();
|
])->assertRedirect();
|
||||||
|
|
||||||
Queue::assertPushed(TranscribeRecording::class);
|
|
||||||
|
|
||||||
$recording->refresh();
|
$recording->refresh();
|
||||||
$this->assertSame('cloud', $recording->transcription_driver);
|
$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
|
public function test_ollama_driver_requires_url(): void
|
||||||
@@ -90,6 +124,29 @@ class RecordingUploadTest extends TestCase
|
|||||||
->assertSessionHasErrors('ollama_url');
|
->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
|
public function test_transcription_job_stores_transcript(): void
|
||||||
{
|
{
|
||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
@@ -111,6 +168,38 @@ class RecordingUploadTest extends TestCase
|
|||||||
$recording->refresh();
|
$recording->refresh();
|
||||||
$this->assertSame('done', $recording->transcription_status);
|
$this->assertSame('done', $recording->transcription_status);
|
||||||
$this->assertSame('Hello from the recorder.', $recording->transcript);
|
$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);
|
$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