Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ yarn-error.log
/.fleet
/.idea
/.vscode
__pycache__/
*.pyc
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions app/Http/Controllers/DownloadTranscriptController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
9 changes: 8 additions & 1 deletion app/Http/Controllers/NewTranscriptionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
}
}
34 changes: 29 additions & 5 deletions app/Http/Controllers/TranscribeAudioController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
],
);
}
}
12 changes: 7 additions & 5 deletions app/Http/Controllers/TranslateTranscriptController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,28 @@ 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,
]);
}

$transcript->update([
'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,
]);
}
}
48 changes: 36 additions & 12 deletions app/Jobs/TranscribeFileJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions app/Jobs/TranslateTranscriptJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -45,6 +66,8 @@ public function handle()
'translations' => $translations,
]);
} catch (\Exception $e) {
report($e);

$this->transcript->update([
'status' => TranscriptStatus::COMPLETED->value,
]);
Expand Down
6 changes: 3 additions & 3 deletions app/Policies/TranscriptPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class TranscriptPolicy
{
use HandlesAuthorization;

public function viewAny(User $user): bool
public function viewAny(?User $user): bool
{
return true;
}
Expand All @@ -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');
}
}
Loading