42 Commits
Author SHA1 Message Date
ben e77a18c49f Stream Whisper uploads with curl so large audio does not OOM. 2026-08-13 20:29:59 +02:00
ben 1898be4def Point the stage checkout at Gitea and keep its host ports off prod Reverb.
CI / test (push) Successful in 34s
CI / build-and-push (push) Successful in 6s
CI / deploy-stage (push) Skipped
CI / deploy (push) Successful in 12s
Cloning from the prod directory made SHA fetches fail, and APP_HOST_PORT 18180 collided with production Reverb.
2026-08-13 18:40:06 +02:00
ben 3601ce8b6f Stop IndexTest from treating Flux CSS 80% as a transcription percent.
CI / test (push) Successful in 22s
CI / build-and-push (push) Successful in 36s
CI / deploy (push) Skipped
CI / deploy-stage (push) Failing after 1s
assertDontSee('0%') matched unrelated classes like transparent_80% and the disk meter.
2026-08-13 18:26:33 +02:00
ben 5e76bfdec1 Add a stage branch deploy beside production on z00.
CI / test (push) Failing after 23s
CI / build-and-push (push) Skipped
CI / deploy (push) Skipped
CI / deploy-stage (push) Skipped
Pushing stage runs CI and deploys to stage.transcribe.z00.nu with its own data, Reverb credentials, and Caddy network.
2026-08-13 18:19:47 +02:00
ben c6856c04d5 Sync Boost agents and skills for Livewire, Flux, Echo, and Fortify. 2026-08-13 17:07:36 +02:00
ben 7b9bf3ac7f Render word confidence as colored text instead of chips.
Keep click-to-seek and hover percentages, but drop the boxed pill styling so the transcript reads as normal flowing text.
2026-08-13 16:49:28 +02:00
ben 500a88a8c1 Show the live transcript as one flowing row of confidence chips.
Drop per-segment cards and plain text so only colored word chips wrap in a single container while transcription runs.
2026-08-13 16:45:40 +02:00
ben 3a9cf50529 Keep live Whisper segments on screen without a page refresh.
Treat streamed chunks as appends, stop Livewire from remorphing the Alpine tree, and hydrate from HTTP while a run is active so segment cards appear as they arrive.
2026-08-13 16:35:33 +02:00
ben 187b6b5d12 Stream Whisper verbose metadata live without overflowing Reverb.
Persist accumulated verbose_json on the recording and broadcast only transcript/whisper deltas so the show page can render language, segments, word timestamps, and confidence while a run is in progress.
2026-08-13 15:55:51 +02:00
ben 4c73620458 Stream Whisper segments so the UI can show live transcript text.
Consume faster-whisper SSE instead of blocking on one JSON response, persist growing text and progress over Reverb, and drop the fake percent heartbeat.
2026-08-13 12:34:47 +02:00
ben 5cea5192c2 Add transcription progress handling and local Whisper integration.
Implement a new method for calculating elapsed transcription time in the Recording model. Enhance the TranscriptionService to handle local Whisper requests, including asynchronous processing and progress reporting. Update the recordings index view to display transcription progress percentage. Add unit tests for the new transcription service functionality.
2026-08-13 12:12:10 +02:00
ben a1bddca2dd Add Laravel Truss for live schema diagrams.
Install albertoarena/laravel-truss as a local-only tool so /truss and the optional Truss MCP server can inspect structure without exposing row data.
2026-08-13 11:38:10 +02:00
ben 1877fee258 Fix flaky transcription duration assertion in CI.
CI / test (push) Successful in 20s
CI / build-and-push (push) Successful in 38s
CI / deploy (push) Successful in 9s
Use integer timestamps for duration and pin the test clock to a whole second so SQLite round-trips stay stable.
2026-08-13 00:49:53 +02:00
ben b3fb74fb1b Make the disk space meter visible and show used percent.
CI / test (push) Failing after 31s
CI / build-and-push (push) Skipped
CI / deploy (push) Skipped
Use an inline-colored full-width progress bar so the fill renders without relying on purged Tailwind classes.
2026-08-13 00:37:20 +02:00
ben f42d124593 Harden stuck Whisper recovery and record transcription duration.
Fail jobs on timeout, treat stale reserved queue rows as orphans, skip migrate/seed on queue/reverb boot, and store how long each successful run took.
2026-08-13 00:12:06 +02:00
ben 2b126ee4e6 Fix deploy asset check to use forwarded HTTPS headers.
CI / test (push) Successful in 24s
CI / build-and-push (push) Successful in 18s
CI / deploy (push) Successful in 13s
Curling localhost without X-Forwarded-Proto always yields http:// URLs even when trustProxies is correct behind Caddy.
2026-08-12 23:32:14 +02:00
ben ab14f5e452 Disable Vite in tests so CI can run without a frontend build.
CI / test (push) Successful in 27s
CI / build-and-push (push) Successful in 1m34s
CI / deploy (push) Failing after 14s
Feature tests were failing on the host runner with ViteManifestNotFoundException after the flaky Node build step was removed.
2026-08-12 23:25:57 +02:00
ben 761f1a1f78 Fix Gitea CI deploy path and ban production hot-patches.
CI / test (push) Failing after 43s
CI / build-and-push (push) Skipped
CI / deploy (push) Skipped
Drop the flaky host Node docker step (image build already runs Vite), hard-reset the deploy checkout to the CI SHA, and fail deploy if assets still emit http://.
2026-08-12 23:16:38 +02:00
ben 5dd0a3aeac Add Gitea Actions CI/CD to build, push, and deploy on main.
CI / test (push) Failing after 12m47s
CI / build-and-push (push) Skipped
CI / deploy (push) Skipped
Mirrors the airports runner flow: test, publish to the Gitea registry, then pull APP_IMAGE into the z00 compose stack.
2026-08-12 22:56:46 +02:00
ben 4898d5cfde Use file session/cache to avoid SQLite lock failures under queue load.
Database sessions and cache competed with the queue worker on one SQLite file, causing "database is locked" when claiming jobs.
2026-08-12 22:32:56 +02:00
ben f65c816464 Default the UI appearance to dark mode for new visitors.
Keep the Flux light/dark/system toggle; persist system explicitly so the app default can stay dark.
2026-08-12 22:12:52 +02:00
ben d60851bb53 Trust reverse proxies so HTTPS asset URLs work behind CPM.
Without TrustProxies, Laravel generated http:// links after TLS termination and browsers blocked CSS/JS as mixed content.
2026-08-12 22:06:22 +02:00
ben 8a66cf6f63 Improve recordings list sorting and live show-page status updates.
Add sortable columns with a fixed status width, and keep the show page in sync via Echo, polling, and Alpine hydrate while transcription runs.
2026-08-12 21:27:03 +02:00
ben 5f0f61995c Fix Docker builds when livewire-tmp is root-owned.
Exclude whole storage trees from the build context, create cache dirs before dump-autoload, and drop Buildx-only cache mounts.
2026-08-12 20:47:58 +02:00
ben b7c36b8b3b Compact recordings list rows to title plus one transcript preview line. 2026-08-12 20:36:22 +02:00
ben 6bc5a20606 Speed up Docker builds with a single image and parallel deps.
Build app once for queue/reverb, run Composer and npm in parallel with cache mounts, and skip redundant vite npm ci on restart.
2026-08-12 20:20:06 +02:00
ben c17f8fb506 Improve live recordings UI, upload flow, and default Flux theme.
Refresh list status over Reverb with polling fallback, clarify queued vs processing, streamline upload empty states, and seed an admin login.
2026-08-12 20:14:35 +02:00
ben 148ba91816 Add delete button with confirm modal on each recordings list row. 2026-08-12 19:23:35 +02:00
ben 34ccf0c32b Move recordings UI to Livewire pages with Flux components.
Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
2026-08-12 19:17:52 +02:00
ben bfcfc12f58 Add Fortify auth, Flux dark mode, demo seed, and Docker hot reload.
Protect recordings per user, seed a demo login on container start, and bind-mount the app with Vite HMR for local Compose development.
2026-08-12 18:38:53 +02:00
ben accb721811 Enable SQLite WAL mode for better concurrent access. 2026-08-12 18:31:37 +02:00
ben 386b15ce94 Clarify Docker install steps in the README.
Make setup a numbered walkthrough covering env, APP_KEY generation, compose up, and verification.
2026-08-12 17:49:55 +02:00
ben e81a27cb8f Raise audio upload limit to 2 GB per file.
Update validation, dropzone checks, Docker PHP ini, and docs so multi-hour recordings can be uploaded.
2026-08-12 17:42:55 +02:00
ben 771ee8db5a Add Docker stack, Reverb live progress, and duplicate upload detection.
Ship FrankenPHP Compose services with Reverb WebSockets for real-time transcription status, skip re-uploading identical audio via content hash, and format disk usage without requiring intl.
2026-08-12 17:35:48 +02:00
ben 352b564f3a Add DiskSpaceService and DiskSpaceBar component for monitoring disk usage
Implement DiskSpaceService to provide snapshots of disk space usage, including total, free, and used bytes, as well as human-readable formats. Create DiskSpaceBar component to display disk usage information in the UI, including a progress bar that visually represents used space. Update app layout to include the DiskSpaceBar and add tests to ensure functionality of the disk space service and its integration in the layout.
2026-08-12 16:33:57 +02:00
ben 1dcdfc0ed0 Auto-queue transcription on upload and clarify pending status.
Batch uploads were only storing files as pending without dispatching Whisper jobs; queue them immediately, add a bulk pending action, and show human-readable status labels.
2026-08-12 16:26:36 +02:00
ben 3498861184 Recover transcripts falsely marked orphaned mid-run.
Keep finishing Whisper jobs from overwriting a superseded run, while restoring results when orphan detection failed the same run too early.
2026-08-12 15:34:36 +02:00
ben 21b17c7657 Use local faster-whisper only for transcription.
Drop cloud and Ollama engine choices so audio stays on-machine via Docker Whisper, and tighten orphan detection plus UI around a single local flow.
2026-08-12 15:34:16 +02:00
ben bc6cb5efa4 Fix stuck transcriptions and make transcripts stoppable and searchable.
Raise queue retry_after above the job timeout, recover orphaned runs, allow stop/restart with any engine, and keep finished transcripts searchable in the recordings list.
2026-08-12 15:10:33 +02:00
ben eb0f019025 Fix Whisper healthcheck and root redirect test. 2026-08-12 14:42:30 +02:00
ben e0400aadef Add live transcription progress and Docker Whisper.
Surface stage, percent, and elapsed time while jobs run, and ship Compose so local confidential transcription can use faster-whisper on port 8090.
2026-08-12 14:32:56 +02:00
ben 67c1941833 Support common audio formats beyond MP3.
Accept WAV, OGG, FLAC, M4A, and related uploads, and read metadata from non-ID3 tag formats.
2026-08-12 14:32:56 +02:00
145 changed files with 14603 additions and 707 deletions
+214
View File
@@ -0,0 +1,214 @@
---
name: echo-development
description: "Develops real-time broadcasting with Laravel Echo. Activates when setting up broadcasting (Reverb, Pusher, Ably); creating ShouldBroadcast events; defining broadcast channels (public, private, presence, encrypted); authorizing channels; configuring Echo; listening for events; implementing client events (whisper); setting up model broadcasting; broadcasting notifications; or when the user mentions broadcasting, Echo, WebSockets, real-time events, Reverb, or presence channels."
license: MIT
metadata:
author: laravel
---
# Laravel Broadcasting & Echo
## When to Apply
Activate this skill when:
- Installing or configuring Laravel broadcasting (Reverb, Pusher, Ably)
- Creating events that implement `ShouldBroadcast`
- Defining broadcast channels and authorization
- Setting up Laravel Echo on the client side
- Listening for broadcast events, notifications, or model events
- Implementing client-to-client events (whisper)
- Working with presence channels for user awareness
## Documentation
Use `search-docs` for detailed broadcasting patterns and documentation.
## Basic Usage
### Installing Broadcasting
```bash
php artisan install:broadcasting
```
Use flags for specific drivers: `--reverb`, `--pusher`, `--ably`. This creates `config/broadcasting.php` and `routes/channels.php`.
### Creating a Broadcast Event
```bash
php artisan make:event OrderShipped
```
<!-- Broadcast Event -->
```php
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
class OrderShipped implements ShouldBroadcast
{
use InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('orders.'.$this->order->id)];
}
}
```
Dispatch the event:
<!-- Dispatch Event -->
```php
use App\Events\OrderShipped;
OrderShipped::dispatch($order);
```
### Authorizing Channels
Define authorization in `routes/channels.php`:
<!-- Channel Authorization -->
```php
use App\Models\Order;
use App\Models\User;
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
return $user->id === Order::findOrNew($orderId)->user_id;
});
```
Create a channel class for complex authorization:
```bash
php artisan make:channel OrderChannel
```
List all registered channels:
```bash
php artisan channel:list
```
### Client-Side Setup
Install Echo and Pusher JS:
```bash
npm install --save-dev laravel-echo pusher-js
```
<!-- Echo Client Configuration -->
```javascript
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
```
### Listening for Events
<!-- Listen on Private Channel -->
```javascript
Echo.private(`orders.${orderId}`)
.listen('OrderShipmentStatusUpdated', (e) => {
console.log(e.order);
});
```
### Running Required Processes
```bash
php artisan queue:work # Required for ShouldBroadcast events
php artisan reverb:start # Required for Reverb driver
```
## What's Possible
Use `search-docs` to find detailed code examples and configuration for each of these:
### Channel Types
- Public (`new Channel`) — no auth, anyone can subscribe. Use for app-wide announcements, public feeds, or status pages.
- Private (`new PrivateChannel`) — requires authorization. Use for user-specific data like orders, messages, or account updates.
- Presence (`new PresenceChannel`) — authorized + tracks who's online. Use for chat rooms, collaborative editing, "who's viewing this" features, or typing indicators.
- EncryptedPrivate — end-to-end encryption, Pusher/Reverb only. Use when payload must be hidden from the broadcast server (e.g., sensitive financial data or private messages).
- Drivers: `reverb` (self-hosted WebSocket server), `pusher` (managed service), `ably` (managed service), `log` (writes to Laravel log, use for debugging), `null` (no-op, use for testing)
### Event Customization
- `broadcastAs()` — custom event name (client must use dot prefix: `.listen('.custom.name')`). Use when you want stable API names decoupled from PHP class names, or shorter event names for the frontend.
- `broadcastWith()` — control exact payload. Use to avoid leaking sensitive model attributes, slim down large payloads, or add computed data not on the model.
- `broadcastWhen()` — conditional broadcasting. Use to skip broadcasting when changes are trivial (e.g., only broadcast order updates above a threshold, or skip unchanged fields).
- `broadcastQueue()` / `$queue` — route to specific queue. Use to isolate real-time broadcasts from slow background jobs so they're processed faster.
- `$connection` — set queue connection per event. Use when broadcasts should go through a faster queue backend like Redis while other jobs use the database driver.
### Broadcasting Interfaces
- `ShouldBroadcast` — queue the broadcast (default). Use for most events to avoid blocking the HTTP response.
- `ShouldBroadcastNow` — broadcast synchronously, skip queue. Use during development or for time-critical events where queue latency is unacceptable.
- `ShouldDispatchAfterCommit` — wait for DB transaction commit. Use when the event references newly created records that listeners need to query (prevents race conditions).
- `ShouldRescue` — auto-catch broadcast exceptions. Use to prevent broadcast failures (e.g., WebSocket server down) from disrupting the user's HTTP request.
- `InteractsWithSockets` — required for `toOthers()`. Use on any event where you want to exclude the sender (optimistic UI updates).
- `InteractsWithBroadcasting` — override driver per event via `broadcastVia()`. Use in multi-driver setups (e.g., some events via Reverb, others via Pusher).
### Broadcasting Helpers
- `broadcast(new Event)->toOthers()` — exclude current user's socket. Use when the client already updates optimistically from the API response to avoid duplicate updates.
- `broadcast(new Event)->via('pusher')` — override connection. Use to route specific events through a different broadcast driver than the default.
- `Broadcast::on()`, `Broadcast::private()`, `Broadcast::presence()` — anonymous broadcasting without event classes. Chain `.as('name')->with($data)->send()` or `.sendNow()`. Use for simple one-off broadcasts where creating a full event class is overkill (e.g., quick status updates, simple notifications).
### Channel Authorization
- Closure-based in `routes/channels.php` — use for simple authorization logic (e.g., checking ownership).
- Model binding: `Broadcast::channel('orders.{order}', fn (User $user, Order $order) => ...)` — use when authorization depends on the model instance (auto-resolves from route parameter).
- Channel classes via `php artisan make:channel` — use for complex authorization logic that benefits from dependency injection or reusable logic across channels.
- Multiple guards: `['guards' => ['web', 'admin']]` — use when the channel should be accessible by users authenticated via different guards (e.g., both regular users and admins).
### Model Broadcasting
- `BroadcastsEvents` trait auto-broadcasts created/updated/deleted/trashed/restored. Use to automatically keep clients in sync with Eloquent model changes without writing individual events.
- Channel convention: `App.Models.Post.{id}` — clients subscribe to model-specific channels.
- `broadcastAs($event)` and `broadcastWith($event)` for per-action customization. Use to send different payloads for create vs update, or suppress certain event types.
- `newBroadcastableEvent($event)` for event instance customization (e.g., `->dontBroadcastToCurrentUser()`). Use when you need to modify the underlying event object before it's dispatched.
### Client-Side Features
- Client events: `whisper()` / `listenForWhisper()` — peer-to-peer without server roundtrip (private/presence channels only). Use for typing indicators, cursor positions, or any ephemeral state that doesn't need server persistence.
- Presence channels: `Echo.join()` with `here()`, `joining()`, `leaving()`, `error()` callbacks. Use for showing online users, "X is viewing this document" features, or live participant counts.
- Notification broadcasting: `.notification()` on user's private channel. Use to show real-time notifications (toast, badge counts) pushed from Laravel's notification system.
- Connection management: `Echo.connectionStatus()`, `Echo.leaveAllChannels()`, `Echo.disconnect()`. Use to show connection indicators, clean up on logout, or handle offline/reconnect scenarios.
- Custom namespace: `new Echo({ namespace: 'App.Other.Namespace' })`. Use when your events live outside the default `App\Events` namespace.
## Common Pitfalls
- Queue worker must be running for `ShouldBroadcast` events. Use `ShouldBroadcastNow` during development.
- `BROADCAST_CONNECTION` not `BROADCAST_DRIVER`: Laravel 11+ renamed this env key.
- `toOthers()` requires `InteractsWithSockets` trait AND `X-Socket-ID` header. Echo auto-adds this to global Axios. For `fetch`, manually send `Echo.socketId()`.
- CORS: When frontend/backend are on different origins, add `broadcasting/auth` to `config/cors.php` paths and set `supports_credentials` to `true`.
- Missing `VITE_` prefix: Client-side env vars must start with `VITE_`.
- `channels.php` not loaded: Verify it's included in `withRouting()` in `bootstrap/app.php`.
- Reverb is long-running: Code changes require `php artisan reverb:restart`.
- Presence channel auth must return an array of user data (`['id' => $user->id, 'name' => $user->name]`), not `true`. Returning `true` silently fails.
- Dot prefix rule: When using `broadcastAs()`, client must prefix with `.` (e.g., `.listen('.custom.name')`). Without the dot, Echo looks for `App\Events\custom.name` which silently fails.
- Reverb host separation: `REVERB_SERVER_HOST`/`REVERB_SERVER_PORT` (internal bind) vs `REVERB_HOST`/`REVERB_PORT` (public address) vs `VITE_REVERB_HOST`/`VITE_REVERB_PORT` (client JS).
- Sanctum SPA auth: Ensure `/broadcasting/auth` uses `auth:sanctum` middleware and CSRF tokens are sent with `withCredentials: true`.
@@ -0,0 +1,81 @@
---
name: fluxui-development
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
license: MIT
metadata:
author: laravel
---
# Flux UI Development
## Documentation
Use `search-docs` for detailed Flux UI patterns and documentation.
## Basic Usage
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
<!-- Basic Button -->
```blade
<flux:button variant="primary">Click me</flux:button>
```
## Available Components (Free Edition)
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
## Icons
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
<!-- Icon Button -->
```blade
<flux:button icon="arrow-down-tray">Export</flux:button>
```
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
```bash
php artisan flux:icon crown grip-vertical github
```
## Common Patterns
### Form Fields
<!-- Form Field -->
```blade
<flux:field>
<flux:label>Email</flux:label>
<flux:input type="email" wire:model="email" />
<flux:error name="email" />
</flux:field>
```
### Modals
<!-- Modal -->
```blade
<flux:modal wire:model="showModal">
<flux:heading>Title</flux:heading>
<p>Content</p>
</flux:modal>
```
## Verification
1. Check component renders correctly
2. Test interactive states
3. Verify mobile responsiveness
## Common Pitfalls
- Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage
+151
View File
@@ -0,0 +1,151 @@
---
name: fortify-development
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
license: MIT
metadata:
author: laravel
---
# Laravel Fortify Development
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
## Documentation
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
## Usage
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
## Available Features
Enable in `config/fortify.php` features array:
- `Features::registration()` - User registration
- `Features::resetPasswords()` - Password reset via email
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
- `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
> Use `search-docs` for feature configuration options and customization patterns.
## Setup Workflows
### Two-Factor Authentication Setup
```
- [ ] Add TwoFactorAuthenticatable trait to User model
- [ ] Enable feature in config/fortify.php
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Set up view callbacks in FortifyServiceProvider
- [ ] Create 2FA management UI
- [ ] Test QR code and recovery codes
```
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Passkeys Setup
```
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
- [ ] Enable passkeys feature in config/fortify.php
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
```
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
### Email Verification Setup
```
- [ ] Enable emailVerification feature in config
- [ ] Implement MustVerifyEmail interface on User model
- [ ] Set up verifyEmailView callback
- [ ] Add verified middleware to protected routes
- [ ] Test verification email flow
```
> Use `search-docs` for MustVerifyEmail implementation patterns.
### Password Reset Setup
```
- [ ] Enable resetPasswords feature in config
- [ ] Set up requestPasswordResetLinkView callback
- [ ] Set up resetPasswordView callback
- [ ] Define password.reset named route (if views disabled)
- [ ] Test reset email and link flow
```
> Use `search-docs` for custom password reset flow patterns.
### SPA Authentication Setup
```
- [ ] Set 'views' => false in config/fortify.php
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
- [ ] Set up CSRF token handling
- [ ] Test XHR authentication flows
```
> Use `search-docs` for integration and SPA authentication patterns.
#### Two-Factor Authentication in SPA Mode
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
```json
{
"two_factor": true
}
```
## Best Practices
### Custom Authentication Logic
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
### Registration Customization
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
### Rate Limiting
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
## Key Endpoints
| Feature | Method | Endpoint |
|------------------------|----------|---------------------------------------------|
| Login | POST | `/login` |
| Logout | POST | `/logout` |
| Register | POST | `/register` |
| Password Reset Request | POST | `/forgot-password` |
| Password Reset | POST | `/reset-password` |
| Email Verify Notice | GET | `/email/verify` |
| Resend Verification | POST | `/email/verification-notification` |
| Password Confirm | POST | `/user/confirm-password` |
| Enable 2FA | POST | `/user/two-factor-authentication` |
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
| 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
| Passkey Login Options | GET | `/passkeys/login/options` |
| Passkey Login | POST | `/passkeys/login` |
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
| Passkey Confirm | POST | `/passkeys/confirm` |
| Passkey Options | GET | `/user/passkeys/options` |
| Register Passkey | POST | `/user/passkeys` |
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
+1 -1
View File
@@ -30,7 +30,7 @@ Fan out when you can. The sweep is embarrassingly parallel. If your environment
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions.
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
@@ -77,12 +77,16 @@ Each item gives the fork, then a hint (a grep or dir to spot which side the app
## F. Frontend & views
No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable.
This app ships a frontend stack, so the items below apply.
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
31. UI kit (Flux): Flux components vs custom components vs another library.
- Hint: grep `<flux:` in `resources/views`.
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
@@ -0,0 +1,175 @@
---
name: livewire-development
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
license: MIT
metadata:
author: laravel
---
# Livewire Development
## Documentation
Use `search-docs` for detailed Livewire 4 patterns and documentation.
## Basic Usage
### Creating Components
```bash
# Single-file component (SFC - default in v4)
# Creates: resources/views/components/⚡create-post.blade.php
php artisan make:livewire create-post
# Page component (SFC - Full Page in v4)
# Creates: resources/views/pages/⚡create-post.blade.php
php artisan make:livewire pages::create-post
# Multi-file component (MFC)
# Creates: resources/views/components/⚡create-post/create-post.php
# resources/views/components/⚡create-post/create-post.blade.php
php artisan make:livewire create-post --mfc
# Class-based component (v3 style)
# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php
php artisan make:livewire create-post --class
# With namespace
php artisan make:livewire Posts/CreatePost
```
### Converting Between Formats
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
### Choosing a Component Format
> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.
Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.
### Component Format Reference
| Format | Flag | Class Path | View Path |
|--------|------|------------|-----------|
| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |
> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.
### Single-File Component Example
<!-- Single-File Component Example -->
```php
<?php
use Livewire\Component;
new class extends Component {
public int $count = 0;
public function increment(): void
{
$this->count++;
}
};
?>
<div>
<button wire:click="increment">Count: @{{ $count }}</button>
</div>
```
## Livewire 4 Specifics
### Key Changes From Livewire 3
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout``component_layout`, `lazy_placeholder``component_placeholder`.
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
- JavaScript: `$wire.$js('name', fn)``$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
### New Features
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
| Feature | Usage | Purpose |
|---------|-------|---------|
| Islands | `@island(name: 'stats')` | Isolated update regions |
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
| Deferred | `defer` attribute | Load after page render |
| Bundled | `lazy.bundle` | Load multiple together |
### New Directives
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
- `data-loading` attribute automatically added to elements triggering network requests.
| Directive | Purpose |
|-----------|---------|
| `wire:sort` | Drag-and-drop sorting |
| `wire:intersect` | Viewport intersection detection |
| `wire:ref` | Element references for JS |
| `.renderless` | Component without rendering |
| `.preserve-scroll` | Preserve scroll position |
## Best Practices
- Always use `wire:key` in loops
- Use `wire:loading` for loading states
- Use `wire:model.live` for instant updates (default is debounced)
- Validate and authorize in actions (treat like HTTP requests)
## Configuration
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
## Alpine & JavaScript
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
## Testing
<!-- Testing Example -->
```php
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1);
```
## Verification
1. Browser console: Check for JS errors
2. Network tab: Verify Livewire requests return 200
3. Ensure `wire:key` on all `@foreach` loops
## Common Pitfalls
- Missing `wire:key` in loops → unexpected re-rendering
- Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4)
@@ -0,0 +1,39 @@
# Livewire 4 JavaScript Integration
## Interceptor System (v4)
### Intercept Messages
```js
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
onFinish(() => { /* After response, before processing */ });
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
onError(() => { /* Server errors */ });
});
```
### Intercept Requests
```js
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
onResponse(({ response }) => { /* When received */ });
onSuccess(({ response, responseJson }) => { /* Success */ });
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
onFailure(({ error }) => { /* Network failures */ });
});
```
### Component-Scoped Interceptors
```blade
<script>
this.$intercept('save', ({ component, onSuccess }) => {
onSuccess(() => console.log('Saved!'));
});
</script>
```
## Magic Properties
- `$errors` - Access validation errors from JavaScript
- `$intercept` - Component-scoped interceptors
+34
View File
@@ -0,0 +1,34 @@
.git
.gitattributes
.github
.idea
.vscode
.cursor
.claude
.ai
node_modules
vendor
public/build
public/hot
# Exclude whole trees (not only /**) so Docker never tries to stat root-owned tmp dirs
storage/app/private
storage/app/public
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
database/*.sqlite*
.env
.env.*
!.env.example
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
tests
docs
todo.txt
*.md
!README.md
+51 -9
View File
@@ -2,7 +2,9 @@ APP_NAME=AndyTranscribe
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_URL=http://localhost:8080
# Production/CI: set to the Gitea registry image (local Compose builds andytranscribe-app:latest).
# APP_IMAGE=gitea.z00.nu/ben/andytranscribe:latest
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -27,17 +29,17 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_STORE=file
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
@@ -64,11 +66,51 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# AndyTranscribe / Laravel AI
OPENAI_API_KEY=
OPENAI_URL=https://api.openai.com/v1
LOCAL_WHISPER_URL=http://localhost:8000/v1
# Laravel Reverb (WebSockets). Browser uses Blade meta (reverb.client) with VITE_* fallback.
REVERB_APP_ID=andytranscribe
REVERB_APP_KEY=andytranscribe-key
REVERB_APP_SECRET=andytranscribe-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
# Public Echo target (z00 stage/prod set these in the instance .env).
# REVERB_PUBLIC_HOST=reverb.transcribe.z00.nu
# REVERB_PUBLIC_PORT=443
# REVERB_PUBLIC_SCHEME=https
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
APP_HOST_PORT=8080
REVERB_HOST_PORT=8081
WHISPER_HOST_PORT=8090
# Unique per Compose instance on a shared Docker host (prod default keeps existing names).
# CONTAINER_PREFIX=andytranscribe
# CADDY_NETWORK=caddy-proxy-manager-test_caddy-test-network
# PUBLIC_APP_URL=https://transcribe.z00.nu
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=localhost
# Browser WS port: use REVERB_HOST_PORT with Docker (8081), or REVERB_PORT for bare-metal reverb:start
VITE_REVERB_PORT="${REVERB_HOST_PORT}"
VITE_REVERB_SCHEME=http
# AndyTranscribe / local faster-whisper
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
LOCAL_WHISPER_API_KEY=not-needed
LOCAL_WHISPER_MODEL=Systran/faster-whisper-base
REMOTE_WHISPER_MODEL=Systran/faster-whisper-base
# curl streams large uploads off disk; http is for tests (Http::fake)
LOCAL_WHISPER_TRANSPORT=curl
TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660
# Demo user created on every container start (db:seed via entrypoint)
SEED_USER_NAME="Demo User"
SEED_USER_EMAIL=demo@example.com
SEED_USER_PASSWORD=password
# Uncomment for Docker bind mounts + Vite HMR (no rebuild for PHP/Blade/CSS/JS):
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
# VITE_HOST_PORT=5173
+169
View File
@@ -0,0 +1,169 @@
name: CI
on:
push:
branches:
- main
- stage
pull_request:
workflow_dispatch:
env:
REGISTRY: gitea.z00.nu
# Fallback baked into the Vite bundle; public pages prefer Blade meta tags.
VITE_REVERB_HOST: reverb.transcribe.z00.nu
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: https
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
set -euo pipefail
HOST="${{ gitea.server_url }}"
HOST="${HOST#https://}"
HOST="${HOST#http://}"
git clone --depth 1 \
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
.
git fetch --depth 1 origin "${{ gitea.sha }}"
git checkout --force "${{ gitea.sha }}"
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Run tests
run: |
set -euo pipefail
cp .env.example .env
php artisan key:generate --force --no-interaction
php -d memory_limit=512M artisan test --compact
- name: Validate compose
run: |
set -euo pipefail
APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \
APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \
docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet
- name: Validate stage compose
run: |
set -euo pipefail
APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \
APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \
PUBLIC_APP_URL="https://stage.transcribe.z00.nu" \
APP_URL="https://stage.transcribe.z00.nu" \
REVERB_PUBLIC_HOST="reverb.stage.transcribe.z00.nu" \
CONTAINER_PREFIX="andytranscribe-stage" \
CADDY_NETWORK="andytranscribe-stage-caddy" \
docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet
build-and-push:
if: gitea.event_name != 'pull_request'
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
set -euo pipefail
HOST="${{ gitea.server_url }}"
HOST="${HOST#https://}"
HOST="${HOST#http://}"
git clone --depth 1 \
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
.
git fetch --depth 1 origin "${{ gitea.sha }}"
git checkout --force "${{ gitea.sha }}"
- name: Build and push image
run: |
set -euo pipefail
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
TAG_SHA="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
TAG_LATEST="${REGISTRY}/${REPO_LC}:latest"
TAG_STAGE="${REGISTRY}/${REPO_LC}:stage"
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY}" -u "${{ gitea.actor }}" --password-stdin
DOCKER_BUILDKIT=1 docker build \
--build-arg VITE_APP_NAME=AndyTranscribe \
--build-arg VITE_REVERB_APP_KEY=andytranscribe-key \
--build-arg "VITE_REVERB_HOST=${VITE_REVERB_HOST}" \
--build-arg "VITE_REVERB_PORT=${VITE_REVERB_PORT}" \
--build-arg "VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}" \
-t "${TAG_SHA}" \
.
docker push "${TAG_SHA}"
if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then
docker tag "${TAG_SHA}" "${TAG_LATEST}"
docker push "${TAG_LATEST}"
elif [ "${{ gitea.ref }}" = "refs/heads/stage" ]; then
docker tag "${TAG_SHA}" "${TAG_STAGE}"
docker push "${TAG_STAGE}"
fi
deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request'
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
set -euo pipefail
HOST="${{ gitea.server_url }}"
HOST="${HOST#https://}"
HOST="${HOST#http://}"
git clone --depth 1 \
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
.
git fetch --depth 1 origin "${{ gitea.sha }}"
git checkout --force "${{ gitea.sha }}"
- name: Deploy production
env:
DEPLOY_PATHS: ${{ secrets.DEPLOY_PATHS }}
DEPLOY_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
chmod +x scripts/deploy-production.sh
if [ -z "${DEPLOY_PATHS:-}" ]; then
echo "DEPLOY_PATHS secret is not set; skipping deploy."
echo "Built image: ${APP_IMAGE}"
exit 0
fi
./scripts/deploy-production.sh
deploy-stage:
if: gitea.ref == 'refs/heads/stage' && gitea.event_name != 'pull_request'
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
set -euo pipefail
HOST="${{ gitea.server_url }}"
HOST="${HOST#https://}"
HOST="${HOST#http://}"
git clone --depth 1 \
"https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \
.
git fetch --depth 1 origin "${{ gitea.sha }}"
git checkout --force "${{ gitea.sha }}"
- name: Deploy stage
env:
DEPLOY_PATHS: ${{ secrets.STAGE_DEPLOY_PATHS }}
DEPLOY_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}"
chmod +x scripts/deploy-production.sh
if [ -z "${DEPLOY_PATHS:-}" ]; then
echo "STAGE_DEPLOY_PATHS secret is not set; skipping deploy."
echo "Built image: ${APP_IMAGE}"
exit 0
fi
./scripts/deploy-production.sh
+1
View File
@@ -21,6 +21,7 @@
/storage/*.key
/storage/pail
/vendor
/storage/app/private/truss
_ide_helper.php
Homestead.json
Homestead.yaml
+8
View File
@@ -6,6 +6,14 @@
"artisan",
"boost:mcp"
]
},
"truss": {
"command": "php",
"args": [
"artisan",
"mcp:start",
"truss"
]
}
}
}
+177
View File
@@ -0,0 +1,177 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
Before relying on a package's API, confirm its installed version:
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
- JS packages: check `package.json` for the installed versions.
## Skills Activation
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
# Laravel Boost
## Tools
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
- Use `database-schema` to inspect table structure before writing migrations or models.
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
## Searching Documentation (IMPORTANT)
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
- Pass a `packages` array to scope results when you know which packages are relevant.
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
### Search Syntax
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Project Rules
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
# Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
## APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
## URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== livewire/core rules ===
# Livewire
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
=== pint/core rules ===
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== phpunit/core rules ===
# PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should cover all happy paths, failure paths, and edge cases.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
## Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
</laravel-boost-guidelines>
+15
View File
@@ -104,6 +104,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
# Do Things the Laravel Way
@@ -134,6 +141,14 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== livewire/core rules ===
# Livewire
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
=== pint/core rules ===
# Laravel Pint Code Formatter
+108
View File
@@ -0,0 +1,108 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Composer deps (runs in parallel with npm ci)
# ---------------------------------------------------------------------------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
# ---------------------------------------------------------------------------
# npm ci only (parallel with vendor when BuildKit is available)
# ---------------------------------------------------------------------------
FROM node:22-bookworm AS npm
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---------------------------------------------------------------------------
# Vite production build (needs Flux/Livewire + Laravel pagination views)
# ---------------------------------------------------------------------------
FROM node:22-bookworm AS assets
WORKDIR /app
COPY --from=npm /app/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY --from=vendor /app/vendor/livewire ./vendor/livewire
COPY --from=vendor /app/vendor/laravel/framework/src/Illuminate/Pagination \
./vendor/laravel/framework/src/Illuminate/Pagination
COPY vite.config.js ./
COPY resources ./resources
COPY public ./public
ARG VITE_APP_NAME=AndyTranscribe
ARG VITE_REVERB_APP_KEY=andytranscribe-key
ARG VITE_REVERB_HOST=localhost
ARG VITE_REVERB_PORT=8081
ARG VITE_REVERB_SCHEME=http
ENV VITE_APP_NAME=$VITE_APP_NAME \
VITE_REVERB_APP_KEY=$VITE_REVERB_APP_KEY \
VITE_REVERB_HOST=$VITE_REVERB_HOST \
VITE_REVERB_PORT=$VITE_REVERB_PORT \
VITE_REVERB_SCHEME=$VITE_REVERB_SCHEME
RUN npm run build
# ---------------------------------------------------------------------------
# Runtime image
# ---------------------------------------------------------------------------
FROM dunglas/frankenphp:php8.5-bookworm
# Rarely changes — keep early for cache hits
RUN install-php-extensions \
pcntl \
pdo_sqlite \
sqlite3 \
zip \
bcmath \
intl \
opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
WORKDIR /app
# Dependency layer (invalidates when lockfiles / vendor change)
COPY --from=vendor /app/vendor ./vendor
COPY composer.json composer.lock ./
# Application source (.dockerignore excludes vendor, node_modules, public/build, storage uploads)
COPY . .
# Built frontend assets
COPY --from=assets /app/public/build ./public/build
# Framework/view cache paths must exist before package:discover runs during dump-autoload
RUN mkdir -p \
storage/app/private \
storage/app/public \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs \
database \
bootstrap/cache \
&& composer dump-autoload --optimize --no-dev \
&& chown -R www-data:www-data storage bootstrap/cache database
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["frankenphp", "php-server", "--listen", ":80", "--root", "/app/public"]
+268 -56
View File
@@ -1,101 +1,299 @@
# 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 locally with [faster-whisper-server](https://github.com/fedirz/faster-whisper-server). Audio never leaves your machine.
Built with Laravel 13, Blade, Tailwind CSS 4, and [Laravel AI](https://github.com/laravel/ai).
Built with Laravel 13, Blade, Livewire, Flux UI, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https://laravel.com/docs/reverb), FrankenPHP, and [Laravel AI](https://github.com/laravel/ai).
## Features
- Upload MP3s (up to 100 MB) and store them on the local disk
- Automatic ID3 metadata extraction (title, artist, album, duration, recorded date)
- User accounts with login and open registration (each user only sees their own recordings)
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 2 GB)
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
- Search recordings by title, artist, or transcript
- Queued transcription with three engines:
- **Cloud** — OpenAI Whisper (`whisper-1`)
- **Local** — confidential; OpenAI-compatible [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) (e.g. Docker on this machine)
- **Ollama host** — user-supplied host URL exposing `/v1/audio/transcriptions`
- Queued local transcription (faster-whisper in Docker)
- Live transcription progress over WebSockets (Reverb) on the list and detail pages
- Stop or restart a run anytime
- Copy finished transcripts from the recording detail page
## Requirements
- PHP 8.3+ (8.5 recommended)
- Composer
- Node.js & npm
- SQLite (default) or another supported database
- For cloud transcription: an OpenAI API key
- For local transcription: a running faster-whisper-server
- For remote transcription: a host with an OpenAI-compatible transcription API
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- About 2 GB free disk for the Whisper model cache (first run downloads the model)
## Setup
Optional for native PHP development: PHP 8.3+ (8.5 recommended), Composer, Node.js & npm.
## Install
These steps run the full stack with Docker: web app, queue worker, Reverb WebSockets, and Whisper.
### 1. Clone the repository
```bash
composer setup
git clone <your-repo-url> andyTranscibe
cd andyTranscibe
```
That installs PHP and JS dependencies, copies `.env` if needed, generates the app key, runs migrations, and builds frontend assets.
Or step by step:
### 2. Create your environment file
```bash
composer install
cp .env.example .env
```
### 3. Generate an application key
`APP_KEY` is required. Containers refuse to start if it is empty.
**Option A — PHP installed on the host**
```bash
php artisan key:generate
touch database/database.sqlite # if using SQLite
php artisan migrate
npm install
npm run build
```
## Configuration
**Option B — Docker only**
Copy values from `.env.example`. The transcription-related settings are:
Print a key:
| Variable | Purpose |
```bash
docker run --rm php:8.5-cli php -r "echo 'base64:'.base64_encode(random_bytes(32)), PHP_EOL;"
```
Open `.env` and set:
```env
APP_KEY=base64:paste-the-value-here
```
On Linux you can write it in one step:
```bash
KEY=$(docker run --rm php:8.5-cli php -r "echo 'base64:'.base64_encode(random_bytes(32));")
sed -i "s|^APP_KEY=.*|APP_KEY=${KEY}|" .env
```
### 4. Start the stack
```bash
docker compose up --build -d
```
Compose builds the **app** image once; `queue` and `reverb` reuse `andytranscribe-app:latest` (no triple rebuild). With the [dev overlay](#local-development-hot-reload), skip `--build` for routine PHP/Blade/JS work — the repo is bind-mounted.
On first start the app container will:
- create `database/database.sqlite` if needed
- run migrations
- seed a demo user (see below)
- start FrankenPHP on port **8080**
Whisper may take a minute or two while the model downloads.
### Demo login
Every container start runs `db:seed`, which ensures these users exist:
| Email | Password |
| --- | --- |
| `OPENAI_API_KEY` | Required for cloud Whisper |
| `OPENAI_URL` | OpenAI API base URL (default `https://api.openai.com/v1`) |
| `LOCAL_WHISPER_URL` | Local faster-whisper base URL (default `http://localhost:8000/v1`) |
| `LOCAL_WHISPER_API_KEY` | API key for local server (often unused) |
| `LOCAL_WHISPER_MODEL` | Model name for local transcription |
| `REMOTE_WHISPER_MODEL` | Model name for Ollama-host transcription |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout in seconds (default `600`) |
| `QUEUE_CONNECTION` | Use `database` (default) so transcription runs in the background |
| `demo@example.com` | `password` |
| `admin@example.com` | `password` |
Ensure `APP_URL` matches how you access the app (default `http://localhost:8000`).
Override the demo user with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
## Running locally
### 5. Open the app
Start the app, queue worker, and Vite together:
| URL | Purpose |
| --- | --- |
| [http://localhost:8080/recordings](http://localhost:8080/recordings) | App UI |
| [http://localhost:8080/up](http://localhost:8080/up) | Health check |
Live transcription status uses WebSockets on port **8081** (Reverb). Keep that port reachable from your browser.
### 6. Verify services (optional)
```bash
composer run dev
docker compose ps
docker compose logs -f app queue whisper reverb
```
Or separately:
You should see `app`, `queue`, `reverb`, and `whisper` running. Whisper becomes healthy after `/health` succeeds.
### Stop / restart
```bash
php artisan serve
php artisan queue:work
npm run dev
docker compose down
docker compose up -d
```
Open [http://localhost:8000/recordings](http://localhost:8000/recordings).
Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host.
Transcription jobs are queued — keep a queue worker running or jobs will stay pending.
If `docker compose build` fails with `can't stat .../storage/app/private/livewire-tmp`, a container created that directory as root. Fix ownership (or remove it), then rebuild:
```bash
sudo chown -R "$USER:$USER" storage
# or: sudo rm -rf storage/app/private/livewire-tmp
docker compose up --build -d
```
### Live reload while developing
Default Compose uses the built image, so PHP/Blade/CSS/JS changes need a rebuild. For day-to-day work, use the dev overlay (bind-mounts the repo and runs Vite HMR):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
```
Or set once in `.env`:
```env
COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
```
Then a normal `docker compose up -d` enables:
- host source mounted at `/app` (PHP, Blade, routes, etc. without rebuild)
- `vite` on port **5173** for CSS/JS hot reload and Blade refresh
- `queue:listen` so worker code picks up changes between jobs
Open [http://localhost:8080](http://localhost:8080) as usual. After changing Composer packages, run `docker compose exec app composer install`.
## CI/CD (Gitea Actions)
Gitea Actions (host runner on z00) on push:
| Branch | Image tags | Deploy target |
| --- | --- | --- |
| `main` | `<sha>` and `:latest` | `~/andyTranscibe` → https://transcribe.z00.nu |
| `stage` | `<sha>` and `:stage` | `~/andyTranscibe-stage` → https://stage.transcribe.z00.nu |
Both runs:
1. PHPUnit (+ compose config check for prod and stage env)
2. Build and push `gitea.z00.nu/ben/andytranscribe:<sha>` — Vite assets are baked in; browser Reverb host/key come from Blade meta at runtime
3. Hard-reset the matching checkout and pull the image (`docker-compose.yml` + `compose.z00.yaml`)
Stage is a separate Compose project: own SQLite, uploads, `APP_KEY`, Reverb credentials, container names (`andytranscribe-stage-*`), host ports, and Caddy Docker network (`andytranscribe-stage-caddy`). Do not copy prod `.env` or data into the stage directory.
Do not hot-patch production or staging containers or their deploy checkouts. Fix in git; push `stage` to preview, then `main` to ship.
One-time server bootstrap (secrets + registry login):
```bash
./scripts/setup-gitea-ci.sh
./scripts/setup-stage.sh
```
`setup-stage.sh` clones `$HOME/andyTranscibe-stage` from Gitea (not the prod checkout), writes a unique `.env`, creates `andytranscribe-stage-caddy`, and attaches Caddy Proxy Manager to that network. You still need DNS for `stage.transcribe.z00.nu` and `reverb.stage.transcribe.z00.nu`, plus Proxy Manager hosts:
- `stage.transcribe.z00.nu``andytranscribe-stage-app:80`
- `reverb.stage.transcribe.z00.nu``andytranscribe-stage-reverb:8080`
Create and push the branch after the workflow file is on the default branch:
```bash
git checkout -b stage
git push -u origin stage
```
Manual deploy of an already-built tag:
```bash
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> DEPLOY_PATHS=$HOME/andyTranscibe-stage ./scripts/deploy-production.sh
```
## Services and ports
| Service | Host port | Role |
| --- | --- | --- |
| `app` | `8080` | FrankenPHP (Laravel web UI) |
| `reverb` | `8081` | WebSockets for live transcription status |
| `whisper` | `8090` | faster-whisper HTTP API |
| `queue` | — | `php artisan queue:work` for transcription jobs |
| `vite` | `5173` | Vite HMR (dev overlay only) |
### Persistent data
| Host path | Container path | Contents |
| --- | --- | --- |
| `./database` | `/app/database` | SQLite database |
| `./storage/app` | `/app/storage/app` | Uploaded audio (`private/recordings`) |
| `./storage/logs` | `/app/storage/logs` | Application logs |
Whisper model cache uses the Docker volume `whisper-huggingface-cache`.
### Useful environment variables
Edit `.env` before `docker compose up` when you need different ports or models:
| Variable | Purpose | Default |
| --- | --- | --- |
| `APP_KEY` | Required Laravel encryption key | — |
| `APP_IMAGE` | Pre-built image for CI/prod deploys (omit locally) | `andytranscribe-app:latest` |
| `APP_URL` | Public app URL | `http://localhost:8080` |
| `APP_HOST_PORT` | Host port for the web app | `8080` |
| `REVERB_HOST_PORT` | Host port for WebSockets | `8081` |
| `WHISPER_HOST_PORT` | Host port for Whisper | `8090` |
| `REVERB_PUBLIC_HOST` | Browser WebSocket host (Blade meta) | `REVERB_HOST` |
| `CONTAINER_PREFIX` | Docker `container_name` prefix | `andytranscribe` |
| `CADDY_NETWORK` | External Caddy network (z00 overlay) | `caddy-proxy-manager-test_caddy-test-network` |
| `PUBLIC_APP_URL` | Public URL injected by the z00 overlay (unset = prod) | `https://transcribe.z00.nu` |
| `LOCAL_WHISPER_MODEL` | Whisper model id | `Systran/faster-whisper-base` |
| `TRANSCRIPTION_TIMEOUT` | Job/HTTP timeout (seconds). Hung Whisper calls fail the job; UI can restart. | `600` |
| `DB_QUEUE_RETRY_AFTER` | Must exceed `TRANSCRIPTION_TIMEOUT` | `660` |
Inside Compose, Laravel talks to Whisper at `http://whisper:8000/v1` and publishes broadcasts to the `reverb` service. The browser connects using Reverb settings from the HTML meta tags (with Vite `VITE_REVERB_*` as a local fallback).
If you change `REVERB_APP_KEY` or browser-facing Reverb host/port settings, restart the app container so Blade picks up the new values. An image rebuild is only needed when Vite-baked fallbacks must change.
### GPU Whisper (optional)
```bash
docker compose --profile gpu up -d --build
```
Use the GPU Whisper service instead of the CPU `whisper` service when you have an NVIDIA GPU and the NVIDIA Container Toolkit installed.
## Usage
1. **Upload** an MP3 from Recordings → Upload (optional title override).
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.
4. Search the list by title, artist, or transcript text.
1. Open the app and **Log in** with `admin@example.com` / `password` (or `demo@example.com` / `password`), or **Register** a new account.
2. Open **Recordings → Upload** and drop one or many audio files.
3. Transcription starts automatically (the `queue` service must be running).
4. Watch live progress on the list or detail page; stop or restart anytime.
5. Search by title, artist, or transcript text.
6. For older uploads still **Queued** with no progress, use **Queue pending transcriptions** on the recordings list.
## Transcription engines
The demo user is re-seeded on every container start. Any recordings with no owner are assigned to that demo user. Later registered users only see their own uploads.
| Driver | When to use | Needs |
| --- | --- | --- |
| `cloud` | Fastest path; audio leaves your machine | `OPENAI_API_KEY` |
| `local` | Confidential; audio stays on this machine | faster-whisper-server at `LOCAL_WHISPER_URL` |
| `ollama` | Another machine on your network | Host URL + OpenAI-compatible `/v1/audio/transcriptions` |
Finished transcripts are stored on each recording and are included in search.
## Native PHP development (optional)
For hacking on the Laravel app outside the FrankenPHP image:
```bash
composer setup
docker compose up -d whisper reverb
```
Then in separate terminals:
```bash
php artisan serve --port=8000
php artisan queue:work
php artisan reverb:start
npm run dev
```
Point `.env` at local services, for example:
```env
APP_URL=http://localhost:8000
LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1
REVERB_HOST=localhost
REVERB_PORT=8080
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
```
## Tests
@@ -105,6 +303,20 @@ composer test
php artisan test
```
## Schema viewer (Laravel Truss)
[Laravel Truss](https://github.com/albertoarena/laravel-truss) is installed as a **dev** dependency. In `local`, open:
```
/truss
```
It shows a live ER diagram of your schema (structure only, never row data). Optional agent MCP:
```bash
php artisan mcp:start truss
```
## License
MIT
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Actions;
use App\Models\Recording;
use App\Models\User;
use App\Services\Mp3MetadataService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class StoreUploadedRecordings
{
public function __construct(private Mp3MetadataService $metadata) {}
/**
* Persist uploaded audio files and queue transcription.
*
* @param list<UploadedFile> $files
* @return array{
* recordings: list<Recording>,
* skipped_duplicates: int,
* message: string,
* }
*/
public function handle(User $user, array $files, ?string $titleOverride = null): array
{
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$hash = hash_file('sha256', $file->getRealPath());
if ($hash === false) {
continue;
}
if (
isset($seenHashes[$hash])
|| $user->recordings()->where('content_hash', $hash)->exists()
|| $user->recordings()
->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0)
->exists()
) {
$skippedDuplicates++;
continue;
}
$seenHashes[$hash] = true;
$title = count($files) === 1 && filled($titleOverride)
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($user, $file, $hash, $title);
}
$message = $this->message(count($recordings), $skippedDuplicates);
return [
'recordings' => $recordings,
'skipped_duplicates' => $skippedDuplicates,
'message' => $message,
];
}
private function storeUploadedRecording(
User $user,
UploadedFile $file,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $this->metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::query()->create([
'user_id' => $user->id,
'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,
'content_hash' => $contentHash,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
$recording->queueLocalTranscription();
return $recording->fresh();
}
private function message(int $savedCount, int $skippedDuplicates): string
{
if ($savedCount === 0 && $skippedDuplicates > 0) {
return $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.";
}
if ($savedCount === 0) {
return 'No valid audio files were uploaded.';
}
$message = $savedCount === 1
? 'Recording uploaded — transcription queued.'
: $savedCount.' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
return $message;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}
@@ -0,0 +1,141 @@
<?php
namespace App\Events;
use App\Models\Recording;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RecordingTranscriptionUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Reverb's default max payload is 10KB. Keep streamed text well under that.
*/
public const MAX_DELTA_BYTES = 8_000;
/**
* Create a new event instance.
*
* @param array<string, mixed>|null $whisperDelta
*/
public function __construct(
public Recording $recording,
public ?string $transcriptDelta = null,
public bool $transcriptReplace = false,
public ?array $whisperDelta = null,
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, PrivateChannel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('recording.'.$this->recording->id),
new PrivateChannel('user.'.$this->recording->user_id.'.recordings'),
];
}
public function broadcastAs(): string
{
return 'RecordingTranscriptionUpdated';
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
$payload = array_merge(
$this->recording->transcriptionStatusPayload(),
[
'word_count' => $this->recording->word_count,
],
);
unset($payload['transcript'], $payload['whisper']);
$delta = $this->transcriptDelta;
if ($delta !== null && $delta !== '' && strlen($delta) <= self::MAX_DELTA_BYTES) {
$payload['transcript_delta'] = $delta;
$payload['transcript_replace'] = $this->transcriptReplace;
}
$whisper = $this->compactWhisperDelta($this->whisperDelta);
if ($whisper !== null) {
$payload['whisper_delta'] = $whisper;
}
return $payload;
}
/**
* @param array<string, mixed>|null $whisper
* @return array<string, mixed>|null
*/
private function compactWhisperDelta(?array $whisper): ?array
{
if ($whisper === null || $whisper === []) {
return null;
}
// Accumulated segment lists belong in HTTP hydrate, not on Reverb.
unset($whisper['segments']);
if ($whisper === []) {
return null;
}
foreach (['tokens', 'logprobs', 'words'] as $drop) {
$encoded = json_encode($whisper);
if (! is_string($encoded) || strlen($encoded) <= self::MAX_DELTA_BYTES) {
return $whisper;
}
$whisper = $this->dropWhisperField($whisper, $drop);
}
$encoded = json_encode($whisper);
if (! is_string($encoded) || strlen($encoded) > self::MAX_DELTA_BYTES) {
return null;
}
return $whisper;
}
/**
* @param array<string, mixed> $whisper
* @return array<string, mixed>
*/
private function dropWhisperField(array $whisper, string $field): array
{
unset($whisper[$field]);
if (isset($whisper['segment']) && is_array($whisper['segment'])) {
unset($whisper['segment'][$field]);
}
if (isset($whisper['segments']) && is_array($whisper['segments'])) {
$whisper['segments'] = array_map(function (mixed $segment) use ($field): mixed {
if (is_array($segment)) {
unset($segment[$field]);
}
return $segment;
}, $whisper['segments']);
}
return $whisper;
}
}
@@ -1,95 +0,0 @@
<?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,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StreamRecordingController extends Controller
{
/**
* Stream the recording audio for in-browser playback.
*/
public function __invoke(Recording $recording): StreamedResponse
{
Gate::authorize('view', $recording);
abort_unless(
$recording->file_path && Storage::disk('local')->exists($recording->file_path),
404,
);
return Storage::disk('local')->response(
$recording->file_path,
$recording->original_filename,
[
'Content-Type' => $recording->audioMimeType(),
'Accept-Ranges' => 'bytes',
],
'inline',
);
}
}
@@ -1,35 +0,0 @@
<?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,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
class TranscriptionStatusController extends Controller
{
/**
* Live transcription progress for polling.
*/
public function __invoke(Recording $recording): JsonResponse
{
Gate::authorize('view', $recording);
$recording = $recording->fresh();
if ($recording->recoverOrphanedTranscription()) {
$recording->refresh();
}
return response()->json($recording->transcriptionStatusPayload());
}
}
+51 -5
View File
@@ -3,12 +3,51 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rules\File;
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',
];
/**
* Maximum upload size accepted by validation (2 GiB).
*/
public const MAX_AUDIO_KILOBYTES = 2 * 1024 * 1024;
public function authorize(): bool
{
return true;
return $this->user() !== null;
}
/**
* Normalize a single file upload into an array for batch handling.
*/
protected function prepareForValidation(): void
{
if ($this->hasFile('audio') && $this->file('audio') instanceof UploadedFile) {
$this->files->set('audio', [$this->file('audio')]);
}
}
/**
@@ -17,7 +56,11 @@ class StoreRecordingRequest extends FormRequest
public function rules(): array
{
return [
'audio' => ['required', 'file', 'mimes:mp3,mpeg', 'max:102400'],
'audio' => ['required', 'array', 'min:1', 'max:50'],
'audio.*' => [
'required',
File::types(self::AUDIO_EXTENSIONS)->max('2gb'),
],
'title' => ['nullable', 'string', 'max:255'],
];
}
@@ -28,9 +71,12 @@ class StoreRecordingRequest extends FormRequest
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.',
'audio.required' => 'Please choose at least one audio file to upload.',
'audio.min' => 'Please choose at least one audio file to upload.',
'audio.max' => 'You can upload at most 50 files at once.',
'audio.*.required' => 'Please choose an audio file to upload.',
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
];
}
}
@@ -1,43 +0,0 @@
<?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://',
];
}
}
+131 -14
View File
@@ -6,24 +6,40 @@ use App\Models\Recording;
use App\Services\TranscriptionService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Support\Facades\Log;
use Throwable;
#[FailOnTimeout]
class TranscribeRecording implements ShouldQueue
{
use Queueable;
/**
* The number of seconds the job can run before timing out.
* The number of times the job may be attempted.
*/
public int $timeout = 600;
public int $tries = 1;
/**
* The number of seconds the job can run before timing out.
*
* Covers a hung Whisper HTTP call: the worker is killed, failed() runs,
* and the recording is marked failed so the UI can restart.
*/
public int $timeout;
/**
* ISO-8601 transcription_started_at this job owns (ignored after cancel/restart).
*/
public ?string $runStartedAt = null;
/**
* Create a new job instance.
*/
public function __construct(public Recording $recording)
{
//
$this->timeout = max(60, (int) config('ai.transcription_timeout', 600));
$this->runStartedAt = $recording->transcription_started_at?->toIso8601String();
}
/**
@@ -31,30 +47,131 @@ class TranscribeRecording implements ShouldQueue
*/
public function handle(TranscriptionService $transcription): void
{
$this->recording->update([
$recording = Recording::query()->find($this->recording->id);
if ($recording === null) {
return;
}
$this->recording = $recording;
if ($this->runStartedAt !== null && ! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
return;
}
if ($this->runStartedAt === null && ! $this->recording->isTranscribing()) {
return;
}
$this->recording->forceFill([
'transcription_status' => 'processing',
]);
'transcription_started_at' => $this->recording->transcription_started_at ?? now(),
'transcription_error' => null,
'transcription_verbose' => null,
])->save();
$this->runStartedAt ??= $this->recording->transcription_started_at?->toIso8601String();
try {
$text = $transcription->transcribe($this->recording);
$text = $transcription->transcribe(
$this->recording,
function (string $message, int $percent, ?string $partialTranscript = null, ?array $whisper = null): void {
$this->reportIfOwned($message, $percent, $partialTranscript, $whisper);
},
fn (): bool => Recording::query()->find($this->recording->id)
?->ownsTranscriptionRun($this->runStartedAt) ?? false,
);
$this->recording->update([
'transcript' => $text,
'transcription_status' => 'done',
'transcribed_at' => now(),
]);
if (! $this->claimSuccessfulTranscript($text)) {
return;
}
} catch (Throwable $e) {
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
Log::warning('Transcription exception ignored after run was superseded', [
'recording_id' => $this->recording->id,
'message' => $e->getMessage(),
]);
return;
}
Log::error('Transcription failed', [
'recording_id' => $this->recording->id,
'driver' => $this->recording->transcription_driver,
'message' => $e->getMessage(),
'exception' => $e::class,
]);
$this->recording->update([
'transcription_status' => 'failed',
]);
$this->recording->markTranscriptionFailed($e->getMessage());
throw $e;
}
}
/**
* Handle a job failure (timeouts, worker kill, etc.).
*/
public function failed(?Throwable $e): void
{
$recording = Recording::query()->find($this->recording->id);
if ($recording === null || ! $recording->ownsTranscriptionRun($this->runStartedAt)) {
return;
}
$recording->markTranscriptionFailed(
$e?->getMessage() ?: 'Transcription stopped unexpectedly.',
);
}
/**
* Persist a finished transcript when this job still owns the run.
*
* Also recovers runs that were falsely marked failed by orphan detection
* while Whisper was still working.
*/
private function claimSuccessfulTranscript(string $text): bool
{
$this->recording->refresh();
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
$this->recording->markTranscriptionComplete($text);
return true;
}
// Same run was wrongly marked failed as "orphaned" while Whisper was still running.
if (
$this->recording->transcription_status === 'failed'
&& $this->recording->matchesTranscriptionRun($this->runStartedAt)
&& (
str_contains((string) $this->recording->transcription_error, 'worker stopped')
|| str_contains((string) $this->recording->transcription_error, 'timed out')
)
) {
Log::warning('Recovering transcript after false orphan failure', [
'recording_id' => $this->recording->id,
]);
$this->recording->markTranscriptionComplete($text);
return true;
}
Log::info('Discarding transcript because transcription run was superseded', [
'recording_id' => $this->recording->id,
'status' => $this->recording->transcription_status,
]);
return false;
}
private function reportIfOwned(string $message, int $percent, ?string $partialTranscript = null, ?array $whisper = null): void
{
if (! $this->recording->ownsTranscriptionRun($this->runStartedAt)) {
return;
}
$this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript, whisperDelta: $whisper);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
class AssignOrphanedRecordings
{
/**
* Assign unowned recordings to the first registered user.
*/
public function handle(Registered $event): void
{
if (User::query()->count() !== 1) {
return;
}
Recording::query()
->whereNull('user_id')
->update(['user_id' => $event->user->id]);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Features\SupportRedirects\Redirector;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke(): Redirector|RedirectResponse
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
return redirect('/');
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Livewire\Recordings;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Upload recording')]
class Create extends Component
{
public function render(): View
{
return view('livewire.recordings.create');
}
}
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('Recordings')]
class Index extends Component
{
use WithPagination;
#[Url(as: 'q', history: true)]
public string $search = '';
#[Url(as: 'sort', history: true)]
public string $sortBy = 'uploaded';
#[Url(as: 'dir', history: true)]
public string $sortDirection = 'desc';
/**
* @var array<string, string>
*/
private const SORTABLE = [
'title' => 'title',
'duration' => 'duration_seconds',
'status' => 'transcription_status',
'uploaded' => 'created_at',
];
public function mount(): void
{
$this->normalizeSort();
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function sort(string $column): void
{
if ($column !== 'words' && ! array_key_exists($column, self::SORTABLE)) {
return;
}
if ($this->sortBy === $column) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDirection = $column === 'uploaded' ? 'desc' : 'asc';
}
$this->resetPage();
}
public function queuePending(): void
{
$queued = 0;
Auth::user()->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->orderBy('id')
->each(function (Recording $recording) use (&$queued): void {
if ($recording->hasActiveTranscriptionJob()) {
return;
}
$recording->queueLocalTranscription();
$queued++;
});
if ($queued === 0) {
Flux::toast(text: 'No recordings need transcription right now.', variant: 'danger');
return;
}
Flux::toast(
text: $queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
variant: 'success',
);
}
public function delete(int $recordingId): void
{
$recording = Auth::user()->recordings()->findOrFail($recordingId);
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
Flux::toast(text: 'Recording deleted.', variant: 'success');
}
public function deleteAll(): void
{
$deleted = 0;
Auth::user()->recordings()
->orderBy('id')
->each(function (Recording $recording) use (&$deleted): void {
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
$deleted++;
});
$this->resetPage();
if ($deleted === 0) {
Flux::toast(text: 'No recordings to delete.', variant: 'danger');
return;
}
Flux::toast(
text: $deleted === 1
? 'Deleted 1 recording.'
: "Deleted {$deleted} recordings.",
variant: 'success',
);
}
public function render(): View
{
$user = Auth::user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$totalCount = $user->recordings()->count();
$this->normalizeSort();
$query = $user->recordings();
$search = trim($this->search);
if ($search !== '') {
$query->search($search);
}
$this->applySort($query);
$recordings = $query->paginate(20);
$pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
$hasActiveTranscriptions = $user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->exists();
return view('livewire.recordings.index', [
'recordings' => $recordings,
'search' => $search,
'pendingCount' => $pendingCount,
'hasActiveTranscriptions' => $hasActiveTranscriptions,
'totalCount' => $totalCount,
]);
}
private function normalizeSort(): void
{
if ($this->sortBy !== 'words' && ! array_key_exists($this->sortBy, self::SORTABLE)) {
$this->sortBy = 'uploaded';
}
if (! in_array($this->sortDirection, ['asc', 'desc'], true)) {
$this->sortDirection = 'desc';
}
}
/**
* @param Builder<Recording>|HasMany<Recording, User> $query
*/
private function applySort(Builder|HasMany $query): void
{
$direction = $this->sortDirection === 'asc' ? 'asc' : 'desc';
if ($this->sortBy === 'words') {
$query->orderByRaw(
'CASE WHEN transcript IS NULL OR TRIM(transcript) = ? THEN 0 ELSE LENGTH(TRIM(transcript)) - LENGTH(REPLACE(TRIM(transcript), ?, ?)) + 1 END '.$direction,
['', ' ', ''],
);
} else {
$query->orderBy(self::SORTABLE[$this->sortBy], $direction);
}
$query->orderByDesc('id');
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Recording $recording;
public function mount(Recording $recording): void
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
$this->recording = $recording;
}
public function startTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
$this->recording->queueLocalTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription started. Progress updates below.', variant: 'success');
}
public function cancelTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
if (! $this->recording->isTranscribing()) {
Flux::toast(text: 'No transcription is currently running.', variant: 'danger');
return;
}
$this->recording->cancelTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription stopped.', variant: 'success');
}
public function delete(): mixed
{
Gate::authorize('delete', $this->recording);
$this->recording->deleteFile();
$this->recording->delete();
session()->flash('success', 'Recording deleted.');
return $this->redirect(route('recordings.index'), navigate: true);
}
public function render(): View
{
return view('livewire.recordings.show')
->title($this->recording->title);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Livewire;
use App\Actions\StoreUploadedRecordings;
use App\Http\Requests\StoreRecordingRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\File;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class UploadRecordings extends Component
{
use WithFileUploads;
/**
* @var list<TemporaryUploadedFile>
*/
public array $audio = [];
public bool $saving = false;
public bool $showCancel = true;
public function updatedAudio(): void
{
if ($this->saving || $this->audio === []) {
return;
}
$this->save();
}
public function save(?StoreUploadedRecordings $store = null): mixed
{
if ($this->saving) {
return null;
}
$this->saving = true;
try {
$store ??= app(StoreUploadedRecordings::class);
$this->validate([
'audio' => ['required', 'array', 'min:1', 'max:50'],
'audio.*' => [
'required',
File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
],
], [
'audio.required' => 'Please choose at least one audio file to upload.',
'audio.min' => 'Please choose at least one audio file to upload.',
'audio.max' => 'You can upload at most 50 files at once.',
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
]);
$result = $store->handle(Auth::user(), $this->audio);
$this->audio = [];
if ($result['recordings'] === []) {
session()->flash('error', $result['message']);
return $this->redirect(route('recordings.create'), navigate: true);
}
session()->flash('success', $result['message']);
if (count($result['recordings']) === 1) {
return $this->redirect(
route('recordings.show', $result['recordings'][0]),
navigate: true,
);
}
return $this->redirect(route('recordings.index'), navigate: true);
} finally {
$this->saving = false;
}
}
public function render()
{
return view('livewire.upload-recordings');
}
}
+694
View File
@@ -2,9 +2,20 @@
namespace App\Models;
use App\Events\RecordingTranscriptionUpdated;
use App\Jobs\TranscribeRecording;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class Recording extends Model
{
@@ -12,6 +23,7 @@ class Recording extends Model
* @var list<string>
*/
protected $fillable = [
'user_id',
'title',
'original_filename',
'file_path',
@@ -20,11 +32,18 @@ class Recording extends Model
'artist',
'album',
'file_size_bytes',
'content_hash',
'transcript',
'transcription_verbose',
'transcription_status',
'transcription_progress',
'transcription_percent',
'transcription_started_at',
'transcription_error',
'transcription_driver',
'ollama_url',
'transcribed_at',
'transcription_duration_seconds',
];
/**
@@ -35,11 +54,24 @@ class Recording extends Model
return [
'recorded_at' => 'datetime',
'transcribed_at' => 'datetime',
'transcription_started_at' => 'datetime',
'duration_seconds' => 'integer',
'transcription_duration_seconds' => 'integer',
'file_size_bytes' => 'integer',
'transcription_percent' => 'integer',
'transcription_verbose' => 'array',
'user_id' => 'integer',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Human-readable duration (m:ss).
*/
@@ -57,6 +89,601 @@ class Recording extends Model
});
}
/**
* Word count of the stored transcript (0 when empty).
*/
protected function wordCount(): Attribute
{
return Attribute::get(function (): int {
if (! filled($this->transcript)) {
return 0;
}
return count(preg_split('/\s+/u', trim($this->transcript), -1, PREG_SPLIT_NO_EMPTY) ?: []);
});
}
/**
* Friendly label for the selected transcription engine.
*/
protected function transcriptionDriverLabel(): Attribute
{
return Attribute::get(function (): ?string {
return match ($this->transcription_driver) {
'local' => 'Local (faster-whisper)',
default => $this->transcription_driver ?: 'Local (faster-whisper)',
};
});
}
/**
* Whether transcription is actively running or queued.
*/
public function isTranscribing(): bool
{
return in_array($this->transcription_status, ['pending', 'processing'], true);
}
/**
* Queue a new local faster-whisper transcription run.
*/
public function queueLocalTranscription(): void
{
// Only stop a real in-flight/queued run — bare "pending" uploads have no job yet.
if ($this->transcription_status === 'processing' || $this->hasActiveTranscriptionJob()) {
$this->cancelTranscription(silent: true);
$this->refresh();
}
$this->update([
'transcription_driver' => 'local',
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_started_at' => now(),
'transcription_error' => null,
'transcription_duration_seconds' => null,
// Keep the previous transcript until a new run succeeds.
'transcribed_at' => $this->transcribed_at,
]);
$recording = $this->fresh();
$this->broadcastTranscriptionUpdated();
TranscribeRecording::dispatch($recording);
}
/**
* Human-readable transcription status for badges.
*/
public function transcriptionStatusLabel(): string
{
return match ($this->transcription_status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $this->transcription_status,
};
}
/**
* Seconds since the current transcription run started.
*/
public function transcriptionElapsedSeconds(): ?int
{
if ($this->transcription_started_at === null) {
return null;
}
return max(0, now()->getTimestamp() - $this->transcription_started_at->getTimestamp());
}
/**
* Search title, metadata, and stored transcript text.
*/
#[Scope]
protected function search(Builder $query, string $term): void
{
$like = '%'.$term.'%';
$query->where(function (Builder $builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('artist', 'like', $like)
->orWhere('album', 'like', $like)
->orWhere('original_filename', 'like', $like)
->orWhere('transcript', 'like', $like);
});
}
/**
* First line of the transcript, truncated for compact list rows.
*/
public function transcriptFirstLine(int $limit = 120): ?string
{
if (! filled($this->transcript)) {
return null;
}
$firstLine = Str::of($this->transcript)
->before("\n")
->replaceMatches('/\s+/', ' ')
->trim()
->toString();
if ($firstLine === '') {
return null;
}
return Str::limit($firstLine, $limit);
}
/**
* Short transcript excerpt, optionally centered on a search hit.
*/
public function transcriptSnippet(?string $term = null, int $radius = 80): ?string
{
if (! filled($this->transcript)) {
return null;
}
$transcript = preg_replace('/\s+/', ' ', $this->transcript) ?? $this->transcript;
if ($term === null || $term === '') {
return Str::limit($transcript, $radius * 2);
}
$position = mb_stripos($transcript, $term);
if ($position === false) {
return Str::limit($transcript, $radius * 2);
}
$start = max(0, $position - $radius);
$excerpt = mb_substr($transcript, $start, ($radius * 2) + mb_strlen($term));
return ($start > 0 ? '…' : '').$excerpt.(mb_strlen($transcript) > $start + mb_strlen($excerpt) ? '…' : '');
}
/**
* Seconds after which a reserved queue row is considered abandoned
* (worker died mid-Whisper without releasing the job).
*/
public function transcriptionJobStaleAfterSeconds(): int
{
return max(120, (int) config('ai.transcription_timeout', 600) + 90);
}
/**
* Whether a TranscribeRecording job for this recording is still on the queue.
*
* Reserved jobs older than the transcription timeout (+ grace) are ignored so
* orphan recovery can unblock the UI when Whisper/the worker is wedged.
*/
public function hasActiveTranscriptionJob(): bool
{
$staleBefore = now()->timestamp - $this->transcriptionJobStaleAfterSeconds();
return DB::table('jobs')
->orderBy('id')
->get(['id', 'payload', 'reserved_at'])
->contains(function (object $job) use ($staleBefore): bool {
$payload = (string) $job->payload;
if (! str_contains($payload, 'TranscribeRecording')) {
return false;
}
if ($job->reserved_at !== null && (int) $job->reserved_at <= $staleBefore) {
return false;
}
return $this->jobPayloadBelongsToRecording($payload);
});
}
/**
* Fallback payload match when unserialize is unavailable.
*/
private function payloadMentionsRecording(string $payload): bool
{
// Jobs table stores JSON; the serialized command inside escapes quotes as \".
return (bool) preg_match('/id\\\\";i:'.$this->id.';/', $payload)
|| (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
|| str_contains($payload, 'id\\\\";s:'.strlen((string) $this->id).':\\"'.$this->id.'\\"');
}
/**
* Processing/pending with no worker job left (crashed worker, bad retry_after, etc.).
*/
public function isOrphanedTranscription(): bool
{
if (! $this->isTranscribing()) {
return false;
}
if ($this->hasActiveTranscriptionJob()) {
return false;
}
$reference = $this->transcription_started_at ?? $this->updated_at;
if ($reference === null) {
return true;
}
// Only treat as orphaned after the job could not possibly still be running.
// (A short grace caused false failures while Whisper was still working.)
$orphanAfterSeconds = max(120, (int) config('ai.transcription_timeout', 600) + 60);
return $reference->lte(now()->subSeconds($orphanAfterSeconds));
}
/**
* Whether a payload/job belongs to this recording's transcription run start time.
*/
public function matchesTranscriptionRun(?string $runStartedAt): bool
{
if ($runStartedAt === null || $this->transcription_started_at === null) {
return false;
}
return $this->transcription_started_at->getTimestamp() === Carbon::parse($runStartedAt)->getTimestamp();
}
/**
* Whether this recording still expects results for the given run.
*/
public function ownsTranscriptionRun(?string $runStartedAt): bool
{
$this->refresh();
if (! $this->isTranscribing()) {
return false;
}
return $this->matchesTranscriptionRun($runStartedAt);
}
/**
* Remove queued TranscribeRecording jobs for this recording.
*/
public function discardQueuedTranscriptionJobs(): int
{
$deleted = 0;
DB::table('jobs')
->orderBy('id')
->get()
->each(function (object $job) use (&$deleted): void {
$payload = (string) $job->payload;
if (! str_contains($payload, 'TranscribeRecording')) {
return;
}
if (! $this->jobPayloadBelongsToRecording($payload)) {
return;
}
DB::table('jobs')->where('id', $job->id)->delete();
$deleted++;
});
$this->releaseTranscriptionUniqueLock();
return $deleted;
}
/**
* Whether a jobs.payload row targets this recording.
*/
private function jobPayloadBelongsToRecording(string $payload): bool
{
$data = json_decode($payload, true);
$command = $data['data']['command'] ?? null;
if (is_string($command)) {
if (
preg_match('/id";i:'.$this->id.';/', $command)
|| str_contains($command, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"')
) {
return true;
}
try {
$queued = unserialize($command);
if ($queued instanceof TranscribeRecording) {
return (int) $queued->recording->getKey() === (int) $this->id;
}
} catch (Throwable) {
// Fall through to escaped JSON heuristics.
}
}
return $this->payloadMentionsRecording($payload);
}
/**
* Stop transcription: drop queued jobs and mark the run cancelled.
*
* @param bool $silent When true, skip status update (used before starting a replacement run).
*/
public function cancelTranscription(bool $silent = false): void
{
$this->discardQueuedTranscriptionJobs();
if ($silent) {
return;
}
$this->forceFill([
'transcription_status' => 'cancelled',
'transcription_progress' => 'Stopped by user',
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => 'Stopped by user',
])->save();
$this->broadcastTranscriptionUpdated();
}
/**
* Release a leftover ShouldBeUnique lock from earlier job versions.
*/
public function releaseTranscriptionUniqueLock(): void
{
Cache::lock(
'laravel_unique_job:'.TranscribeRecording::class.'transcribe-recording:'.$this->id
)->forceRelease();
}
/**
* Persist a successful transcript and how long the run took.
*/
public function markTranscriptionComplete(string $text): void
{
$finishedAt = now();
$startedAt = $this->transcription_started_at;
$durationSeconds = $startedAt === null
? null
: max(0, $finishedAt->getTimestamp() - $startedAt->getTimestamp());
$this->forceFill([
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => $finishedAt,
'transcription_duration_seconds' => $durationSeconds,
])->save();
$this->broadcastTranscriptionUpdated();
}
/**
* Mark transcription as failed and unblock the UI.
*/
public function markTranscriptionFailed(string $message): void
{
if (! $this->isTranscribing()) {
return;
}
$this->forceFill([
'transcription_status' => 'failed',
'transcription_progress' => 'Transcription failed',
'transcription_percent' => $this->transcription_percent ?: 0,
'transcription_error' => $message,
])->save();
$this->broadcastTranscriptionUpdated();
}
/**
* Recover a stuck transcription if the queue job is gone or a reservation is stale.
*/
public function recoverOrphanedTranscription(): bool
{
if (! $this->isOrphanedTranscription()) {
return false;
}
// Drop abandoned reserved rows so a restart can enqueue cleanly.
$this->discardQueuedTranscriptionJobs();
$this->markTranscriptionFailed(
'Transcription timed out or the worker stopped before finishing. Start transcription again.',
);
return true;
}
/**
* Update the live progress fields shown in the UI.
*
* @param array<string, mixed>|null $whisperDelta
*/
public function reportProgress(string $message, int $percent, string $status = 'processing', ?string $partialTranscript = null, ?array $whisperDelta = null): void
{
$diff = $this->transcriptBroadcastDiff($partialTranscript);
$attributes = [
'transcription_status' => $status,
'transcription_progress' => $message,
'transcription_percent' => max(0, min(100, $percent)),
'transcription_error' => null,
];
if ($partialTranscript !== null) {
$attributes['transcript'] = $partialTranscript;
}
if ($whisperDelta !== null) {
$attributes['transcription_verbose'] = $this->mergeWhisperVerbose($whisperDelta);
}
$this->forceFill($attributes)->save();
$this->broadcastTranscriptionUpdated($diff['delta'], $diff['replace'], $whisperDelta);
}
/**
* Broadcast the current transcription status to connected browsers.
*
* @param array<string, mixed>|null $whisperDelta
*/
public function broadcastTranscriptionUpdated(?string $transcriptDelta = null, bool $transcriptReplace = false, ?array $whisperDelta = null): void
{
try {
RecordingTranscriptionUpdated::dispatch(
$this->fresh() ?? $this,
$transcriptDelta,
$transcriptReplace,
$whisperDelta,
);
} catch (Throwable $e) {
Log::warning('Failed to broadcast transcription status', [
'recording_id' => $this->id,
'message' => $e->getMessage(),
]);
}
}
/**
* Incremental text to push over Reverb instead of the full transcript.
*
* @return array{delta: ?string, replace: bool}
*/
public function transcriptBroadcastDiff(?string $next): array
{
if ($next === null) {
return ['delta' => null, 'replace' => false];
}
$previous = (string) $this->transcript;
if ($previous === '' || ! str_starts_with($next, $previous)) {
return ['delta' => $next, 'replace' => true];
}
$delta = substr($next, strlen($previous));
return ['delta' => $delta === '' ? null : $delta, 'replace' => false];
}
/**
* Fold a streamed Whisper chunk into the stored verbose_json snapshot.
*
* @param array<string, mixed> $delta
* @return array<string, mixed>
*/
public function mergeWhisperVerbose(array $delta): array
{
$verbose = is_array($this->transcription_verbose) ? $this->transcription_verbose : [];
if (isset($delta['language']) && is_string($delta['language'])) {
$verbose['language'] = $delta['language'];
}
if (isset($delta['duration']) && is_numeric($delta['duration'])) {
$verbose['duration'] = (float) $delta['duration'];
}
$segments = is_array($verbose['segments'] ?? null) ? $verbose['segments'] : [];
if (isset($delta['segment']) && is_array($delta['segment'])) {
$segments = $this->appendVerboseSegment($segments, $delta['segment']);
}
if (isset($delta['segments']) && is_array($delta['segments'])) {
$incoming = [];
foreach ($delta['segments'] as $segment) {
if (is_array($segment)) {
$incoming[] = $segment;
}
}
if ($incoming !== []) {
if (isset($delta['segment'])) {
foreach ($incoming as $segment) {
$segments = $this->appendVerboseSegment($segments, $segment);
}
} else {
$segments = $incoming;
}
}
}
if (isset($delta['words']) && is_array($delta['words']) && $delta['words'] !== []) {
$last = $segments === [] ? null : count($segments) - 1;
if ($last === null) {
$segments[] = ['words' => $delta['words']];
} else {
$existing = is_array($segments[$last]['words'] ?? null) ? $segments[$last]['words'] : [];
$segments[$last]['words'] = array_merge($existing, $delta['words']);
}
}
$logprobs = is_array($verbose['logprobs'] ?? null) ? $verbose['logprobs'] : [];
if (isset($delta['logprobs']) && is_array($delta['logprobs'])) {
foreach ($delta['logprobs'] as $row) {
if (is_array($row)) {
$logprobs[] = $row;
}
}
}
if ($segments !== []) {
$verbose['segments'] = $segments;
}
if ($logprobs !== []) {
$verbose['logprobs'] = $logprobs;
}
return $verbose;
}
/**
* @param list<array<string, mixed>> $segments
* @param array<string, mixed> $segment
* @return list<array<string, mixed>>
*/
private function appendVerboseSegment(array $segments, array $segment): array
{
$key = $this->verboseSegmentKey($segment);
foreach ($segments as $existing) {
if ($this->verboseSegmentKey($existing) === $key) {
return $segments;
}
}
$segments[] = $segment;
return $segments;
}
/**
* @param array<string, mixed> $segment
*/
private function verboseSegmentKey(array $segment): string
{
return implode('|', [
$segment['id'] ?? '',
$segment['start'] ?? '',
$segment['end'] ?? '',
$segment['text'] ?? '',
]);
}
/**
* Absolute filesystem path for the stored audio file.
*/
@@ -65,6 +692,26 @@ class Recording extends Model
return Storage::disk('local')->path($this->file_path);
}
/**
* MIME type for browser audio playback based on the original filename.
*/
public function audioMimeType(): string
{
$extension = strtolower(pathinfo((string) $this->original_filename, PATHINFO_EXTENSION));
return match ($extension) {
'mp3', 'mpga', 'mpeg' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg', 'oga' => 'audio/ogg',
'flac' => 'audio/flac',
'm4a', 'mp4', 'aac' => 'audio/mp4',
'webm' => 'audio/webm',
'wma' => 'audio/x-ms-wma',
'aiff', 'aif' => 'audio/aiff',
default => 'application/octet-stream',
};
}
/**
* Delete the audio file from storage.
*/
@@ -74,4 +721,51 @@ class Recording extends Model
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 = $this->transcriptionElapsedSeconds();
return [
'id' => $this->id,
'status' => $this->transcription_status,
'status_label' => $this->transcriptionStatusLabel(),
'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,
'transcription_duration_seconds' => $this->transcription_duration_seconds,
'transcription_duration_human' => $this->transcription_duration_seconds === null
? null
: $this->formatElapsed($this->transcription_duration_seconds),
'is_active' => $this->isTranscribing(),
'has_transcript' => filled($this->transcript),
'transcript' => $this->transcript,
'whisper' => $this->transcription_verbose,
'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);
}
}
+22 -1
View File
@@ -2,13 +2,14 @@
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\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
@@ -29,4 +30,24 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
/**
* @return HasMany<Recording, $this>
*/
public function recordings(): HasMany
{
return $this->hasMany(Recording::class);
}
/**
* Initials for Flux avatar components.
*/
public function initials(): string
{
return Str::of($this->name)
->explode(' ')
->take(2)
->map(fn (string $part) => Str::substr($part, 0, 1))
->implode('');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Policies;
use App\Models\Recording;
use App\Models\User;
class RecordingPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
/**
* Determine whether the user can start or stop transcription.
*/
public function transcribe(User $user, Recording $recording): bool
{
return $recording->user_id === $user->id;
}
}
+7 -1
View File
@@ -2,7 +2,11 @@
namespace App\Providers;
use App\Listeners\AssignOrphanedRecordings;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
@@ -19,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Password::defaults(fn () => Password::min(8));
Event::listen(Registered::class, AssignOrphanedRecordings::class);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn () => view('pages::auth.login'));
Fortify::registerView(fn () => view('pages::auth.register'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class DiskSpaceService
{
/**
* Snapshot of free/used space for the recordings storage volume.
*
* @return array{
* total_bytes: int,
* free_bytes: int,
* used_bytes: int,
* used_percent: float,
* free_percent: float,
* total_human: string,
* free_human: string,
* used_human: string
* }|null
*/
public function snapshot(?string $path = null): ?array
{
$path ??= Storage::disk('local')->path('');
if (! is_dir($path)) {
@mkdir($path, 0755, true);
}
/** @var array{total_bytes: int, free_bytes: int, used_bytes: int, used_percent: float, free_percent: float, total_human: string, free_human: string, used_human: string}|null */
return Cache::remember(
'disk-space:'.md5($path),
now()->addSeconds(30),
fn () => $this->measure($path),
);
}
/**
* @return array{
* total_bytes: int,
* free_bytes: int,
* used_bytes: int,
* used_percent: float,
* free_percent: float,
* total_human: string,
* free_human: string,
* used_human: string
* }|null
*/
private function measure(string $path): ?array
{
$total = @disk_total_space($path);
$free = @disk_free_space($path);
if ($total === false || $free === false || $total <= 0) {
return null;
}
$totalBytes = (int) $total;
$freeBytes = (int) max(0, $free);
$usedBytes = (int) max(0, $totalBytes - $freeBytes);
$usedPercent = round(($usedBytes / $totalBytes) * 100, 1);
$freePercent = round(($freeBytes / $totalBytes) * 100, 1);
return [
'total_bytes' => $totalBytes,
'free_bytes' => $freeBytes,
'used_bytes' => $usedBytes,
'used_percent' => $usedPercent,
'free_percent' => $freePercent,
'total_human' => $this->formatBytes($totalBytes),
'free_human' => $this->formatBytes($freeBytes),
'used_human' => $this->formatBytes($usedBytes),
];
}
/**
* Human-readable byte size without requiring the intl extension.
*/
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$value = (float) max(0, $bytes);
$unit = 0;
while ($value >= 1024 && $unit < count($units) - 1) {
$value /= 1024;
$unit++;
}
$precision = $unit === 0 ? 0 : 1;
return number_format($value, $precision).' '.$units[$unit];
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Services;
use Closure;
use CURLFile;
use RuntimeException;
/**
* Streams large audio uploads to Whisper with curl (disk network),
* and delivers response body chunks without buffering the request in PHP.
*
* Laravel Http's stream => true uses Guzzle's StreamHandler, which casts the
* multipart body to a string and OOMs on big recordings.
*/
class LocalWhisperCurlClient
{
/**
* POST /audio/transcriptions and stream the response body.
*
* @param Closure(string): bool $onBodyChunk Return false to abort the transfer.
* @param Closure(string): void|null $onContentType Invoked when the response Content-Type header arrives.
* @return array{status: int, content_type: string}
*/
public function streamTranscription(
string $url,
string $apiKey,
string $path,
string $filename,
string $mimeType,
string $model,
int $timeout,
Closure $onBodyChunk,
?Closure $onContentType = null,
): array {
if (! function_exists('curl_init')) {
throw new RuntimeException('The curl PHP extension is required for local Whisper transcription.');
}
$contentType = '';
$abort = false;
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('Unable to initialize curl for local Whisper.');
}
try {
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.$apiKey,
'Accept: application/json, text/event-stream',
],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile($path, $mimeType, $filename),
'model' => $model,
'response_format' => 'verbose_json',
'stream' => 'true',
'without_timestamps' => 'false',
'timestamp_granularities[]' => 'word',
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_HEADER => false,
CURLOPT_TIMEOUT => max(1, $timeout),
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HEADERFUNCTION => static function ($ch, string $header) use (&$contentType, $onContentType): int {
if (stripos($header, 'Content-Type:') === 0) {
$contentType = trim(substr($header, strlen('Content-Type:')));
if ($onContentType !== null) {
$onContentType($contentType);
}
}
return strlen($header);
},
CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use ($onBodyChunk, &$abort): int {
if ($abort) {
return 0;
}
if ($onBodyChunk($chunk) === false) {
$abort = true;
return 0;
}
return strlen($chunk);
},
]);
$ok = curl_exec($handle);
$errno = curl_errno($handle);
$error = curl_error($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
if ($abort) {
return [
'status' => $status > 0 ? $status : 499,
'content_type' => $contentType,
];
}
if ($ok === false && $errno !== 0) {
throw new RuntimeException(
'Local transcription request failed: '.$error.' (curl '.$errno.')'
);
}
return [
'status' => $status,
'content_type' => $contentType,
];
} finally {
curl_close($handle);
}
}
}
+38 -8
View File
@@ -8,7 +8,7 @@ use getID3;
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{
* title: ?string,
@@ -23,17 +23,15 @@ class Mp3MetadataService
$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'];
}
$tags = $this->preferredTags($info);
$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');
$year = $this->firstTag($tags, 'year')
?? $this->firstTag($tags, 'date')
?? $this->firstTag($tags, 'recording_time')
?? $this->firstTag($tags, 'creation_date');
$duration = isset($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
*/
+367 -49
View File
@@ -3,100 +3,418 @@
namespace App\Services;
use App\Models\Recording;
use Closure;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Transcription;
use RuntimeException;
class TranscriptionService
{
public function __construct(
private WhisperTranscriptionStream $stream,
private LocalWhisperCurlClient $curl,
) {}
/**
* Run transcription for a recording using the selected driver.
* Transcribe a recording with the local faster-whisper server.
*
* @param (Closure(string, int, ?string, ?array): void)|null $onProgress
* @param (Closure(): bool)|null $shouldContinue
*/
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
public function transcribe(Recording $recording, ?Closure $onProgress = null, ?Closure $shouldContinue = null): string
{
$report = $onProgress ?? static fn (string $message, int $percent, ?string $partial = null, ?array $whisper = null) => null;
$model = config('ai.local_whisper_model', 'Systran/faster-whisper-base');
$report("Transcribing locally with {$model} (audio stays on this machine)…", 0);
if (Transcription::isFaked()) {
$transcript = Transcription::fromStorage($recording->file_path)
->timeout((int) config('ai.transcription_timeout', 600))
->generate('local-whisper', $model);
} else {
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
}
return (string) $transcript;
}
/**
* Call an OpenAI-compatible /v1/audio/transcriptions endpoint at a user-supplied host URL.
* Call local Whisper, streaming SSE when the server supports it.
*
* @param Closure(string, int, ?string, ?array): void $report
* @param (Closure(): bool)|null $shouldContinue
*/
private function viaRemoteCompatible(Recording $recording): string
private function transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): 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', [
$message = "Transcribing locally with {$model} (audio stays on this machine)…";
$filename = $recording->original_filename ?: basename($path);
if ($this->usesHttpTransport()) {
return $this->transcribeViaHttp($recording, $report, $message, $model, $filename, $path, $shouldContinue);
}
return $this->transcribeViaCurl($recording, $report, $message, $model, $filename, $path, $shouldContinue);
}
/**
* Laravel Http client path (used in tests via Http::fake).
*
* Does not set Guzzle stream => true, which would string-cast the multipart upload.
*
* @param Closure(string, int, ?string, ?array): void $report
* @param (Closure(): bool)|null $shouldContinue
*/
private function transcribeViaHttp(
Recording $recording,
Closure $report,
string $message,
string $model,
string $filename,
string $path,
?Closure $shouldContinue,
): string {
$response = $this->localWhisperRequest($filename, $path)
->post('audio/transcriptions', [
'model' => $model,
'response_format' => 'json',
'response_format' => 'verbose_json',
'stream' => 'true',
'without_timestamps' => 'false',
'timestamp_granularities[]' => 'word',
]);
if (! $response->successful()) {
throw new RuntimeException(
'Remote transcription failed (HTTP '.$response->status().'): '.$response->body()
'Local transcription failed (HTTP '.$response->status().'): '.$this->truncateBody($response->body())
);
}
$text = $response->json('text');
$contentType = strtolower((string) $response->header('Content-Type'));
if (! str_contains($contentType, 'text/event-stream')) {
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
'recording_id' => $recording->id,
'content_type' => $contentType,
'transport' => 'http',
]);
return $this->extractTranscriptText($response->json(), $report, $message);
}
return $this->consumeSseBody($response->body(), $report, $message, $recording, $shouldContinue);
}
/**
* Curl path for production: streams the file upload and SSE response chunks.
*
* @param Closure(string, int, ?string, ?array): void $report
* @param (Closure(): bool)|null $shouldContinue
*/
private function transcribeViaCurl(
Recording $recording,
Closure $report,
string $message,
string $model,
string $filename,
string $path,
?Closure $shouldContinue,
): string {
$config = config('ai.providers.local-whisper');
$baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/');
$timeout = (int) config('ai.transcription_timeout', 600);
$apiKey = (string) ($config['key'] ?? 'not-needed');
$buffer = '';
$rawBody = '';
$accumulated = '';
$lastPercent = 0;
$isSse = null;
$result = $this->curl->streamTranscription(
$baseUrl.'/audio/transcriptions',
$apiKey,
$path,
$filename,
$recording->audioMimeType(),
$model,
$timeout,
function (string $chunk) use (
&$buffer,
&$rawBody,
&$accumulated,
&$lastPercent,
&$isSse,
$report,
$message,
$recording,
$shouldContinue,
): bool {
if ($shouldContinue !== null && ! $shouldContinue()) {
return false;
}
if ($isSse === null) {
$rawBody .= $chunk;
return true;
}
if ($isSse === false) {
$rawBody .= $chunk;
return true;
}
if ($rawBody !== '') {
$buffer .= $rawBody;
$rawBody = '';
foreach ($this->stream->extractPayloads($buffer) as $payload) {
[$accumulated, $lastPercent] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$recording,
$report,
$message,
);
}
}
$buffer .= $chunk;
foreach ($this->stream->extractPayloads($buffer) as $payload) {
[$accumulated, $lastPercent] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$recording,
$report,
$message,
);
}
return true;
},
function (string $contentType) use (&$isSse): void {
$isSse = str_contains(strtolower($contentType), 'text/event-stream');
},
);
if ($result['status'] >= 400 || $result['status'] === 0) {
throw new RuntimeException(
'Local transcription failed (HTTP '.$result['status'].'): '.$this->truncateBody($rawBody)
);
}
$contentType = strtolower($result['content_type']);
$isSse ??= str_contains($contentType, 'text/event-stream');
if (! $isSse) {
Log::warning('Whisper did not stream SSE; falling back to a single JSON body', [
'recording_id' => $recording->id,
'content_type' => $contentType,
'transport' => 'curl',
]);
$json = json_decode($rawBody, true);
return $this->extractTranscriptText(is_array($json) ? $json : null, $report, $message);
}
if ($rawBody !== '') {
$buffer .= $rawBody;
$rawBody = '';
}
foreach ($this->stream->extractPayloads($buffer) as $payload) {
[$accumulated, $lastPercent] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$recording,
$report,
$message,
);
}
foreach ($this->stream->flushBuffer($buffer) as $payload) {
[$accumulated, $lastPercent] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$recording,
$report,
$message,
);
}
if ($accumulated === '') {
throw new RuntimeException('Local transcription returned an empty transcript.');
}
return $accumulated;
}
private function usesHttpTransport(): bool
{
return config('ai.local_whisper_transport', 'curl') === 'http';
}
/**
* @param Closure(string, int, ?string, ?array): void $report
* @param (Closure(): bool)|null $shouldContinue
*/
private function consumeSseBody(
string $body,
Closure $report,
string $message,
Recording $recording,
?Closure $shouldContinue,
): string {
if ($shouldContinue !== null && ! $shouldContinue()) {
throw new RuntimeException('Local transcription returned an empty transcript.');
}
$buffer = $body;
$accumulated = '';
$lastPercent = 0;
foreach ($this->stream->extractPayloads($buffer) as $payload) {
[$accumulated, $lastPercent] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$recording,
$report,
$message,
);
}
foreach ($this->stream->flushBuffer($buffer) as $payload) {
[$accumulated, $lastPercent] = $this->ingestEvent(
$payload,
$accumulated,
$lastPercent,
$recording,
$report,
$message,
);
}
if ($accumulated === '') {
throw new RuntimeException('Local transcription returned an empty transcript.');
}
return $accumulated;
}
/**
* @param Closure(string, int, ?string, ?array): void $report
* @return array{0: string, 1: int}
*/
private function ingestEvent(
string $payload,
string $accumulated,
int $lastPercent,
Recording $recording,
Closure $report,
string $message,
): array {
$event = $this->stream->parseEvent($payload);
if ($event === null) {
return [$accumulated, $lastPercent];
}
$accumulated = $this->stream->applyEvent($accumulated, $event);
$whisper = $event['whisper'] ?? [];
if ($accumulated === '' && $whisper === []) {
return [$accumulated, $lastPercent];
}
$lastPercent = $this->percentForEvent($event, $recording, $lastPercent);
$report(
$message,
$lastPercent,
$accumulated === '' ? null : $accumulated,
$whisper === [] ? null : $whisper,
);
return [$accumulated, $lastPercent];
}
/**
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
*/
private function percentForEvent(array $event, Recording $recording, int $lastPercent): int
{
$duration = $recording->duration_seconds;
$end = $event['end'] ?? null;
if ($end === null || $duration === null || $duration <= 0) {
return $lastPercent;
}
return (int) min(99, max($lastPercent, round(100 * $end / $duration)));
}
private function localWhisperRequest(string $filename, string $path): PendingRequest
{
$config = config('ai.providers.local-whisper');
$baseUrl = rtrim((string) ($config['url'] ?? 'http://127.0.0.1:8090/v1'), '/');
$timeout = (int) config('ai.transcription_timeout', 600);
return Http::baseUrl($baseUrl)
->withHeaders(['Authorization' => 'Bearer '.($config['key'] ?? 'not-needed')])
->timeout($timeout)
->connectTimeout(10)
->attach('file', fopen($path, 'r'), $filename);
}
/**
* @param array<string, mixed>|null $json
* @param Closure(string, int, ?string, ?array): void $report
*/
private function extractTranscriptText(?array $json, ?Closure $report = null, string $message = ''): string
{
if (! is_array($json)) {
throw new RuntimeException('Local transcription returned an empty transcript.');
}
$text = $json['text'] ?? null;
if (! is_string($text) || $text === '') {
throw new RuntimeException('Remote transcription returned an empty transcript. Ensure the host exposes OpenAI-compatible /v1/audio/transcriptions.');
throw new RuntimeException('Local transcription returned an empty transcript.');
}
$whisper = $this->stream->extractWhisperMeta($json);
if ($report !== null && $whisper !== []) {
$report($message, 99, $text, $whisper);
}
return $text;
}
/**
* Normalize a user URL to an OpenAI-style base ending in /v1.
*/
private function normalizeBaseUrl(string $url): string
private function truncateBody(string $body): string
{
$url = rtrim(trim($url), '/');
$body = trim($body);
if (Str::endsWith($url, '/v1')) {
return $url;
if (strlen($body) <= 2000) {
return $body;
}
return $url.'/v1';
return substr($body, 0, 2000).'…';
}
}
+460
View File
@@ -0,0 +1,460 @@
<?php
namespace App\Services;
class WhisperTranscriptionStream
{
/**
* Pull complete SSE `data:` payloads out of a rolling buffer.
*
* @return list<string>
*/
public function extractPayloads(string &$buffer): array
{
$frames = explode("\n\n", str_replace("\r\n", "\n", $buffer));
$buffer = array_pop($frames) ?? '';
$payloads = [];
foreach ($frames as $frame) {
foreach ($this->dataLines($frame) as $payload) {
if ($payload === '[DONE]' || $payload === '') {
continue;
}
$payloads[] = $payload;
}
}
return $payloads;
}
/**
* Flush a trailing incomplete frame at end-of-stream.
*
* @return list<string>
*/
public function flushBuffer(string &$buffer): array
{
$trimmed = trim($buffer);
if ($trimmed === '') {
$buffer = '';
return [];
}
$buffer .= "\n\n";
return $this->extractPayloads($buffer);
}
/**
* Parse one SSE JSON payload into append/replace/end/done/whisper fields.
*
* @return array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper: array<string, mixed>}|null
*/
public function parseEvent(string $json): ?array
{
$data = json_decode($json, true);
if (! is_array($data)) {
return null;
}
$type = $data['type'] ?? null;
$whisper = $this->extractWhisperMeta($data);
$end = $this->latestAudioEnd($data, $whisper);
if ($type === 'transcript.text.delta') {
$delta = $data['delta'] ?? '';
if ((! is_string($delta) || $delta === '') && $whisper === []) {
return null;
}
return [
'append' => is_string($delta) && $delta !== '' ? $delta : null,
'replace' => null,
'end' => $end,
'done' => false,
'legacy' => false,
'whisper' => $whisper,
];
}
if ($type === 'transcript.text.done') {
$text = $data['text'] ?? '';
return [
'append' => null,
'replace' => is_string($text) ? $text : '',
'end' => $end,
'done' => true,
'legacy' => false,
'whisper' => $whisper,
];
}
if ($this->isLiveSegmentEvent($type, $data)) {
return [
'append' => $data['text'],
'replace' => null,
'end' => $end,
'done' => false,
'legacy' => true,
'whisper' => $whisper,
];
}
if (isset($data['segments']) && is_array($data['segments'])) {
$text = $data['text'] ?? '';
if ((! is_string($text) || $text === '') && $whisper === []) {
return null;
}
return [
'append' => null,
'replace' => is_string($text) && $text !== '' ? $text : null,
'end' => $end,
'done' => true,
'legacy' => false,
'whisper' => $whisper,
];
}
if ($whisper !== []) {
return [
'append' => null,
'replace' => null,
'end' => $end,
'done' => false,
'legacy' => false,
'whisper' => $whisper,
];
}
return null;
}
/**
* Apply a parsed event to the accumulated transcript.
*
* @param array{append: ?string, replace: ?string, end: ?float, done: bool, legacy: bool, whisper?: array<string, mixed>} $event
*/
public function applyEvent(string $accumulated, array $event): string
{
if (is_string($event['replace'])) {
return $event['replace'];
}
$chunk = $event['append'] ?? '';
if ($chunk === '') {
return $accumulated;
}
if ($event['legacy']) {
$chunk = trim($chunk);
if ($accumulated === '') {
return $chunk;
}
return rtrim($accumulated).' '.$chunk;
}
return $accumulated.$chunk;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function extractWhisperMeta(array $data): array
{
$meta = [];
if (isset($data['language']) && is_string($data['language']) && $data['language'] !== '') {
$meta['language'] = $data['language'];
}
if (is_numeric($data['duration'] ?? null)) {
$meta['duration'] = (float) $data['duration'];
}
if (isset($data['logprobs']) && is_array($data['logprobs'])) {
$logprobs = $this->normalizeLogprobs($data['logprobs']);
if ($logprobs !== []) {
$meta['logprobs'] = $logprobs;
}
}
$segment = $this->normalizeSegment($data);
if ($segment !== null && ($data['type'] ?? null) !== 'transcript.text.delta') {
$meta['segment'] = $segment;
}
if (isset($data['segments']) && is_array($data['segments'])) {
$segments = [];
foreach ($data['segments'] as $row) {
if (! is_array($row)) {
continue;
}
$normalized = $this->normalizeSegment($row);
if ($normalized !== null) {
$segments[] = $normalized;
}
}
if ($segments !== [] && ! isset($meta['segment'])) {
$meta['segments'] = $segments;
}
}
if (isset($data['words']) && is_array($data['words']) && ! isset($meta['segment']) && ! isset($meta['segments'])) {
$words = $this->normalizeWords($data['words']);
if ($words !== []) {
$meta['words'] = $words;
}
}
return $meta;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>|null
*/
public function normalizeSegment(array $data): ?array
{
$hasDetail = isset($data['start'])
|| isset($data['end'])
|| isset($data['words'])
|| isset($data['avg_logprob'])
|| isset($data['tokens'])
|| isset($data['no_speech_prob'])
|| array_key_exists('id', $data);
if (! $hasDetail) {
return null;
}
$text = $data['text'] ?? null;
if (! is_string($text) || $text === '') {
return null;
}
$segment = [
'id' => is_numeric($data['id'] ?? null) ? (int) $data['id'] : null,
'seek' => is_numeric($data['seek'] ?? null) ? (int) $data['seek'] : null,
'start' => $this->nullableFloat($data['start'] ?? null),
'end' => $this->nullableFloat($data['end'] ?? null),
'text' => $text,
'tokens' => $this->normalizeTokens($data['tokens'] ?? null),
'temperature' => $this->nullableFloat($data['temperature'] ?? null),
'avg_logprob' => $this->nullableFloat($data['avg_logprob'] ?? null),
'compression_ratio' => $this->nullableFloat($data['compression_ratio'] ?? null),
'no_speech_prob' => $this->nullableFloat($data['no_speech_prob'] ?? null),
'words' => $this->normalizeWords($data['words'] ?? null),
];
return array_filter($segment, fn (mixed $value): bool => $value !== null && $value !== []);
}
/**
* One streamed Whisper chunk (not a finished verbose_json document).
*
* @param array<string, mixed> $data
*/
private function isLiveSegmentEvent(mixed $type, array $data): bool
{
$text = $data['text'] ?? null;
if (! is_string($text) || $text === '') {
return false;
}
if ($type === 'segment') {
return true;
}
if ($type !== null && $type !== '') {
return false;
}
if (isset($data['start']) || isset($data['end'])) {
return true;
}
return ! (isset($data['segments']) && is_array($data['segments']));
}
/**
* @return list<string>
*/
private function dataLines(string $frame): array
{
$payloads = [];
foreach (explode("\n", $frame) as $line) {
if (str_starts_with($line, 'data:')) {
$payloads[] = ltrim(substr($line, 5));
}
}
return $payloads;
}
/**
* Latest media timestamp in this event (segment/word `end`). Never file `duration`.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $whisper
*/
public function latestAudioEnd(array $data, array $whisper): ?float
{
$ends = [];
$direct = $this->nullableFloat($data['end'] ?? null);
if ($direct !== null) {
$ends[] = $direct;
}
$this->collectAudioEnds($ends, $whisper);
if (isset($data['words']) && is_array($data['words'])) {
$this->collectAudioEnds($ends, ['words' => $data['words']]);
}
return $ends === [] ? null : max($ends);
}
/**
* @param list<float> $ends
* @param array<string, mixed> $node
*/
private function collectAudioEnds(array &$ends, array $node): void
{
$end = $this->nullableFloat($node['end'] ?? null);
if ($end !== null) {
$ends[] = $end;
}
foreach (['words', 'segments'] as $key) {
if (! isset($node[$key]) || ! is_array($node[$key])) {
continue;
}
foreach ($node[$key] as $child) {
if (is_array($child)) {
$this->collectAudioEnds($ends, $child);
}
}
}
if (isset($node['segment']) && is_array($node['segment'])) {
$this->collectAudioEnds($ends, $node['segment']);
}
}
private function nullableFloat(mixed $value): ?float
{
if (! is_numeric($value)) {
return null;
}
return (float) $value;
}
/**
* @return list<int>|null
*/
private function normalizeTokens(mixed $tokens): ?array
{
if (! is_array($tokens) || $tokens === []) {
return null;
}
$normalized = [];
foreach ($tokens as $token) {
if (is_numeric($token)) {
$normalized[] = (int) $token;
}
}
return $normalized === [] ? null : $normalized;
}
/**
* @return list<array{word: string, start: ?float, end: ?float, probability: ?float}>|null
*/
private function normalizeWords(mixed $words): ?array
{
if (! is_array($words) || $words === []) {
return null;
}
$normalized = [];
foreach ($words as $word) {
if (! is_array($word)) {
continue;
}
$text = $word['word'] ?? $word['text'] ?? null;
if (! is_string($text) || $text === '') {
continue;
}
$normalized[] = array_filter([
'word' => $text,
'start' => $this->nullableFloat($word['start'] ?? null),
'end' => $this->nullableFloat($word['end'] ?? null),
'probability' => $this->nullableFloat($word['probability'] ?? $word['prob'] ?? null),
], fn (mixed $value): bool => $value !== null);
}
return $normalized === [] ? null : $normalized;
}
/**
* @return list<array{token: ?string, logprob: ?float}>
*/
private function normalizeLogprobs(array $logprobs): array
{
$normalized = [];
foreach ($logprobs as $row) {
if (! is_array($row)) {
continue;
}
$token = $row['token'] ?? $row['bytes'] ?? null;
$token = is_string($token) ? $token : null;
$logprob = $this->nullableFloat($row['logprob'] ?? $row['avg_logprob'] ?? null);
if ($token === null && $logprob === null) {
continue;
}
$normalized[] = array_filter([
'token' => $token,
'logprob' => $logprob,
], fn (mixed $value): bool => $value !== null);
}
return $normalized;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\View\Components;
use App\Services\DiskSpaceService;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class DiskSpaceBar extends Component
{
/**
* @var array{
* total_bytes: int,
* free_bytes: int,
* used_bytes: int,
* used_percent: float,
* free_percent: float,
* total_human: string,
* free_human: string,
* used_human: string
* }|null
*/
public readonly ?array $disk;
public function __construct(DiskSpaceService $diskSpace)
{
$this->disk = $diskSpace->snapshot();
}
public function shouldRender(): bool
{
return $this->disk !== null;
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.disk-space-bar');
}
}
+8
View File
@@ -1,4 +1,8 @@
{
"agents": [
"claude_code",
"cursor"
],
"cloud": false,
"guidelines": true,
"mcp": true,
@@ -7,7 +11,11 @@
"skills": [
"infer-conventions",
"ai-sdk-development",
"fortify-development",
"laravel-best-practices",
"fluxui-development",
"livewire-development",
"echo-development",
"tailwindcss-development"
]
}
+13 -1
View File
@@ -9,10 +9,22 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
// CPM/Caddy terminates TLS; trust forwarded proto/host so asset() URLs stay https.
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO
| Request::HEADER_X_FORWARDED_PREFIX,
);
$middleware->redirectGuestsTo(fn () => route('login'));
$middleware->redirectUsersTo(fn () => route('recordings.index'));
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
+2
View File
@@ -1,7 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
];
+51
View File
@@ -0,0 +1,51 @@
# z00 overlay for AndyTranscribe (behind Caddy Proxy Manager).
# Instance .env supplies PUBLIC_APP_URL, REVERB_PUBLIC_HOST, CADDY_NETWORK, CONTAINER_PREFIX.
services:
app:
networks:
- default
- caddy
environment:
APP_ENV: production
APP_DEBUG: "false"
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
TRUSTED_PROXIES: "*"
REVERB_HOST: reverb
REVERB_PORT: "8080"
REVERB_SCHEME: http
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
REVERB_PUBLIC_PORT: "443"
REVERB_PUBLIC_SCHEME: https
reverb:
networks:
- default
- caddy
environment:
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
REVERB_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
REVERB_PORT: "443"
REVERB_SCHEME: https
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
REVERB_PUBLIC_PORT: "443"
REVERB_PUBLIC_SCHEME: https
queue:
networks:
- default
environment:
APP_ENV: production
APP_DEBUG: "false"
APP_URL: ${PUBLIC_APP_URL:-https://transcribe.z00.nu}
REVERB_HOST: reverb
REVERB_PORT: "8080"
REVERB_SCHEME: http
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-reverb.transcribe.z00.nu}
REVERB_PUBLIC_PORT: "443"
REVERB_PUBLIC_SCHEME: https
whisper:
networks:
- default
networks:
caddy:
external: true
name: ${CADDY_NETWORK:-caddy-proxy-manager-test_caddy-test-network}
+7 -1
View File
@@ -9,10 +9,16 @@
"php": "^8.3",
"james-heinrich/getid3": "^1.9",
"laravel/ai": "^0.10.3",
"laravel/fortify": "^1.38",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0"
"laravel/reverb": "^1.11",
"laravel/tinker": "^3.0",
"livewire/flux": "^2.16",
"livewire/livewire": "^4.4",
"symfony/polyfill-iconv": "^1.37"
},
"require-dev": {
"albertoarena/laravel-truss": "^1.8",
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.5",
"laravel/pail": "^1.2.5",
Generated
+2657 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -28,7 +28,12 @@ return [
'transcription_timeout' => (int) env('TRANSCRIPTION_TIMEOUT', 600),
'local_whisper_model' => env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base'),
'remote_whisper_model' => env('REMOTE_WHISPER_MODEL', env('LOCAL_WHISPER_MODEL', 'Systran/faster-whisper-base')),
/*
| "curl" streams large uploads off disk (default). "http" uses Laravel's
| HTTP client and is intended for Http::fake() in tests.
*/
'local_whisper_transport' => env('LOCAL_WHISPER_TRANSPORT', 'curl'),
/*
|--------------------------------------------------------------------------
@@ -149,7 +154,7 @@ return [
'local-whisper' => [
'driver' => 'openai',
'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,
],
+82
View File
@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
+4 -3
View File
@@ -38,9 +38,10 @@ return [
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
// Wait longer under concurrent writers (queue + web on one SQLite file).
'busy_timeout' => (int) env('DB_BUSY_TIMEOUT', 30000),
'journal_mode' => 'WAL',
'synchronous' => 'NORMAL',
'transaction_mode' => 'DEFERRED',
],
+167
View File
@@ -0,0 +1,167 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/recordings',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Passkeys
|--------------------------------------------------------------------------
|
| These settings configure Fortify's passkey (WebAuthn) support. Passkeys
| allow users to sign in without needing to remember credentials since
| they use public-key cryptography - making them immune to breaches.
|
*/
'passkeys' => [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'timeout' => 60000,
],
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
],
];
+284
View File
@@ -0,0 +1,284 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts.app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
'with' => [
'js' => false,
'css' => false,
'test' => false,
],
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
// Pocket-recorder audio can be large; max is kilobytes (2 GiB).
'rules' => ['required', 'file', 'max:2097152'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
'ogg', 'oga', 'flac', 'aac', 'webm', 'aiff', 'aif',
],
'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 200, // Maximum components per batch request
],
];
+2 -1
View File
@@ -40,7 +40,8 @@ return [
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
// Must exceed TranscribeRecording::$timeout / TRANSCRIPTION_TIMEOUT (default 600).
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 660),
'after_commit' => false,
],
+120
View File
@@ -0,0 +1,120 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Browser (Echo) connection
|--------------------------------------------------------------------------
|
| Public WebSocket host/key rendered into HTML so one image can serve
| production and staging. Server-side publish still uses REVERB_HOST
| (Docker DNS "reverb" behind the z00 overlay).
|
*/
'client' => [
'key' => env('REVERB_APP_KEY'),
'host' => env('REVERB_PUBLIC_HOST', env('REVERB_HOST')),
'port' => env('REVERB_PUBLIC_PORT', env('REVERB_PORT', 443)),
'scheme' => env('REVERB_PUBLIC_SCHEME', env('REVERB_SCHEME', 'https')),
],
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];
+396
View File
@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
// Configuration for albertoarena/laravel-truss.
// This is the single source of truth for Truss's behaviour. Authorization is a
// fixed `viewTruss` gate the host app defines — the ability name is not set here.
return [
/*
|--------------------------------------------------------------------------
| Route prefix
|--------------------------------------------------------------------------
|
| URL prefix under which the index page and the JSON schema endpoint are
| registered, e.g. "truss" GET /truss and GET /truss/api/schema.
|
*/
'route_prefix' => env('TRUSS_ROUTE_PREFIX', 'truss'),
/*
|--------------------------------------------------------------------------
| Enabled
|--------------------------------------------------------------------------
|
| Global on/off switch. Defaults to enabled only in the local environment.
| Authorization is enforced separately by the fixed `viewTruss` gate.
|
*/
'enabled' => env('TRUSS_ENABLED', env('APP_ENV', 'production') === 'local'),
/*
|--------------------------------------------------------------------------
| Middleware
|--------------------------------------------------------------------------
|
| The middleware stack applied to both Truss routes. Its job is to establish
| the auth context (session, cookies, the authenticated user) so the
| `viewTruss` gate can identify who is viewing without it, the gate sees no
| user and denies everyone in non-local environments. The default `web` group
| covers session-based auth; swap it for a custom guard/Sanctum stack if your
| app authenticates differently.
|
| The fixed `viewTruss` authorization check is always appended after this and
| cannot be configured away this list controls the auth *context*, not
| whether authorization runs.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Authorization
|--------------------------------------------------------------------------
|
| Truss is gated by the fixed `viewTruss` gate (the ability name is not
| configurable). In non-local environments the shipped default gate admits
| only the emails listed here the zero-code path for "let these admins in".
| Set them via TRUSS_ALLOWED_EMAILS as a comma-separated list, e.g.
| TRUSS_ALLOWED_EMAILS="ada@example.com,grace@example.com".
|
| The list is ignored in local (the gate is not consulted there) and ignored
| entirely if the host app defines its own `viewTruss` gate (e.g. a role
| check). An empty list fails closed: no one may view in non-local until you
| either add emails here or override the gate.
|
*/
'authorization' => [
'allowed_emails' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('TRUSS_ALLOWED_EMAILS', '')),
))),
],
/*
|--------------------------------------------------------------------------
| Cache
|--------------------------------------------------------------------------
|
| The schema snapshot is derived, disposable data cached via Laravel's Cache
| facade, keyed per connection. `ttl` is in seconds.
|
*/
'cache' => [
'ttl' => (int) env('TRUSS_CACHE_TTL', 3600),
],
/*
|--------------------------------------------------------------------------
| Connections
|--------------------------------------------------------------------------
|
| Which database connections are visualizable, and any per-connection
| overrides. When left empty, Truss uses the application's default
| connection (config('database.default')).
|
| Example:
| 'mysql' => ['excluded_tables' => ['legacy_import']],
|
*/
'connections' => [
//
],
/*
|--------------------------------------------------------------------------
| Excluded tables
|--------------------------------------------------------------------------
|
| Tables hidden from the diagram by default (framework/infrastructure noise).
| Applied server-side: excluded tables never appear in the API response.
|
*/
'excluded_tables' => [
'migrations',
'password_reset_tokens',
'sessions',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
],
/*
|--------------------------------------------------------------------------
| Diagram
|--------------------------------------------------------------------------
|
| Styling options passed through to the Mermaid theme, plus the default
| column-type label mode:
| 'native' the full DB type (varchar(255), bigint unsigned) [default]
| 'laravel' a best-effort Laravel-style short label (string, integer)
| The mode is user-toggleable in the UI; this is only the default.
|
*/
'diagram' => [
'type_labels' => env('TRUSS_TYPE_LABELS', 'native'),
// Where the browser loads Mermaid from. Null (the default) self-hosts it
// from the package's own asset route — no CDN, so a strict CSP needs only
// `script-src 'self'`. Set a URL (e.g. a CDN or your own copy) to opt out
// of self-hosting: TRUSS_MERMAID_URL=https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js
'mermaid_url' => env('TRUSS_MERMAID_URL'),
// Lower bound for the automatic fit-to-screen: a large schema is never
// auto-zoomed below this (it stays legible and you pan). The "Fit" button
// ignores this and frames the whole diagram. 1.0 = 100%.
'min_zoom' => (float) env('TRUSS_MIN_ZOOM', 0.7),
],
/*
|--------------------------------------------------------------------------
| Theme
|--------------------------------------------------------------------------
|
| Truss ships a light and dark "blueprint" theme. To match the app Truss is
| embedded in, redefine its colours and fonts here. Everything is optional:
| only the knobs you set are overridden, the rest stay on the default, so a
| handful of values re-skins the whole dashboard (chrome and diagram) in both
| light and dark. Config driven, no build step.
|
| It is delivered as a same-origin stylesheet, so a strict CSP still needs
| only style-src 'self' (no inline styles). Each value is validated before it
| is emitted; an invalid value is ignored and falls back to the default.
|
| Colours accept hex, rgb()/rgba()/hsl()/hsla(), or a CSS colour keyword.
| Fonts are family names only: name a font your app already loads or a system
| font (Truss serves no font files here). Set a knob under both 'light' and
| 'dark' to theme both modes; omit 'dark' to theme light only.
|
| Colour knobs and what each paints:
| accent primary accent: headings, PK badges, entity borders, focus ring
| accent-secondary secondary accent
| background the canvas / page background (and relationship-label backdrop)
| surface panels, table bodies, rows, and inputs
| surface-alt row striping
| text body and diagram text
| muted secondary text and the relationship lines / labels
| border table, panel, and field lines
|
*/
'theme' => [
'fonts' => [
'mono' => env('TRUSS_THEME_FONT_MONO'),
'sans' => env('TRUSS_THEME_FONT_SANS'),
],
'colors' => [
'light' => [
// 'accent' => '#3730a3',
// 'background' => '#ffffff',
],
'dark' => [
// 'accent' => '#a5b4fc',
// 'background' => '#0b1020',
],
],
],
/*
|--------------------------------------------------------------------------
| Focus
|--------------------------------------------------------------------------
|
| Focus mode reduces the diagram to a table and its foreign-key neighbours.
| `default_depth` is how many hops of neighbours are shown by default.
|
*/
'focus' => [
'default_depth' => (int) env('TRUSS_FOCUS_DEPTH', 1),
],
/*
|--------------------------------------------------------------------------
| Large schema
|--------------------------------------------------------------------------
|
| Table count above which the UI shows a "large schema — use focus/filter"
| warning before rendering everything at once.
|
*/
'large_schema' => [
'warn_above' => (int) env('TRUSS_LARGE_SCHEMA_WARN_ABOVE', 60),
],
/*
|--------------------------------------------------------------------------
| Schema diff
|--------------------------------------------------------------------------
|
| "What changed since the last migration". After each migration Truss keeps
| the previous schema snapshot as a baseline and compares it against the
| current one, surfacing added, removed, and changed tables, columns, indexes,
| and foreign keys in the dashboard "Changes" panel and via `truss:diff`.
|
| This is the only feature that writes to the filesystem: the baseline is a
| structure-only JSON file (never row data), stored on disk rather than in the
| cache because it cannot be rebuilt from the live database once a migration
| has run.
|
| `enabled`: master switch. When false, no baseline is captured, nothing is
| written to disk, the "Changes" toggle is hidden, and `truss:diff` reports the
| feature is off. Set it false if you do not want Truss touching your disk.
|
| `disk`: the filesystem disk the baseline is written to, `local` by default.
| The path is always `truss/baselines/{connection}`. This deliberately does not
| follow the application's default disk: the baseline is derived tooling state,
| not application data, so it should not land in a production bucket, cost
| money, or have several instances racing on one object. A failure to read or
| write it is never fatal; the diff is simply unavailable until it recovers.
|
*/
'diff' => [
'enabled' => (bool) env('TRUSS_DIFF_ENABLED', true),
'disk' => env('TRUSS_DIFF_DISK', 'local'),
],
/*
|--------------------------------------------------------------------------
| Doctor
|--------------------------------------------------------------------------
|
| `truss:doctor` reviews the schema for problems visible from structure
| alone (a table with no primary key, an unindexed foreign key, and so on)
| and can fail CI. Structure only: it never reads row data and makes no
| network call.
|
| preset: recommended (high-confidence rules), strict (every rule), none.
| rules: per-rule overrides keyed by code: false disables, true enables
| (even a heuristic one), ['severity' => 'error'] changes severity.
| ignore: per-rule fnmatch patterns (table or table.column) to silence.
| fail_on: the severity at or above which the command exits non-zero.
| exclude: extra tables to skip, on top of truss.excluded_tables.
| dashboard: show the findings in the dashboard "Health" panel. When false,
| the schema endpoint sends no doctor payload and the panel and
| node badges never appear, leaving the CLI/CI doctor untouched.
| flag_tables: always mark tables that have findings on the diagram with a
| small severity count, even when the Health panel is closed. Set
| false to keep the diagram clean and surface findings only when
| the panel is open.
|
*/
'doctor' => [
'preset' => env('TRUSS_DOCTOR_PRESET', 'recommended'),
'rules' => [
// 'TRUSS-INT-002' => true,
// 'TRUSS-IDX-001' => ['severity' => 'error'],
],
'ignore' => [
// 'TRUSS-IDX-001' => ['audit_log.actor_id'],
],
'fail_on' => env('TRUSS_DOCTOR_FAIL_ON', 'error'),
'exclude' => [],
'dashboard' => (bool) env('TRUSS_DOCTOR_DASHBOARD', true),
'flag_tables' => (bool) env('TRUSS_DOCTOR_FLAG_TABLES', true),
],
/*
|--------------------------------------------------------------------------
| Annotations
|--------------------------------------------------------------------------
|
| Business meaning that cannot be introspected: that status = 1 means paid,
| that total_amount is integer cents, that legacy_orders is deprecated. It is
| declared here (and/or read from native schema comments) and rendered into
| the exports so a coding agent has grounding a type alone cannot give.
|
| This is still structure only: native comments are part of the CREATE TABLE
| definition, not row content, the same boundary as column defaults.
|
| source: ordered precedence for resolving an annotation; first match
| wins. 'config' reads the maps below; 'database' reads native
| table/column comments (MySQL and Postgres; SQLite and SQL Server
| have none and are skipped). Drop 'database' to ignore DB comments.
| notes: global notes rendered in a header block where the format has one.
| tables: per-table annotations, keyed by table name.
| columns: per-column annotations, keyed by "table.column".
|
| --no-annotations (and the facade withoutAnnotations()) strip them all.
|
*/
'annotations' => [
'source' => ['config', 'database'],
'notes' => [
// 'All timestamps are UTC.',
// 'Monetary columns are integer cents unless stated.',
],
'tables' => [
// 'orders' => 'One row per order, not per line item.',
],
'columns' => [
// 'orders.status' => '0 draft, 1 paid, 2 refunded',
],
],
/*
|--------------------------------------------------------------------------
| Export
|--------------------------------------------------------------------------
|
| `default_format`: the format truss:export and the facade use when none is
| given. One of dbml, json, csv, markdown, mermaid, or llm.
|
*/
'export' => [
'default_format' => env('TRUSS_EXPORT_FORMAT', 'dbml'),
],
/*
|--------------------------------------------------------------------------
| MCP server
|--------------------------------------------------------------------------
|
| The optional read-only, structure-only MCP server exposes the live schema
| to a coding agent (structure only, never row data). It requires the
| optional first-party `laravel/mcp` package: install it with
| `composer require laravel/mcp`. When that package is absent this switch has
| no effect (the server is only registered when the package is present), so a
| host that does not opt in is unaffected.
|
| `enabled`: master switch for registering the server once laravel/mcp is
| installed. Defaults on, so installing the package is the only opt-in step.
|
*/
'mcp' => [
'enabled' => (bool) env('TRUSS_MCP_ENABLED', true),
],
];
Regular → Executable
View File
View File
View File
View File
View File
View File
@@ -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,30 @@
<?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('content_hash', 64)->nullable()->after('file_size_bytes');
$table->unique('content_hash');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropUnique(['content_hash']);
$table->dropColumn('content_hash');
});
}
};
@@ -0,0 +1,42 @@
<?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('users', function (Blueprint $table) {
$table->text('two_factor_secret')
->after('password')
->nullable();
$table->text('two_factor_recovery_codes')
->after('two_factor_secret')
->nullable();
$table->timestamp('two_factor_confirmed_at')
->after('two_factor_recovery_codes')
->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};
@@ -0,0 +1,34 @@
<?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->foreignId('user_id')
->nullable()
->after('id')
->constrained()
->nullOnDelete();
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
};
@@ -0,0 +1,30 @@
<?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->unsignedInteger('transcription_duration_seconds')
->nullable()
->after('transcribed_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropColumn('transcription_duration_seconds');
});
}
};
@@ -0,0 +1,30 @@
<?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->json('transcription_verbose')
->nullable()
->after('transcript');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('recordings', function (Blueprint $table) {
$table->dropColumn('transcription_verbose');
});
}
};
+25 -5
View File
@@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -15,11 +16,30 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
$email = (string) env('SEED_USER_EMAIL', 'demo@example.com');
$name = (string) env('SEED_USER_NAME', 'Demo User');
$password = (string) env('SEED_USER_PASSWORD', 'password');
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
$user = User::query()->updateOrCreate(
['email' => $email],
[
'name' => $name,
'password' => $password,
'email_verified_at' => now(),
],
);
User::query()->updateOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => 'password',
'email_verified_at' => now(),
],
);
Recording::query()
->whereNull('user_id')
->update(['user_id' => $user->id]);
}
}
+60
View File
@@ -0,0 +1,60 @@
# Local development overlay: bind-mount source + Vite HMR.
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
# Or set in .env:
# COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml
services:
app:
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
depends_on:
vite:
condition: service_started
queue:
# Reload PHP between jobs so code mounts take effect without restarting.
command:
- php
- artisan
- queue:listen
- database
- --sleep=1
- --tries=1
- --timeout=${TRANSCRIPTION_TIMEOUT:-600}
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
reverb:
environment:
KEEP_VITE_HOT: "true"
volumes:
- .:/app
vite:
image: node:22-bookworm
container_name: andytranscribe-vite
working_dir: /app
command: >
sh -c "if [ ! -x node_modules/.bin/vite ]; then npm ci; fi;
npm run dev -- --host 0.0.0.0 --port 5173"
ports:
- "${VITE_HOST_PORT:-5173}:5173"
environment:
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
VITE_USE_POLLING: "true"
volumes:
- .:/app
- vite-node-modules:/app/node_modules
restart: unless-stopped
volumes:
vite-node-modules:
+177
View File
@@ -0,0 +1,177 @@
x-data volumes:
app-data: &app-data
- ./database:/app/database
- ./storage/app:/app/storage/app
- ./storage/logs:/app/storage/logs
x-app-env: &app-env
APP_NAME: AndyTranscribe
APP_ENV: ${APP_ENV:-local}
APP_KEY: ${APP_KEY}
APP_DEBUG: ${APP_DEBUG:-true}
APP_URL: ${APP_URL:-http://localhost:8080}
APP_PORT: ${APP_HOST_PORT:-8080}
LOG_CHANNEL: stderr
DB_CONNECTION: sqlite
DB_DATABASE: /app/database/database.sqlite
# File drivers avoid SQLite lock storms: Livewire polls + database queue +
# session/cache all writing the same sqlite file caused "database is locked".
SESSION_DRIVER: file
QUEUE_CONNECTION: database
CACHE_STORE: file
BROADCAST_CONNECTION: reverb
FILESYSTEM_DISK: local
REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe}
REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
REVERB_APP_SECRET: ${REVERB_APP_SECRET:-andytranscribe-secret}
# Server-side publish target (Docker DNS)
REVERB_HOST: reverb
REVERB_PORT: 8080
REVERB_SCHEME: http
REVERB_SERVER_HOST: 0.0.0.0
REVERB_SERVER_PORT: 8080
# Browser-facing Reverb (Blade meta). Overlay sets public host for z00.
REVERB_PUBLIC_HOST: ${REVERB_PUBLIC_HOST:-localhost}
REVERB_PUBLIC_PORT: ${REVERB_PUBLIC_PORT:-8081}
REVERB_PUBLIC_SCHEME: ${REVERB_PUBLIC_SCHEME:-http}
LOCAL_WHISPER_URL: http://whisper:8000/v1
LOCAL_WHISPER_API_KEY: ${LOCAL_WHISPER_API_KEY:-not-needed}
LOCAL_WHISPER_MODEL: ${LOCAL_WHISPER_MODEL:-Systran/faster-whisper-base}
TRANSCRIPTION_TIMEOUT: ${TRANSCRIPTION_TIMEOUT:-600}
DB_QUEUE_RETRY_AFTER: ${DB_QUEUE_RETRY_AFTER:-660}
SEED_USER_NAME: ${SEED_USER_NAME:-Demo User}
SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com}
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password}
# Local: omit APP_IMAGE (builds andytranscribe-app:latest).
# CI/prod: set APP_IMAGE=gitea.z00.nu/ben/andytranscribe:<sha> and pull.
x-app-image: &app-image
image: ${APP_IMAGE:-andytranscribe-app:latest}
services:
app:
<<: *app-image
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_NAME: ${VITE_APP_NAME:-AndyTranscribe}
VITE_REVERB_APP_KEY: ${REVERB_APP_KEY:-andytranscribe-key}
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
container_name: ${CONTAINER_PREFIX:-andytranscribe}-app
ports:
- "${APP_HOST_PORT:-8080}:80"
environment:
<<: *app-env
volumes: *app-data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1/up"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
depends_on:
whisper:
condition: service_healthy
reverb:
condition: service_started
queue:
condition: service_started
restart: unless-stopped
# Shares the app image — do not declare build: here (avoids rebuilding 3×).
# `docker compose up --build` builds `app` first, then starts these with the tagged image.
queue:
<<: *app-image
container_name: ${CONTAINER_PREFIX:-andytranscribe}-queue
command:
- php
- artisan
- queue:work
- database
- --sleep=1
- --tries=1
- --timeout=${TRANSCRIPTION_TIMEOUT:-600}
environment:
<<: *app-env
volumes: *app-data
healthcheck:
test: ["CMD-SHELL", "tr '\\0' ' ' </proc/1/cmdline | grep -q 'queue:work'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
depends_on:
whisper:
condition: service_healthy
reverb:
condition: service_started
restart: unless-stopped
reverb:
<<: *app-image
container_name: ${CONTAINER_PREFIX:-andytranscribe}-reverb
command:
- php
- artisan
- reverb:start
- --host=0.0.0.0
- --port=8080
ports:
- "${REVERB_HOST_PORT:-8081}:8080"
environment:
<<: *app-env
# Browser connects to localhost:8081; hostname used in app config for allowed hosts
REVERB_HOST: localhost
volumes: *app-data
healthcheck:
test: ["CMD-SHELL", "tr '\\0' ' ' </proc/1/cmdline | grep -q 'reverb:start'"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
restart: unless-stopped
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: ${CONTAINER_PREFIX:-andytranscribe}-whisper
ports:
# Host 8090 avoids clashing with the FrankenPHP app on 8080
- "${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
healthcheck:
test: ["CMD", "python3", "-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: ${CONTAINER_PREFIX:-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:
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
set -eu
cd /app
mkdir -p \
database \
storage/app/private/recordings \
storage/app/public \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
if [ ! -f database/database.sqlite ]; then
touch database/database.sqlite
fi
# Production images should not honor a leftover Vite HMR file from the host.
# Development bind mounts keep public/hot so the Vite container can drive assets.
if [ "${KEEP_VITE_HOT:-false}" != "true" ]; then
rm -f public/hot
fi
# Bind mounts may arrive as root-owned; keep the app and host tooling writable.
chmod -R a+rwX database storage bootstrap/cache 2>/dev/null || true
if [ -z "${APP_KEY:-}" ]; then
echo "APP_KEY is not set. Generate one with: php artisan key:generate --show" >&2
exit 1
fi
# Bind-mounted trees may lack vendor/ (image files are shadowed by the mount).
if [ ! -f vendor/autoload.php ]; then
composer install --prefer-dist --no-interaction
fi
# Only the web app should migrate/seed. Queue and Reverb share the DB and must
# not race on sqlite (locks) or re-seed on every restart/deploy.
should_bootstrap_db() {
case " $* " in
*" queue:work "*|*" queue:listen "*|*" reverb:start "*)
return 1
;;
*)
return 0
;;
esac
}
if should_bootstrap_db "$@"; then
php artisan migrate --force --no-interaction
php artisan db:seed --force --no-interaction
fi
exec "$@"
+10
View File
@@ -0,0 +1,10 @@
; PHP limits for large pocket-recorder uploads (up to 2 GB per file).
upload_max_filesize = 2G
post_max_size = 10G
memory_limit = 512M
max_execution_time = 3600
max_input_time = 3600
; Pick up bind-mounted PHP changes without restarting FrankenPHP.
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
+69
View File
@@ -6,8 +6,11 @@
"": {
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.16.1",
"concurrently": "^10.0.3",
"laravel-echo": "^2.4.0",
"laravel-vite-plugin": "^3.1",
"pusher-js": "^8.6.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
@@ -661,6 +664,33 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz",
"integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -1128,6 +1158,28 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/laravel-echo": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"pusher-js": "*",
"socket.io-client": "*"
},
"peerDependenciesMeta": {
"pusher-js": {
"optional": true
},
"socket.io-client": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
@@ -1542,6 +1594,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pusher-js": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.6.0.tgz",
"integrity": "sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -1822,6 +1884,13 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true,
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
+3
View File
@@ -8,8 +8,11 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.16.1",
"concurrently": "^10.0.3",
"laravel-echo": "^2.4.0",
"laravel-vite-plugin": "^3.1",
"pusher-js": "^8.6.0",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
+5
View File
@@ -32,5 +32,10 @@
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="REVERB_APP_KEY" value="testing-reverb-key" force="true"/>
<env name="REVERB_PUBLIC_HOST" value="reverb.testing.example" force="true"/>
<env name="REVERB_PUBLIC_PORT" value="443" force="true"/>
<env name="REVERB_PUBLIC_SCHEME" value="https" force="true"/>
<env name="LOCAL_WHISPER_TRANSPORT" value="http"/>
</php>
</phpunit>
+6 -2
View File
@@ -1,11 +1,15 @@
@import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../views';
@source '../js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
@custom-variant dark (&:where(.dark, .dark *));
+5 -1
View File
@@ -1 +1,5 @@
//
import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription';
window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex;
+43
View File
@@ -0,0 +1,43 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
function metaContent(name) {
return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content')?.trim() || '';
}
const isLocalHost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
const internalHosts = new Set(['', 'reverb', 'localhost', '127.0.0.1']);
const key = metaContent('reverb-key') || import.meta.env.VITE_REVERB_APP_KEY;
let host = metaContent('reverb-host') || import.meta.env.VITE_REVERB_HOST || '';
let port = metaContent('reverb-port') || import.meta.env.VITE_REVERB_PORT || '';
let scheme = metaContent('reverb-scheme') || import.meta.env.VITE_REVERB_SCHEME || 'https';
if (!isLocalHost && internalHosts.has(host)) {
host = `reverb.${window.location.hostname}`;
port = '443';
scheme = 'https';
} else if (isLocalHost && host === 'reverb') {
host = import.meta.env.VITE_REVERB_HOST || 'localhost';
port = import.meta.env.VITE_REVERB_PORT || '8081';
scheme = import.meta.env.VITE_REVERB_SCHEME || 'http';
}
window.Echo = new Echo({
broadcaster: 'reverb',
key,
wsHost: host,
wsPort: port || 80,
wssPort: port || 443,
forceTLS: scheme === 'https',
enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',
auth: {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
},
},
});
+627
View File
@@ -0,0 +1,627 @@
/**
* Shared helpers and Alpine components for live transcription updates via Reverb.
*/
const BADGE_COLORS = {
done: 'teal',
processing: 'amber',
pending: 'zinc',
failed: 'red',
cancelled: 'zinc',
};
export function badgeColorFor(status) {
return BADGE_COLORS[status] || 'zinc';
}
/** @deprecated Use badgeColorFor — kept for any leftover callers */
export function badgeClassFor(status) {
return badgeColorFor(status);
}
export function formatElapsed(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
if (minutes === 0) {
return remain + 's';
}
return minutes + 'm ' + String(remain).padStart(2, '0') + 's';
}
export function formatDuration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const remain = total % 60;
return minutes + ':' + String(remain).padStart(2, '0');
}
export function formatTimestamp(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const pad = (n) => String(n).padStart(2, '0');
return date.getFullYear()
+ '-' + pad(date.getMonth() + 1)
+ '-' + pad(date.getDate())
+ ' ' + pad(date.getHours())
+ ':' + pad(date.getMinutes());
}
function unwrapBroadcast(event) {
if (! event || typeof event !== 'object') {
return {};
}
if (
event.transcript_delta === undefined
&& event.transcriptDelta === undefined
&& event.whisper_delta === undefined
&& event.whisperDelta === undefined
&& event.data
&& typeof event.data === 'object'
) {
return event.data;
}
return event;
}
function subscribeToRecording({ recordingId, userId }, handler) {
if (! window.Echo) {
return () => {};
}
const channels = [window.Echo.private('recording.' + recordingId)];
if (userId) {
channels.push(window.Echo.private('user.' + userId + '.recordings'));
}
channels.forEach((channel) => {
channel.listen('.RecordingTranscriptionUpdated', handler);
});
return () => {
channels.forEach((channel) => {
if (typeof channel.stopListening === 'function') {
channel.stopListening('.RecordingTranscriptionUpdated');
}
});
};
}
function whisperSnapshot(snapshot) {
return {
language: snapshot?.language ?? null,
duration: snapshot?.duration ?? null,
segments: Array.isArray(snapshot?.segments) ? snapshot.segments.slice() : [],
logprobs: Array.isArray(snapshot?.logprobs) ? snapshot.logprobs.slice() : [],
};
}
function segmentKey(segment) {
return [segment?.start ?? '', segment?.end ?? '', segment?.text ?? ''].join('|');
}
export function formatClock(seconds) {
if (seconds == null || Number.isNaN(Number(seconds))) {
return '';
}
const value = Math.max(0, Number(seconds));
const minutes = Math.floor(value / 60);
const rest = value - minutes * 60;
return minutes + ':' + rest.toFixed(1).padStart(4, '0');
}
export function wordConfidenceClass(probability) {
if (probability == null) {
return 'text-zinc-700 dark:text-zinc-200';
}
if (probability >= 0.85) {
return 'text-teal-700 dark:text-teal-300';
}
if (probability >= 0.6) {
return 'text-amber-700 dark:text-amber-300';
}
return 'text-red-700 dark:text-red-300';
}
/**
* Live updates come from Echo. While a run is active we also hydrate from HTTP
* so segment cards keep appearing even if Livewire remorphs or an Echo frame is missed.
*/
export function transcriptionMonitor({ statusUrl, initial, userId }) {
return {
statusUrl,
userId,
status: {
...initial,
badge_color: badgeColorFor(initial.status),
},
pollError: null,
tickTimer: null,
hydrateTimer: null,
leaveChannel: null,
liveFromEcho: false,
lastDeltaStamp: null,
whisper: whisperSnapshot(initial?.whisper),
get badgeColor() {
return badgeColorFor(this.status.status);
},
get startButtonLabel() {
if (this.status.is_active) {
return 'Restart transcription';
}
return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription';
},
get whisperWords() {
return this.whisper.segments.flatMap((segment, segmentIndex) => {
if (Array.isArray(segment.words) && segment.words.length) {
return segment.words.map((word, wordIndex) => ({
word: word.word,
start: word.start,
probability: word.probability,
key: [segmentIndex, wordIndex, word.start ?? '', word.word ?? ''].join(':'),
}));
}
const text = typeof segment.text === 'string' ? segment.text.trim() : '';
if (text === '') {
return [];
}
return [{
word: text,
start: segment.start,
probability: null,
key: segmentIndex + ':text',
}];
});
},
start() {
this.leaveChannel = subscribeToRecording({
recordingId: this.status.id,
userId: this.userId,
}, (event) => {
const payload = unwrapBroadcast(event);
if (Number(payload.id) !== Number(this.status.id)) {
return;
}
this.applyPayload(payload, { fromEcho: true });
});
if (this.status.is_active) {
this.beginTick();
this.beginHydratePoll();
this.hydrateOnce();
}
},
destroy() {
this.stopTick();
this.stopHydratePoll();
if (this.leaveChannel) {
this.leaveChannel();
this.leaveChannel = null;
}
},
applyPayload(payload, { fromEcho = false } = {}) {
const wasActive = this.status.is_active;
const transcript = this.mergeTranscript(payload, fromEcho);
const nextStatus = payload.status ?? this.status.status;
this.status = {
...this.status,
...payload,
transcript,
has_transcript: Boolean(transcript),
percent: this.mergePercent(payload, nextStatus),
badge_color: badgeColorFor(nextStatus),
};
if (this.shouldResetWhisper(payload, fromEcho)) {
this.whisper = whisperSnapshot(null);
}
this.whisper = this.mergeWhisper(payload, fromEcho);
this.pollError = null;
if (this.status.is_active) {
this.beginTick();
this.beginHydratePoll();
} else {
this.stopTick();
this.stopHydratePoll();
if (wasActive) {
this.hydrateOnce();
this.refreshLivewire();
}
}
if (wasActive && ! this.status.is_active
&& ! this.status.has_transcript
&& this.status.status !== 'failed'
&& this.status.status !== 'cancelled') {
window.location.reload();
}
},
refreshLivewire() {
if (typeof this.$wire?.$refresh === 'function') {
this.$wire.$refresh();
}
},
mergeTranscript(payload, fromEcho = false) {
const delta = payload.transcript_delta || payload.transcriptDelta;
const replace = payload.transcript_replace ?? payload.transcriptReplace ?? false;
if (fromEcho && delta) {
const stamp = String(payload.percent ?? '') + ':' + delta;
if (this.lastDeltaStamp === stamp) {
return this.status.transcript;
}
this.lastDeltaStamp = stamp;
this.liveFromEcho = true;
}
if (replace) {
return delta || '';
}
if (delta) {
this.liveFromEcho = this.liveFromEcho || fromEcho;
return (this.status.transcript || '') + delta;
}
if (this.liveFromEcho && (payload.status ?? this.status.status) === 'processing') {
const incoming = typeof payload.transcript === 'string' ? payload.transcript : '';
const current = this.status.transcript || '';
if (incoming.length > current.length) {
return incoming;
}
return current;
}
if (typeof payload.transcript === 'string') {
const current = this.status.transcript || '';
if (payload.transcript.length < current.length) {
return current;
}
return payload.transcript;
}
return this.status.transcript;
},
shouldResetWhisper(payload, fromEcho = false) {
const delta = payload.whisper_delta || payload.whisperDelta;
const segment = delta?.segment;
if (
fromEcho
&& payload.status === 'processing'
&& payload.percent === 0
&& ! delta
&& ! payload.transcript_delta
&& ! payload.transcriptDelta
) {
return true;
}
if (segment && this.whisper.segments.length) {
const last = this.whisper.segments[this.whisper.segments.length - 1];
if (segment.start != null && last.start != null && Number(segment.start) + 0.05 < Number(last.start)) {
return true;
}
}
return false;
},
mergeWhisper(payload, fromEcho = false) {
const snapshot = payload.whisper;
const delta = payload.whisper_delta || payload.whisperDelta;
if (snapshot && Array.isArray(snapshot.segments) && ! delta) {
if (fromEcho) {
return this.whisper;
}
// HTTP hydrate is source of truth once it is ahead of (or equal to) local Echo state.
if (snapshot.segments.length >= this.whisper.segments.length) {
return whisperSnapshot(snapshot);
}
const next = whisperSnapshot(this.whisper);
if (! next.language && snapshot.language) {
next.language = snapshot.language;
}
if (next.duration == null && snapshot.duration != null) {
next.duration = snapshot.duration;
}
return next;
}
if (! delta) {
if (snapshot) {
const next = whisperSnapshot(this.whisper);
if (snapshot.language) {
next.language = snapshot.language;
}
if (snapshot.duration != null) {
next.duration = snapshot.duration;
}
return next;
}
return this.whisper;
}
if (fromEcho) {
this.liveFromEcho = true;
}
const next = whisperSnapshot(this.whisper);
if (delta.language) {
next.language = delta.language;
}
if (delta.duration != null) {
next.duration = delta.duration;
}
if (delta.segment) {
next.segments = this.appendSegment(next.segments, delta.segment);
}
if (Array.isArray(delta.segments) && delta.segments.length) {
if (! delta.segment) {
next.segments = delta.segments.slice();
} else {
delta.segments.forEach((row) => {
next.segments = this.appendSegment(next.segments, row);
});
}
}
if (Array.isArray(delta.logprobs) && delta.logprobs.length) {
next.logprobs = next.logprobs.concat(delta.logprobs);
}
if (Array.isArray(delta.words) && delta.words.length && next.segments.length) {
const last = { ...next.segments[next.segments.length - 1] };
last.words = (last.words || []).concat(delta.words);
next.segments = next.segments.slice(0, -1).concat([last]);
}
return next;
},
appendSegment(segments, segment) {
const key = segmentKey(segment);
if (segments.some((row) => segmentKey(row) === key)) {
return segments;
}
return segments.concat([segment]);
},
seekTo(seconds) {
const player = this.$refs.player;
if (! player || seconds == null) {
return;
}
player.currentTime = Number(seconds);
player.play().catch(() => {});
},
mergePercent(payload, status) {
if (status !== 'processing') {
return payload.percent !== undefined ? payload.percent : this.status.percent;
}
const incoming = payload.percent;
const current = Number(this.status.percent) || 0;
if (incoming == null) {
return this.status.percent;
}
return Math.max(current, Number(incoming) || 0);
},
beginTick() {
if (this.tickTimer) {
return;
}
this.tickTimer = setInterval(() => this.tickElapsed(), 1000);
},
stopTick() {
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
},
beginHydratePoll() {
if (this.hydrateTimer || ! this.statusUrl) {
return;
}
this.hydrateTimer = setInterval(() => {
if (! this.status.is_active) {
this.stopHydratePoll();
return;
}
this.hydrateOnce();
}, 1000);
},
stopHydratePoll() {
if (this.hydrateTimer) {
clearInterval(this.hydrateTimer);
this.hydrateTimer = null;
}
},
tickElapsed() {
if (! this.status.is_active || this.status.elapsed_seconds == null) {
return;
}
const next = this.status.elapsed_seconds + 1;
const duration = Number(this.status.duration_seconds) || 0;
let percent = this.status.percent;
if (this.status.status === 'processing' && duration > 0) {
const elapsedPercent = Math.min(99, Math.round((100 * next) / duration));
percent = Math.max(Number(percent) || 0, elapsedPercent);
}
this.status = {
...this.status,
elapsed_seconds: next,
elapsed_human: formatElapsed(next),
percent,
};
},
async hydrateOnce() {
if (! this.statusUrl) {
return;
}
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (! response.ok) {
throw new Error('Status request failed (' + response.status + ')');
}
this.applyPayload(await response.json());
} catch (error) {
this.pollError = error.message || 'Could not refresh progress.';
}
},
formatElapsed,
formatDuration,
formatTimestamp,
formatClock,
wordConfidenceClass,
};
}
/**
* Index-page Alpine component: inline audio player only.
* Status/progress refresh via wire:poll. Live transcript on the show page uses Echo.
*/
export function recordingsIndex() {
return {
playingId: null,
isPlaying: false,
start() {
//
},
destroy() {
const player = this.$refs.player;
if (player) {
player.pause();
player.removeAttribute('src');
player.load();
}
},
syncPlayer() {
const player = this.$refs.player;
this.isPlaying = Boolean(player && !player.paused && !player.ended);
if (player?.ended) {
this.playingId = null;
}
},
isPlayingRow(id) {
return this.playingId === id && this.isPlaying;
},
togglePlay(id, url) {
const player = this.$refs.player;
if (!player) {
return;
}
if (this.playingId === id && this.isPlaying) {
player.pause();
return;
}
if (this.playingId !== id) {
player.src = url;
this.playingId = id;
}
player.play().catch(() => {
this.playingId = null;
this.isPlaying = false;
});
},
};
}
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

@@ -0,0 +1,17 @@
@props([
'sidebar' => false,
])
@if($sidebar)
<flux:sidebar.brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand :name="config('app.name', 'Laravel')" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:brand>
@endif
@@ -0,0 +1,13 @@
<flux:dropdown x-data align="end">
<flux:button variant="subtle" square class="group" aria-label="{{ __('Preferred color scheme') }}">
<flux:icon.sun x-show="$flux.appearance === 'light'" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.moon x-show="$flux.appearance === 'dark'" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.moon x-show="$flux.appearance === 'system' && $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
<flux:icon.sun x-show="$flux.appearance === 'system' && ! $flux.dark" variant="mini" class="text-zinc-500 dark:text-white" />
</flux:button>
<flux:menu>
<flux:menu.item icon="sun" x-on:click="$flux.appearance = 'light'">{{ __('Light') }}</flux:menu.item>
<flux:menu.item icon="moon" x-on:click="$flux.appearance = 'dark'">{{ __('Dark') }}</flux:menu.item>
<flux:menu.item icon="computer-desktop" x-on:click="$flux.appearance = 'system'">{{ __('System') }}</flux:menu.item>
</flux:menu>
</flux:dropdown>
@@ -0,0 +1,9 @@
@props([
'title',
'description',
])
<div class="flex w-full flex-col text-center">
<flux:heading size="xl">{{ $title }}</flux:heading>
<flux:subheading>{{ $description }}</flux:subheading>
</div>
@@ -0,0 +1,9 @@
@props([
'status',
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
@endif
@@ -0,0 +1,31 @@
@auth
<flux:dropdown position="bottom" align="end">
<flux:button variant="ghost" class="max-lg:hidden" data-test="user-menu-button">
{{ auth()->user()->name }}
</flux:button>
<flux:button variant="ghost" class="lg:hidden" icon="user" data-test="user-menu-button-mobile" />
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar :name="auth()->user()->name" :initials="auth()->user()->initials()" />
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
@endauth
@@ -0,0 +1,33 @@
@php
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
$usedPercent = min(100, max(0, (float) $disk['used_percent']));
$usedPercentLabel = rtrim(rtrim(number_format($usedPercent, 1, '.', ''), '0'), '.') ?: '0';
$freePercent = (float) ($disk['free_percent'] ?? 100);
// Inline colors so the fill is visible even before / without a Tailwind rebuild.
$fillColor = match (true) {
$freePercent <= 5 => '#dc2626',
$freePercent <= 15 => '#f59e0b',
default => '#0d9488',
};
@endphp
<div
class="flex min-w-36 flex-col gap-1 rounded-lg border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $usedPercentLabel }}% used"
>
<div
class="h-2 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ (int) round($usedPercent) }}"
aria-label="Disk space {{ $usedPercentLabel }}% used"
>
<div
class="h-full rounded-full transition-[width] duration-300"
style="width: {{ $usedPercent }}%; background-color: {{ $fillColor }};"
></div>
</div>
<span class="text-xs font-medium tabular-nums text-zinc-600 dark:text-zinc-300">
{{ $usedPercentLabel }}% used · {{ $disk['free_human'] }} free
</span>
</div>
@@ -0,0 +1,62 @@
@props([
'status',
'label' => null,
/** @var string|null Alpine expression that returns a row object with badge_color + status_label (+ status for spinner) */
'alpineRow' => null,
])
@php
$label ??= match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$color = match ($status) {
'done' => 'teal',
'processing' => 'amber',
'pending' => 'zinc',
'failed' => 'red',
default => 'zinc',
};
$icon = $status === 'processing' ? 'loading' : null;
@endphp
@if ($alpineRow)
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
@if ($badgeColor === $color)
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@else
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-cloak
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@endif
@endforeach
<flux:icon.loading
variant="micro"
class="size-3 text-amber-600 dark:text-amber-400"
x-show="{{ $alpineRow }}.status === 'processing'"
x-cloak
/>
</span>
@else
<span {{ $attributes->class('inline-flex items-center gap-1.5') }}>
<flux:badge size="sm" :color="$color" :icon="$icon">
{{ $label }}
</flux:badge>
</span>
@endif
+68 -29
View File
@@ -1,50 +1,89 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'Recordings') {{ config('app.name', 'AndyTranscribe') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@include('partials.head', ['title' => $title ?? null])
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased">
<header class="border-b border-stone-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800">
AndyTranscribe
</a>
<nav class="flex items-center gap-3 text-sm">
<a href="{{ route('recordings.index') }}" class="text-stone-600 hover:text-stone-900">Recordings</a>
<a href="{{ route('recordings.create') }}" class="rounded bg-teal-700 px-3 py-1.5 font-medium text-white hover:bg-teal-800">
Upload
</a>
</nav>
</div>
</header>
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate class="max-lg:hidden" />
<flux:navbar class="-mb-px max-lg:hidden">
<flux:navbar.item
:href="route('recordings.index')"
:current="request()->routeIs('recordings.index', 'recordings.show')"
wire:navigate
>
{{ __('Recordings') }}
</flux:navbar.item>
<flux:navbar.item
:href="route('recordings.create')"
:current="request()->routeIs('recordings.create')"
wire:navigate
>
{{ __('Upload') }}
</flux:navbar.item>
</flux:navbar>
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</flux:header>
<flux:sidebar collapsible="mobile" sticky class="lg:hidden">
<flux:sidebar.header>
<flux:sidebar.brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
<flux:sidebar.collapse />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')" wire:navigate>
{{ __('Recordings') }}
</flux:sidebar.item>
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')" wire:navigate>
{{ __('Upload') }}
</flux:sidebar.item>
</flux:sidebar.nav>
</flux:sidebar>
<flux:main container>
@if (session('success'))
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900">
{{ session('success') }}
</div>
<flux:callout variant="success" icon="check-circle" class="mb-6">
<flux:callout.text>{{ session('success') }}</flux:callout.text>
</flux:callout>
@endif
@if (session('error'))
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
{{ session('error') }}
</div>
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>{{ session('error') }}</flux:callout.text>
</flux:callout>
@endif
@if ($errors->any())
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
<flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>
<ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
</flux:callout.text>
</flux:callout>
@endif
@yield('content')
</main>
{{ $slot }}
</flux:main>
<flux:toast />
@fluxScripts
</body>
</html>
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:brand href="{{ route('recordings.index') }}" name="AndyTranscribe" wire:navigate />
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</flux:header>
<flux:main container>
{{ $slot }}
</flux:main>
@fluxScripts
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-zinc-50 antialiased dark:bg-zinc-900">
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6">
<flux:brand
class="justify-center"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ route('home') }}"
wire:navigate
/>
<div class="flex flex-col gap-6">
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-800 shadow-xs dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">
<div class="px-10 py-8">{{ $slot }}</div>
</div>
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-white text-zinc-800 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="absolute end-4 top-4">
<x-appearance-toggle />
</div>
<div class="flex w-full max-w-sm flex-col gap-6">
<flux:brand
class="justify-center"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ url('/') }}"
wire:navigate
/>
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
</div>
</div>
@fluxScripts
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More