Add first edition of AndyTranscribe.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreRecordingRequest;
|
||||
use App\Models\Recording;
|
||||
use App\Services\Mp3MetadataService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RecordingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of recordings.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Recording::query()->latest();
|
||||
|
||||
if ($search = $request->string('q')->trim()->toString()) {
|
||||
$query->where(function ($builder) use ($search) {
|
||||
$builder->where('title', 'like', "%{$search}%")
|
||||
->orWhere('artist', 'like', "%{$search}%")
|
||||
->orWhere('album', 'like', "%{$search}%")
|
||||
->orWhere('original_filename', 'like', "%{$search}%")
|
||||
->orWhere('transcript', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$recordings = $query->paginate(20)->withQueryString();
|
||||
|
||||
return view('recordings.index', compact('recordings', 'search'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the upload form.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('recordings.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly uploaded recording.
|
||||
*/
|
||||
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
|
||||
{
|
||||
$file = $request->file('audio');
|
||||
$path = $file->store('recordings', 'local');
|
||||
$absolutePath = Storage::disk('local')->path($path);
|
||||
$tags = $metadata->extract($absolutePath);
|
||||
|
||||
$title = $request->string('title')->trim()->toString()
|
||||
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
|
||||
|
||||
$recording = Recording::create([
|
||||
'title' => $title,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'file_path' => $path,
|
||||
'duration_seconds' => $tags['duration_seconds'],
|
||||
'recorded_at' => $tags['recorded_at'],
|
||||
'artist' => $tags['artist'],
|
||||
'album' => $tags['album'],
|
||||
'file_size_bytes' => $file->getSize() ?: 0,
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('recordings.show', $recording)
|
||||
->with('success', 'Recording uploaded successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified recording.
|
||||
*/
|
||||
public function show(Recording $recording): View
|
||||
{
|
||||
return view('recordings.show', compact('recording'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified recording.
|
||||
*/
|
||||
public function destroy(Recording $recording): RedirectResponse
|
||||
{
|
||||
$recording->deleteFile();
|
||||
$recording->delete();
|
||||
|
||||
return redirect()
|
||||
->route('recordings.index')
|
||||
->with('success', 'Recording deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\TranscribeRecordingRequest;
|
||||
use App\Jobs\TranscribeRecording;
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class TranscribeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Queue transcription for the recording with the chosen engine.
|
||||
*/
|
||||
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
|
||||
{
|
||||
if (in_array($recording->transcription_status, ['processing'], true)) {
|
||||
return back()->with('error', 'Transcription is already in progress.');
|
||||
}
|
||||
|
||||
$driver = $request->validated('driver');
|
||||
|
||||
$recording->update([
|
||||
'transcription_driver' => $driver,
|
||||
'ollama_url' => $driver === 'ollama' ? rtrim($request->validated('ollama_url'), '/') : null,
|
||||
'transcription_status' => 'pending',
|
||||
'transcript' => null,
|
||||
'transcribed_at' => null,
|
||||
]);
|
||||
|
||||
TranscribeRecording::dispatch($recording->fresh());
|
||||
|
||||
return back()->with('success', 'Transcription started. Refresh in a moment to see the result.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreRecordingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'audio' => ['required', 'file', 'mimes:mp3,mpeg', 'max:102400'],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'audio.required' => 'Please choose an MP3 file to upload.',
|
||||
'audio.mimes' => 'Only MP3 files are supported.',
|
||||
'audio.max' => 'The audio file may not be larger than 100 MB.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TranscribeRecordingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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://',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Recording;
|
||||
use App\Services\TranscriptionService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class TranscribeRecording implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*/
|
||||
public int $timeout = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(public Recording $recording)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(TranscriptionService $transcription): void
|
||||
{
|
||||
$this->recording->update([
|
||||
'transcription_status' => 'processing',
|
||||
]);
|
||||
|
||||
try {
|
||||
$text = $transcription->transcribe($this->recording);
|
||||
|
||||
$this->recording->update([
|
||||
'transcript' => $text,
|
||||
'transcription_status' => 'done',
|
||||
'transcribed_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Transcription failed', [
|
||||
'recording_id' => $this->recording->id,
|
||||
'driver' => $this->recording->transcription_driver,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->recording->update([
|
||||
'transcription_status' => 'failed',
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Recording extends Model
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'original_filename',
|
||||
'file_path',
|
||||
'duration_seconds',
|
||||
'recorded_at',
|
||||
'artist',
|
||||
'album',
|
||||
'file_size_bytes',
|
||||
'transcript',
|
||||
'transcription_status',
|
||||
'transcription_driver',
|
||||
'ollama_url',
|
||||
'transcribed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'recorded_at' => 'datetime',
|
||||
'transcribed_at' => 'datetime',
|
||||
'duration_seconds' => 'integer',
|
||||
'file_size_bytes' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable duration (m:ss).
|
||||
*/
|
||||
protected function durationFormatted(): Attribute
|
||||
{
|
||||
return Attribute::get(function (): string {
|
||||
if ($this->duration_seconds === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$minutes = intdiv($this->duration_seconds, 60);
|
||||
$seconds = $this->duration_seconds % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute filesystem path for the stored audio file.
|
||||
*/
|
||||
public function absolutePath(): string
|
||||
{
|
||||
return Storage::disk('local')->path($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the audio file from storage.
|
||||
*/
|
||||
public function deleteFile(): void
|
||||
{
|
||||
if ($this->file_path && Storage::disk('local')->exists($this->file_path)) {
|
||||
Storage::disk('local')->delete($this->file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use getID3;
|
||||
|
||||
class Mp3MetadataService
|
||||
{
|
||||
/**
|
||||
* Extract ID3 and audio metadata from an MP3 file on disk.
|
||||
*
|
||||
* @return array{
|
||||
* title: ?string,
|
||||
* artist: ?string,
|
||||
* album: ?string,
|
||||
* duration_seconds: ?int,
|
||||
* recorded_at: ?string
|
||||
* }
|
||||
*/
|
||||
public function extract(string $absolutePath): array
|
||||
{
|
||||
$analyzer = new getID3;
|
||||
$info = $analyzer->analyze($absolutePath);
|
||||
|
||||
$tags = [];
|
||||
if (isset($info['tags']['id3v2'])) {
|
||||
$tags = $info['tags']['id3v2'];
|
||||
} elseif (isset($info['tags']['id3v1'])) {
|
||||
$tags = $info['tags']['id3v1'];
|
||||
}
|
||||
|
||||
$title = $this->firstTag($tags, 'title');
|
||||
$artist = $this->firstTag($tags, 'artist');
|
||||
$album = $this->firstTag($tags, 'album');
|
||||
$year = $this->firstTag($tags, 'year') ?? $this->firstTag($tags, 'recording_time');
|
||||
|
||||
$duration = isset($info['playtime_seconds'])
|
||||
? (int) round((float) $info['playtime_seconds'])
|
||||
: null;
|
||||
|
||||
$recordedAt = null;
|
||||
if (filled($year) && preg_match('/^\d{4}/', $year)) {
|
||||
try {
|
||||
$recordedAt = Carbon::parse($year)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
$recordedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'artist' => $artist,
|
||||
'album' => $album,
|
||||
'duration_seconds' => $duration,
|
||||
'recorded_at' => $recordedAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $tags
|
||||
*/
|
||||
private function firstTag(array $tags, string $key): ?string
|
||||
{
|
||||
if (! isset($tags[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $tags[$key];
|
||||
|
||||
if (is_array($value)) {
|
||||
$value = $value[0] ?? null;
|
||||
}
|
||||
|
||||
return filled($value) ? (string) $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Recording;
|
||||
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.
|
||||
*/
|
||||
public function transcribe(Recording $recording): string
|
||||
{
|
||||
return match ($recording->transcription_driver) {
|
||||
'cloud' => $this->viaCloud($recording),
|
||||
'local' => $this->viaLocal($recording),
|
||||
'ollama' => $this->viaRemoteCompatible($recording),
|
||||
default => throw new RuntimeException('Unknown transcription driver: '.$recording->transcription_driver),
|
||||
};
|
||||
}
|
||||
|
||||
private function viaCloud(Recording $recording): string
|
||||
{
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('openai', 'whisper-1');
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
private function viaLocal(Recording $recording): string
|
||||
{
|
||||
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
|
||||
|
||||
$transcript = Transcription::fromStorage($recording->file_path)
|
||||
->timeout((int) config('ai.transcription_timeout', 600))
|
||||
->generate('local-whisper', $model);
|
||||
|
||||
return (string) $transcript;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
|
||||
*/
|
||||
private function viaRemoteCompatible(Recording $recording): 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.');
|
||||
}
|
||||
|
||||
$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.');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user