diff --git a/.env.example b/.env.example index f1af0d5..856dc4b 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,18 @@ FILESYSTEM_DISK=local QUEUE_CONNECTION=sync SESSION_DRIVER=file SESSION_LIFETIME=120 +TRANSCRIPT_STORAGE_DISK=public +WRITEOUT_ALLOW_GUEST_UPLOADS=true +WRITEOUT_MOCK_AI=true +WRITEOUT_TRANSCRIPTION_DRIVER=local_whisper +WRITEOUT_TRANSLATION_DRIVER=mock +WRITEOUT_LOCAL_WHISPER_COMMAND=py +WRITEOUT_LOCAL_WHISPER_MODEL=tiny +WRITEOUT_LOCAL_WHISPER_DEVICE=cpu +WRITEOUT_LOCAL_WHISPER_COMPUTE_TYPE=int8 +HF_HUB_DISABLE_SYMLINKS_WARNING=1 +HF_HUB_DISABLE_PROGRESS_BARS=1 +# HF_TOKEN= MEMCACHED_HOST=127.0.0.1 diff --git a/.gitignore b/.gitignore index f0d10af..951a84d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ yarn-error.log /.fleet /.idea /.vscode +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 39427f0..cb762c6 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,45 @@ the prompt context limit. git clone https://github.com/beyondcode/writeout.ai ``` -### Create an OpenAI account and link your API key. +### Configure your environment + +1. Copy `.env.example` to `.env` +2. Add your `OPENAI_API_KEY` +3. Keep `QUEUE_CONNECTION=sync` for the simplest local setup +4. Keep `TRANSCRIPT_STORAGE_DISK=public` unless you already have a DigitalOcean Spaces bucket configured +5. Keep `WRITEOUT_ALLOW_GUEST_UPLOADS=true` if you want to test uploads locally without GitHub OAuth +6. Set `WRITEOUT_TRANSCRIPTION_DRIVER=local_whisper` if you want real local transcription without OpenAI billing +7. Keep `WRITEOUT_TRANSLATION_DRIVER=mock` if you only need real transcription locally and do not want local translation work yet + +### Prepare the app + +```bash +composer install +php artisan key:generate +php artisan migrate +php artisan storage:link +``` + +### Start the app + +```bash +php artisan serve +``` + +When guest uploads are enabled, local uploads are stored on the `public` disk and made public automatically so you can reopen the transcript without signing in. +When `WRITEOUT_TRANSCRIPTION_DRIVER=local_whisper`, uploads are transcribed on your own machine instead of through OpenAI. +When `WRITEOUT_TRANSLATION_DRIVER=mock`, translations are still mocked for local development. + +### Install the local Whisper dependency + +```bash +python -m pip install faster-whisper +``` + +The first local transcription run will download the selected Whisper model automatically. `tiny` is the easiest CPU-friendly starting point; if you want more accuracy later, switch the model to `base` or `small`. +The app keeps the Hugging Face cache under Laravel's `storage/app/huggingface` folder by default, and `HF_HUB_DISABLE_SYMLINKS_WARNING=1` keeps the Windows symlink warning out of normal local runs. If you later need higher download limits for diarization or larger models, add your own `HF_TOKEN`. + +### Create an OpenAI account and link your API key 1. Sign up at [OpenAI](https://openai.com/) to create a free account (you'll get $8 credits) 2. Click on the "User" / "API Keys" menu item and create a new API key diff --git a/app/Http/Controllers/DownloadTranscriptController.php b/app/Http/Controllers/DownloadTranscriptController.php index 66725bc..935558c 100644 --- a/app/Http/Controllers/DownloadTranscriptController.php +++ b/app/Http/Controllers/DownloadTranscriptController.php @@ -12,11 +12,46 @@ public function __invoke(Transcript $transcript, Request $request) $this->authorize('view', $transcript); $transcriptVtt = $transcript->translations[$request->get('language')] ?? $transcript->transcript; + $plainTextTranscript = $this->plainTextFromVtt($transcriptVtt); - return response($transcriptVtt, 200, [ - 'Content-Type' => 'text/vtt', - 'Content-Disposition' => 'attachment; filename="transcript.vtt"', - 'Content-Length' => strlen($transcriptVtt), + return response($plainTextTranscript, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="transcript.txt"', + 'Content-Length' => strlen($plainTextTranscript), ]); } + + protected function plainTextFromVtt(string $transcriptVtt): string + { + $normalizedVtt = str_replace(["\r\n", "\r"], "\n", trim($transcriptVtt)); + $blocks = preg_split("/\n{2,}/", $normalizedVtt) ?: []; + $plainTextBlocks = []; + + foreach ($blocks as $block) { + $lines = array_values(array_filter( + array_map('trim', explode("\n", trim($block))), + fn (string $line) => $line !== '', + )); + + if ($lines === [] || $lines[0] === 'WEBVTT') { + continue; + } + + if (isset($lines[1]) && str_contains($lines[1], '-->')) { + array_shift($lines); + } + + if (isset($lines[0]) && str_contains($lines[0], '-->')) { + array_shift($lines); + } + + if ($lines === []) { + continue; + } + + $plainTextBlocks[] = implode(' ', $lines); + } + + return implode(PHP_EOL.PHP_EOL, $plainTextBlocks); + } } diff --git a/app/Http/Controllers/NewTranscriptionController.php b/app/Http/Controllers/NewTranscriptionController.php index 78af576..5c32ce1 100644 --- a/app/Http/Controllers/NewTranscriptionController.php +++ b/app/Http/Controllers/NewTranscriptionController.php @@ -3,14 +3,21 @@ namespace App\Http\Controllers; use App\SendStack; +use Illuminate\Contracts\Auth\Authenticatable; class NewTranscriptionController extends Controller { public function __invoke(SendStack $sendStack) { + /** @var Authenticatable|null $user */ + $user = auth()->user(); return view('transcribe', [ - 'isSubscribed' => $sendStack->isActiveSubscriber(auth()->user()->email), + 'isSubscribed' => $user?->email ? $sendStack->isActiveSubscriber($user->email) : false, + 'guestUploadsEnabled' => config('writeout.allow_guest_uploads'), + 'mockAiEnabled' => config('writeout.mock_ai'), + 'transcriptionDriver' => config('writeout.transcription_driver'), + 'translationDriver' => config('writeout.translation_driver'), ]); } } diff --git a/app/Http/Controllers/TranscribeAudioController.php b/app/Http/Controllers/TranscribeAudioController.php index 6df45cd..dee9148 100644 --- a/app/Http/Controllers/TranscribeAudioController.php +++ b/app/Http/Controllers/TranscribeAudioController.php @@ -4,6 +4,7 @@ use App\Jobs\TranscribeFileJob; use App\Models\Transcript; +use App\Models\User; use App\SendStack; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -14,36 +15,59 @@ public function __invoke(Request $request, SendStack $sendStack) { $this->authorize('create', Transcript::class); + $uploader = $this->resolveUploader($request); + $isGuestUpload = $request->user() === null; + $transcriptDisk = config('writeout.transcript_disk'); + $request->validate([ 'file' => [ 'required', 'file', 'max:'.(25 * 1024), // max 25MB - ] + ], ]); - if ($request->get('newsletter')) { - $sendStack->updateOrSubscribe($request->user()->email, [config('app.name')]); + if ($request->boolean('newsletter') && $uploader->email) { + $sendStack->updateOrSubscribe($uploader->email, [config('app.name')]); } $filename = Str::random(40).'.'.$request->file('file')->getClientOriginalExtension(); // Store the file in the public disk $path = $request->file('file') - ->storePubliclyAs('transcribe', $filename, 'do'); + ->storePubliclyAs('transcribe', $filename, $transcriptDisk); // Store the file locally temporarily for OpenAI $request->file('file') ->storeAs('transcribe', $filename, 'local'); $transcript = Transcript::create([ - 'user_id' => $request->user()->id, + 'user_id' => $uploader->id, 'hash' => $path, 'prompt' => $request->input('prompt', ''), + 'public' => $isGuestUpload ? true : $request->boolean('public', true), ]); $this->dispatch(new TranscribeFileJob($transcript)); return redirect()->action(ShowTranscriptController::class, $transcript); } + + protected function resolveUploader(Request $request): User + { + if ($request->user() instanceof User) { + return $request->user(); + } + + abort_unless(config('writeout.allow_guest_uploads'), 403); + + return User::firstOrCreate( + ['github_id' => 'local-uploader'], + [ + 'github_username' => 'local-uploader', + 'name' => 'Local Uploader', + 'email' => null, + ], + ); + } } diff --git a/app/Http/Controllers/TranslateTranscriptController.php b/app/Http/Controllers/TranslateTranscriptController.php index 5890492..f1cb8b5 100644 --- a/app/Http/Controllers/TranslateTranscriptController.php +++ b/app/Http/Controllers/TranslateTranscriptController.php @@ -13,14 +13,16 @@ public function __invoke(Transcript $transcript, Request $request) { $this->authorize('view', $transcript); - if ($request->get('language') === null) { + $language = trim((string) $request->get('language', '')); + + if ($language === '') { return redirect()->action(ShowTranscriptController::class, $transcript); } - if ($transcript->translations[$request->get('language')] ?? null) { + if ($transcript->translations[$language] ?? null) { return redirect()->action(ShowTranscriptController::class, [ 'transcript' => $transcript, - 'language' => $request->get('language'), + 'language' => $language, ]); } @@ -28,11 +30,11 @@ public function __invoke(Transcript $transcript, Request $request) 'status' => TranscriptStatus::TRANSLATING->value, ]); - $this->dispatch(new TranslateTranscriptJob($transcript, $request->get('language'))); + $this->dispatch(new TranslateTranscriptJob($transcript, $language)); return redirect()->action(ShowTranscriptController::class, [ 'transcript' => $transcript, - 'language' => $request->get('language'), + 'language' => $language, ]); } } diff --git a/app/Jobs/TranscribeFileJob.php b/app/Jobs/TranscribeFileJob.php index d657885..60f4ded 100644 --- a/app/Jobs/TranscribeFileJob.php +++ b/app/Jobs/TranscribeFileJob.php @@ -4,6 +4,8 @@ use App\Enum\TranscriptStatus; use App\Models\Transcript; +use App\Support\LocalWhisperTranscriber; +use App\Support\MockTranscriptFactory; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -20,29 +22,51 @@ public function __construct(protected Transcript $transcript) { } - public function handle() + public function handle(LocalWhisperTranscriber $localWhisperTranscriber) { + $transcriptDisk = config('writeout.transcript_disk'); + $driver = config('writeout.transcription_driver'); + $this->transcript->update([ 'status' => TranscriptStatus::TRANSCRIBING, ]); try { - $transcriptionResults = OpenAI::audio()->transcribe([ - 'model' => 'whisper-1', - 'file' => fopen(storage_path('app/'.$this->transcript->hash), 'r'), - 'prompt' => $this->transcript->prompt, - 'temperature' => 0.2, - 'response_format' => 'vtt', - ]); + if ($driver === 'mock') { + $transcriptionResults = [ + 'language' => 'English', + 'duration' => 14, + 'transcript' => MockTranscriptFactory::makeVtt($this->transcript), + ]; + } elseif ($driver === 'local_whisper') { + $transcriptionResults = $localWhisperTranscriber->transcribe($this->transcript); + } else { + $openAiResults = OpenAI::audio()->transcribe([ + 'model' => 'whisper-1', + 'file' => fopen(storage_path('app/'.$this->transcript->hash), 'r'), + 'prompt' => $this->transcript->prompt, + 'temperature' => 0.2, + 'response_format' => 'vtt', + ]); + + $transcriptionResults = [ + 'language' => $openAiResults->language, + 'duration' => $openAiResults->duration, + 'transcript' => $openAiResults->text, + ]; + } $this->transcript->update([ - 'source_language' => $transcriptionResults->language, + 'source_language' => $transcriptionResults['language'], 'status' => TranscriptStatus::COMPLETED, - 'duration' => $transcriptionResults->duration, - 'transcript' => $transcriptionResults->text, + 'duration' => $transcriptionResults['duration'], + 'transcript' => $transcriptionResults['transcript'], + 'error' => null, ]); } catch (\Throwable $e) { - Storage::disk('public')->delete($this->transcript->hash); + report($e); + + Storage::disk($transcriptDisk)->delete($this->transcript->hash); $this->transcript->update([ 'status' => TranscriptStatus::FAILED, diff --git a/app/Jobs/TranslateTranscriptJob.php b/app/Jobs/TranslateTranscriptJob.php index b79d868..f2681f0 100644 --- a/app/Jobs/TranslateTranscriptJob.php +++ b/app/Jobs/TranslateTranscriptJob.php @@ -4,6 +4,7 @@ use App\Enum\TranscriptStatus; use App\Models\Transcript; +use App\Support\MockTranscriptFactory; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -27,8 +28,28 @@ public function __construct(private Transcript $transcript, private string $lang public function handle() { $chunks = $this->getTranscriptChunks(); + $driver = config('writeout.translation_driver'); try { + if ($driver === 'mock') { + $translations = $this->transcript->translations ?? []; + $translations[$this->language] = MockTranscriptFactory::translateVtt( + $this->transcript->transcript, + $this->language, + ); + + $this->transcript->update([ + 'status' => TranscriptStatus::TRANSLATED->value, + 'translations' => $translations, + ]); + + return; + } + + if ($driver !== 'openai') { + throw new \RuntimeException('Unsupported translation driver: '.$driver); + } + $translation = ''; foreach ($chunks as $chunk) { @@ -45,6 +66,8 @@ public function handle() 'translations' => $translations, ]); } catch (\Exception $e) { + report($e); + $this->transcript->update([ 'status' => TranscriptStatus::COMPLETED->value, ]); diff --git a/app/Policies/TranscriptPolicy.php b/app/Policies/TranscriptPolicy.php index 17c45db..ca6f514 100644 --- a/app/Policies/TranscriptPolicy.php +++ b/app/Policies/TranscriptPolicy.php @@ -10,7 +10,7 @@ class TranscriptPolicy { use HandlesAuthorization; - public function viewAny(User $user): bool + public function viewAny(?User $user): bool { return true; } @@ -20,8 +20,8 @@ public function view(?User $user, Transcript $transcript): bool return $user?->id === $transcript->user_id || $transcript->public; } - public function create(User $user): bool + public function create(?User $user): bool { - return true; + return $user !== null || config('writeout.allow_guest_uploads'); } } diff --git a/app/Support/LocalWhisperTranscriber.php b/app/Support/LocalWhisperTranscriber.php new file mode 100644 index 0000000..542b1ba --- /dev/null +++ b/app/Support/LocalWhisperTranscriber.php @@ -0,0 +1,69 @@ +hash), + '--model', + config('writeout.local_whisper.model'), + '--device', + config('writeout.local_whisper.device'), + '--compute-type', + config('writeout.local_whisper.compute_type'), + ]; + + if (filled($transcript->prompt)) { + $command[] = '--prompt'; + $command[] = $transcript->prompt; + } + + $process = new Process($command, base_path(), [ + 'PYTHONIOENCODING' => 'utf-8', + 'HF_HOME' => config('writeout.huggingface.home'), + 'HF_HUB_CACHE' => config('writeout.huggingface.hub_cache'), + 'HF_HUB_DISABLE_SYMLINKS_WARNING' => config('writeout.huggingface.disable_symlinks_warning') ? '1' : '0', + 'HF_HUB_DISABLE_PROGRESS_BARS' => config('writeout.huggingface.disable_progress_bars') ? '1' : '0', + 'HF_TOKEN' => (string) config('writeout.huggingface.token'), + ]); + + $process->setTimeout(3600); + $process->run(); + + if (! $process->isSuccessful()) { + $error = trim($process->getErrorOutput()); + + throw new RuntimeException( + $error !== '' + ? $error + : 'Local Whisper transcription failed to start.', + ); + } + + $payload = json_decode($process->getOutput(), true); + + if (! is_array($payload)) { + throw new RuntimeException('Local Whisper returned an invalid response.'); + } + + if (! isset($payload['language'], $payload['duration'], $payload['transcript'])) { + throw new RuntimeException('Local Whisper returned an incomplete response.'); + } + + return $payload; + } +} diff --git a/app/Support/MockTranscriptFactory.php b/app/Support/MockTranscriptFactory.php new file mode 100644 index 0000000..fdfd712 --- /dev/null +++ b/app/Support/MockTranscriptFactory.php @@ -0,0 +1,63 @@ +prompt)->squish()->trim(); + + $promptLine = $prompt->isNotEmpty() + ? $prompt->limit(110)->value() + : 'This is a locally generated demo transcript so you can keep building without OpenAI billing.'; + + return implode(PHP_EOL, [ + 'WEBVTT', + '', + '1', + '00:00:00.000 --> 00:00:04.000', + 'Mock transcript generated in local mode.', + '', + '2', + '00:00:04.000 --> 00:00:09.000', + $promptLine, + '', + '3', + '00:00:09.000 --> 00:00:14.000', + 'Set WRITEOUT_MOCK_AI=false when you are ready to use the real OpenAI API.', + ]); + } + + public static function translateVtt(string $vtt, string $language): string + { + $lines = preg_split('/\r\n|\r|\n/', $vtt) ?: []; + + $translated = array_map( + fn (string $line) => self::shouldTranslateLine($line) + ? '['.$language.' demo] '.$line + : $line, + $lines, + ); + + return implode(PHP_EOL, $translated); + } + + protected static function shouldTranslateLine(string $line): bool + { + $trimmed = trim($line); + + if ($trimmed === '' || $trimmed === 'WEBVTT') { + return false; + } + + if (is_numeric($trimmed)) { + return false; + } + + return ! str_contains($trimmed, '-->'); + } +} diff --git a/config/writeout.php b/config/writeout.php index efd962f..ae10ce6 100644 --- a/config/writeout.php +++ b/config/writeout.php @@ -1,6 +1,51 @@ env('WRITEOUT_ALLOW_GUEST_UPLOADS', $isLocal), + + 'transcript_disk' => env('TRANSCRIPT_STORAGE_DISK', env('DO_BUCKET') ? 'do' : 'public'), + + 'transcription_driver' => $transcriptionDriver, + + 'translation_driver' => $translationDriver, + + 'mock_ai' => $transcriptionDriver === 'mock' || $translationDriver === 'mock', + + 'local_whisper' => [ + 'command' => env('WRITEOUT_LOCAL_WHISPER_COMMAND', PHP_OS_FAMILY === 'Windows' ? 'py' : 'python3'), + 'model' => env('WRITEOUT_LOCAL_WHISPER_MODEL', 'tiny'), + 'device' => env('WRITEOUT_LOCAL_WHISPER_DEVICE', 'cpu'), + 'compute_type' => env('WRITEOUT_LOCAL_WHISPER_COMPUTE_TYPE', 'int8'), + ], + + 'huggingface' => [ + 'home' => env('HF_HOME', storage_path('app/huggingface')), + 'hub_cache' => env('HF_HUB_CACHE', storage_path('app/huggingface/hub')), + 'token' => env('HF_TOKEN'), + 'disable_symlinks_warning' => env('HF_HUB_DISABLE_SYMLINKS_WARNING', true), + 'disable_progress_bars' => env('HF_HUB_DISABLE_PROGRESS_BARS', true), + ], + 'translatable_languages' => [ 'English', 'German', diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php index d819338..9db3357 100644 --- a/resources/views/errors/403.blade.php +++ b/resources/views/errors/403.blade.php @@ -1,5 +1,13 @@ @extends('layouts.app') @section('content') + @php + $canTranscribeWithoutLogin = config('writeout.allow_guest_uploads'); + $ctaUrl = auth()->check() || $canTranscribeWithoutLogin ? url('/transcribe') : route('login'); + $ctaLabel = auth()->check() || $canTranscribeWithoutLogin + ? 'Transcribe your own audio' + : 'Log in to transcribe your own audio'; + @endphp +
- Copyright © {{ date('Y') }} Beyond Code. All rights reserved. + Copyright © {{ date('Y') }} Beyond Code. All rights reserved.
diff --git a/resources/views/transcribe.blade.php b/resources/views/transcribe.blade.php index 8389922..3fbbe2a 100644 --- a/resources/views/transcribe.blade.php +++ b/resources/views/transcribe.blade.php @@ -11,6 +11,31 @@ class="font-display text-3xl tracking-tight text-slate-900 sm:text-5xl"> Upload your audio file and we'll transcribe it for you. It's that easy. + @if ($transcriptionDriver === 'local_whisper') ++ New uploads will be transcribed on this machine without using OpenAI billing. +
+ @if ($translationDriver === 'mock') ++ Transcript translation is still running in demo mode for local development. +
+ @endif ++ Uploads will complete with a demo transcript and demo translations, so you can keep building locally without OpenAI billing. +
++ Upload a public transcript to preview the audio demo here. +
+ @endifIn more than 10 languages, including Klingon 🤓
-- Take a look at the - demo. - -
+ @if ($demoTranscript) ++ Take a look at the + demo. + +
+ @else ++ The demo transcript link will appear automatically once you have a public transcript. +
+ @endif