28 Commits
Author SHA1 Message Date
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
89 changed files with 7215 additions and 1479 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
+8 -6
View File
@@ -10,12 +10,13 @@ node_modules
vendor
public/build
public/hot
storage/app/private/**
storage/app/public/**
storage/logs/**
storage/framework/cache/**
storage/framework/sessions/**
storage/framework/views/**
# 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.*
@@ -28,5 +29,6 @@ npm-debug.log
yarn-error.log
tests
docs
todo.txt
*.md
!README.md
+22 -15
View File
@@ -3,6 +3,8 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
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,7 +29,7 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@@ -37,7 +39,7 @@ BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_STORE=file
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
@@ -64,7 +66,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Laravel Reverb (WebSockets). Browser uses VITE_*; server publish uses REVERB_HOST.
# 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
@@ -73,11 +75,25 @@ 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="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
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
@@ -87,15 +103,6 @@ TRANSCRIPTION_TIMEOUT=600
# Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run
DB_QUEUE_RETRY_AFTER=660
# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper)
APP_HOST_PORT=8080
REVERB_HOST_PORT=8081
WHISPER_HOST_PORT=8090
# Browser-facing Reverb host/port (used at Vite build time in Docker)
VITE_REVERB_HOST=localhost
VITE_REVERB_SCHEME=http
# Demo user created on every container start (db:seed via entrypoint)
SEED_USER_NAME="Demo User"
SEED_USER_EMAIL=demo@example.com
+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
+35 -10
View File
@@ -1,10 +1,14 @@
# 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 \
@@ -12,15 +16,29 @@ RUN composer install \
--prefer-dist \
--no-interaction
FROM node:22-bookworm AS assets
# ---------------------------------------------------------------------------
# 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
COPY --from=vendor /app/vendor ./vendor
COPY composer.json composer.lock ./
# ---------------------------------------------------------------------------
# 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
@@ -39,8 +57,12 @@ ENV VITE_APP_NAME=$VITE_APP_NAME \
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 \
@@ -50,20 +72,25 @@ RUN install-php-extensions \
intl \
opcache
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
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
RUN composer dump-autoload --optimize --no-dev \
&& mkdir -p \
# 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 \
@@ -72,11 +99,9 @@ RUN composer dump-autoload --optimize --no-dev \
storage/logs \
database \
bootstrap/cache \
&& composer dump-autoload --optimize --no-dev \
&& chown -R www-data:www-data storage bootstrap/cache database
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+84 -13
View File
@@ -76,6 +76,8 @@ sed -i "s|^APP_KEY=.*|APP_KEY=${KEY}|" .env
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
@@ -87,14 +89,14 @@ Whisper may take a minute or two while the model downloads.
### Demo login
Every container start runs `db:seed`, which ensures this user exists:
Every container start runs `db:seed`, which ensures these users exist:
| Field | Default |
| Email | Password |
| --- | --- |
| Email | `demo@example.com` |
| Password | `password` |
| `demo@example.com` | `password` |
| `admin@example.com` | `password` |
Override with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
Override the demo user with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`.
### 5. Open the app
@@ -123,6 +125,14 @@ docker compose up -d
Data under `./database`, `./storage/app`, and `./storage/logs` is kept on the host.
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):
@@ -144,6 +154,52 @@ Then a normal `docker compose up -d` enables:
- `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 |
@@ -171,21 +227,22 @@ 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) | `600` |
| `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 to Reverb on `localhost:8081`.
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, rebuild so Vite embeds the new values:
```bash
docker compose up --build -d
```
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)
@@ -197,7 +254,7 @@ Use the GPU Whisper service instead of the CPU `whisper` service when you have a
## Usage
1. Open the app and **Log in** with the demo user (`demo@example.com` / `password`), or **Register** a new account.
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.
@@ -246,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
+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;
}
}
+94 -3
View File
@@ -14,9 +14,21 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
* Reverb's default max payload is 10KB. Keep streamed text well under that.
*/
public function __construct(public Recording $recording) {}
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.
@@ -27,6 +39,7 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
{
return [
new PrivateChannel('recording.'.$this->recording->id),
new PrivateChannel('user.'.$this->recording->user_id.'.recordings'),
];
}
@@ -40,11 +53,89 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow
*/
public function broadcastWith(): array
{
return array_merge(
$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,26 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class CancelTranscriptionController extends Controller
{
/**
* Stop an in-progress or queued transcription.
*/
public function __invoke(Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
if (! $recording->isTranscribing()) {
return back()->with('error', 'No transcription is currently running.');
}
$recording->cancelTranscription();
return back()->with('success', 'Transcription stopped.');
}
}
@@ -1,223 +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\Http\UploadedFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class RecordingController extends Controller
{
/**
* Display a listing of recordings.
*/
public function index(Request $request): View
{
$user = $request->user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$query = $user->recordings()->latest();
if ($search = $request->string('q')->trim()->toString()) {
$query->search($search);
}
$recordings = $query->paginate(20)->withQueryString();
$pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
return view('recordings.index', [
'recordings' => $recordings,
'search' => $search ?? '',
'pendingCount' => $pendingCount,
]);
}
/**
* Show the upload form.
*/
public function create(Request $request): View
{
$existingFingerprints = $request->user()->recordings()
->get(['original_filename', 'file_size_bytes'])
->map(fn (Recording $recording) => $this->uploadFingerprint(
$recording->original_filename,
(int) $recording->file_size_bytes,
))
->unique()
->values()
->all();
return view('recordings.create', [
'existingFingerprints' => $existingFingerprints,
]);
}
/**
* Store one or more uploaded recordings.
*/
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
{
/** @var list<UploadedFile> $files */
$files = array_values(array_filter(
$request->file('audio', []),
fn ($file) => $file instanceof UploadedFile,
));
$user = $request->user();
$titleOverride = $request->string('title')->trim()->toString();
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
$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 && $titleOverride !== ''
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($request, $file, $metadata, $hash, $title);
}
if ($recordings === [] && $skippedDuplicates > 0) {
return redirect()
->route('recordings.create')
->with('error', $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.");
}
if ($recordings === []) {
return redirect()
->route('recordings.create')
->with('error', 'No valid audio files were uploaded.');
}
$message = count($recordings) === 1
? 'Recording uploaded — transcription queued.'
: count($recordings).' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
if (count($recordings) === 1) {
return redirect()
->route('recordings.show', $recordings[0])
->with('success', $message);
}
return redirect()
->route('recordings.index')
->with('success', $message);
}
/**
* Display the specified recording.
*/
public function show(Recording $recording): View
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
return view('recordings.show', compact('recording'));
}
/**
* Remove the specified recording.
*/
public function destroy(Recording $recording): RedirectResponse
{
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
return redirect()
->route('recordings.index')
->with('success', 'Recording deleted.');
}
/**
* Persist a single uploaded audio file as a recording.
*/
private function storeUploadedRecording(
Request $request,
UploadedFile $file,
Mp3MetadataService $metadata,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::create([
'user_id' => $request->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();
}
/**
* Client-side fingerprint for name + size duplicate checks before upload.
*/
private function uploadFingerprint(string $filename, int $sizeBytes): string
{
return strtolower($filename).':'.$sizeBytes;
}
}
@@ -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,25 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TranscribeRecordingRequest;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class TranscribeController extends Controller
{
/**
* Queue local faster-whisper transcription for the recording.
*
* Always allowed: stops any current run first, then starts a new one.
*/
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
$recording->queueLocalTranscription();
return back()->with('success', 'Transcription started. Progress updates below.');
}
}
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TranscribePendingController extends Controller
{
/**
* Queue local transcription for recordings that still need a transcript.
*/
public function __invoke(Request $request): RedirectResponse
{
$queued = 0;
$request->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) {
return back()->with('error', 'No recordings need transcription right now.');
}
return back()->with(
'success',
$queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
);
}
}
@@ -1,23 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TranscribeRecordingRequest extends FormRequest
{
public function authorize(): bool
{
$recording = $this->route('recording');
return $recording !== null && $this->user()?->can('transcribe', $recording) === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}
+18 -32
View File
@@ -6,9 +6,11 @@ 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;
@@ -20,6 +22,9 @@ class TranscribeRecording implements ShouldQueue
/**
* 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;
@@ -62,18 +67,19 @@ class TranscribeRecording implements ShouldQueue
'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();
$this->reportIfOwned('Preparing audio file…', 15);
try {
$text = $transcription->transcribe(
$this->recording,
function (string $message, int $percent): void {
$this->reportIfOwned($message, $percent);
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,
);
if (! $this->claimSuccessfulTranscript($text)) {
@@ -129,21 +135,7 @@ class TranscribeRecording implements ShouldQueue
$this->recording->refresh();
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
$this->reportIfOwned('Saving transcript…', 90);
$this->recording->refresh();
}
if ($this->recording->ownsTranscriptionRun($this->runStartedAt)) {
$this->recording->forceFill([
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
$this->recording->markTranscriptionComplete($text);
return true;
}
@@ -152,22 +144,16 @@ class TranscribeRecording implements ShouldQueue
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, '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->forceFill([
'transcript' => $text,
'transcription_status' => 'done',
'transcription_progress' => 'Transcription complete',
'transcription_percent' => 100,
'transcription_error' => null,
'transcribed_at' => now(),
])->save();
$this->recording->broadcastTranscriptionUpdated();
$this->recording->markTranscriptionComplete($text);
return true;
}
@@ -180,12 +166,12 @@ class TranscribeRecording implements ShouldQueue
return false;
}
private function reportIfOwned(string $message, int $percent): void
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);
$this->recording->reportProgress($message, $percent, partialTranscript: $partialTranscript, whisperDelta: $whisper);
}
}
+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');
}
}
+323 -50
View File
@@ -12,6 +12,7 @@ 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;
@@ -33,6 +34,7 @@ class Recording extends Model
'file_size_bytes',
'content_hash',
'transcript',
'transcription_verbose',
'transcription_status',
'transcription_progress',
'transcription_percent',
@@ -41,6 +43,7 @@ class Recording extends Model
'transcription_driver',
'ollama_url',
'transcribed_at',
'transcription_duration_seconds',
];
/**
@@ -53,8 +56,10 @@ class Recording extends Model
'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',
];
}
@@ -135,15 +140,16 @@ class Recording extends Model
'ollama_url' => null,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => 5,
'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();
RecordingTranscriptionUpdated::dispatch($recording);
$this->broadcastTranscriptionUpdated();
TranscribeRecording::dispatch($recording);
}
@@ -162,6 +168,18 @@ class Recording extends Model
};
}
/**
* 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.
*/
@@ -179,6 +197,28 @@ class Recording extends Model
});
}
/**
* 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.
*/
@@ -206,33 +246,40 @@ class Recording extends Model
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')
->pluck('payload')
->contains(function (string $payload): bool {
if (! str_contains($payload, TranscribeRecording::class)) {
->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;
}
$data = json_decode($payload, true);
$command = $data['data']['command'] ?? null;
if (! is_string($command)) {
if ($job->reserved_at !== null && (int) $job->reserved_at <= $staleBefore) {
return false;
}
try {
$job = unserialize($command);
} catch (Throwable) {
return $this->payloadMentionsRecording($payload);
}
return $job instanceof TranscribeRecording
&& (int) $job->recording->getKey() === (int) $this->id;
return $this->jobPayloadBelongsToRecording($payload);
});
}
@@ -241,8 +288,11 @@ class Recording extends Model
*/
private function payloadMentionsRecording(string $payload): bool
{
return (bool) preg_match('/id";i:'.$this->id.';/', $payload)
|| str_contains($payload, 'id";s:'.strlen((string) $this->id).':"'.$this->id.'"');
// 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.'\\"');
}
/**
@@ -310,34 +360,16 @@ class Recording extends Model
->each(function (object $job) use (&$deleted): void {
$payload = (string) $job->payload;
if (! str_contains($payload, TranscribeRecording::class)) {
if (! str_contains($payload, 'TranscribeRecording')) {
return;
}
$data = json_decode($payload, true);
$command = $data['data']['command'] ?? null;
if (! is_string($command)) {
return;
}
try {
$queued = unserialize($command);
} catch (Throwable) {
if (! preg_match('/id";i:'.$this->id.';/', $payload)) {
if (! $this->jobPayloadBelongsToRecording($payload)) {
return;
}
DB::table('jobs')->where('id', $job->id)->delete();
$deleted++;
return;
}
if ($queued instanceof TranscribeRecording && (int) $queued->recording->getKey() === (int) $this->id) {
DB::table('jobs')->where('id', $job->id)->delete();
$deleted++;
}
});
$this->releaseTranscriptionUniqueLock();
@@ -345,6 +377,36 @@ class Recording extends Model
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.
*
@@ -365,7 +427,7 @@ class Recording extends Model
'transcription_error' => 'Stopped by user',
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
$this->broadcastTranscriptionUpdated();
}
/**
@@ -378,6 +440,30 @@ class Recording extends Model
)->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.
*/
@@ -394,11 +480,11 @@ class Recording extends Model
'transcription_error' => $message,
])->save();
RecordingTranscriptionUpdated::dispatch($this->fresh());
$this->broadcastTranscriptionUpdated();
}
/**
* Recover a stuck transcription if the queue job is gone.
* Recover a stuck transcription if the queue job is gone or a reservation is stale.
*/
public function recoverOrphanedTranscription(): bool
{
@@ -406,8 +492,11 @@ class Recording extends Model
return false;
}
// Drop abandoned reserved rows so a restart can enqueue cleanly.
$this->discardQueuedTranscriptionJobs();
$this->markTranscriptionFailed(
'Transcription worker stopped before finishing. Start transcription again.',
'Transcription timed out or the worker stopped before finishing. Start transcription again.',
);
return true;
@@ -415,25 +504,184 @@ class Recording extends Model
/**
* 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'): void
public function reportProgress(string $message, int $percent, string $status = 'processing', ?string $partialTranscript = null, ?array $whisperDelta = null): void
{
$this->forceFill([
$diff = $this->transcriptBroadcastDiff($partialTranscript);
$attributes = [
'transcription_status' => $status,
'transcription_progress' => $message,
'transcription_percent' => max(0, min(100, $percent)),
'transcription_error' => null,
])->save();
];
RecordingTranscriptionUpdated::dispatch($this->fresh());
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(): void
public function broadcastTranscriptionUpdated(?string $transcriptDelta = null, bool $transcriptReplace = false, ?array $whisperDelta = null): void
{
RecordingTranscriptionUpdated::dispatch($this->fresh() ?? $this);
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'] ?? '',
]);
}
/**
@@ -444,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.
*/
@@ -462,7 +730,7 @@ class Recording extends Model
public function transcriptionStatusPayload(): array
{
$startedAt = $this->transcription_started_at;
$elapsed = $startedAt ? (int) round($startedAt->diffInSeconds(now())) : null;
$elapsed = $this->transcriptionElapsedSeconds();
return [
'id' => $this->id,
@@ -477,9 +745,14 @@ class Recording extends Model
'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(),
];
}
+229 -7
View File
@@ -4,29 +4,251 @@ namespace App\Services;
use App\Models\Recording;
use Closure;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Transcription;
use RuntimeException;
class TranscriptionService
{
public function __construct(private WhisperTranscriptionStream $stream) {}
/**
* Transcribe a recording with the local faster-whisper server.
*
* @param (Closure(string, int): void)|null $onProgress
* @param (Closure(string, int, ?string, ?array): void)|null $onProgress
* @param (Closure(): bool)|null $shouldContinue
*/
public function transcribe(Recording $recording, ?Closure $onProgress = null): string
public function transcribe(Recording $recording, ?Closure $onProgress = null, ?Closure $shouldContinue = null): string
{
$report = $onProgress ?? static fn (string $message, int $percent) => null;
$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('Connecting to local faster-whisper server…', 30);
$report("Transcribing locally with {$model} (audio stays on this machine)…", 50);
$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);
$report('Received transcript from local Whisper…', 85);
} else {
$transcript = $this->transcribeViaLocalWhisper($recording, $report, $model, $shouldContinue);
}
return (string) $transcript;
}
/**
* 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 transcribeViaLocalWhisper(Recording $recording, Closure $report, string $model, ?Closure $shouldContinue): string
{
$path = $recording->absolutePath();
if (! is_readable($path)) {
throw new RuntimeException('Recording audio file is not readable.');
}
$message = "Transcribing locally with {$model} (audio stays on this machine)…";
$filename = $recording->original_filename ?: basename($path);
$response = $this->localWhisperRequest($filename, $path)
->withOptions(['stream' => true])
->post('audio/transcriptions', [
'model' => $model,
'response_format' => 'verbose_json',
'stream' => 'true',
'without_timestamps' => 'false',
'timestamp_granularities[]' => 'word',
]);
if (! $response->successful()) {
throw new RuntimeException(
'Local transcription failed (HTTP '.$response->status().'): '.$response->body()
);
}
$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,
]);
return $this->extractTranscriptText($response, $report, $message);
}
return $this->consumeWhisperStream($response, $report, $message, $recording, $shouldContinue);
}
/**
* @param Closure(string, int, ?string, ?array): void $report
* @param (Closure(): bool)|null $shouldContinue
*/
private function consumeWhisperStream(
Response $response,
Closure $report,
string $message,
Recording $recording,
?Closure $shouldContinue,
): string {
$body = $response->toPsrResponse()->getBody();
$buffer = '';
$accumulated = '';
$lastPercent = 0;
$idleReads = 0;
try {
while (! $body->eof()) {
if ($shouldContinue !== null && ! $shouldContinue()) {
$body->close();
break;
}
$chunk = $body->read(8192);
if ($chunk === '') {
$idleReads++;
if ($idleReads >= 40) {
break;
}
usleep(50_000);
continue;
}
$idleReads = 0;
$buffer .= $chunk;
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,
);
}
} finally {
$response->close();
}
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 Closure(string, int, ?string, ?array): void $report
*/
private function extractTranscriptText(Response $response, ?Closure $report = null, string $message = ''): string
{
$json = $response->json();
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('Local transcription returned an empty transcript.');
}
$whisper = $this->stream->extractWhisperMeta($json);
if ($report !== null && $whisper !== []) {
$report($message, 99, $text, $whisper);
}
return $text;
}
}
+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;
}
}
-18
View File
@@ -33,24 +33,6 @@ class DiskSpaceBar extends Component
return $this->disk !== null;
}
/**
* Progress fill color based on remaining free space.
*/
public function barColor(): string
{
$freePercent = $this->disk['free_percent'] ?? 100;
if ($freePercent <= 5) {
return 'bg-red-600';
}
if ($freePercent <= 15) {
return 'bg-amber-500';
}
return 'bg-teal-600';
}
/**
* Get the view / contents that represent the component.
*/
+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"
]
}
+10
View File
@@ -13,6 +13,16 @@ return Application::configure(basePath: dirname(__DIR__))
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'));
})
+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}
+1
View File
@@ -18,6 +18,7 @@
"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
+143 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d2a98af0564164e3228a1b7712e36e87",
"content-hash": "eb8ccdae5381401eaa99d07ff82ce500",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -8941,6 +8941,87 @@
}
],
"packages-dev": [
{
"name": "albertoarena/laravel-truss",
"version": "v1.8.3",
"source": {
"type": "git",
"url": "https://github.com/albertoarena/laravel-truss.git",
"reference": "d8750756f5f239066c7f0d6e40b0eeb7e7e435bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/albertoarena/laravel-truss/zipball/d8750756f5f239066c7f0d6e40b0eeb7e7e435bf",
"reference": "d8750756f5f239066c7f0d6e40b0eeb7e7e435bf",
"shasum": ""
},
"require": {
"illuminate/contracts": "^12.0 || ^13.0",
"illuminate/support": "^12.0 || ^13.0",
"php": "^8.3",
"spatie/laravel-package-tools": "^1.16"
},
"require-dev": {
"laravel/mcp": "^0.9",
"laravel/pint": "^1.18",
"orchestra/testbench": "^10.0 || ^11.0",
"pestphp/pest": "^3.5"
},
"suggest": {
"laravel/mcp": "Enables the optional read-only, structure-only Truss MCP server (Truss as AI context). Opt in with `composer require laravel/mcp`. Needs Laravel >= 12.41.1 or 13."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"AlbertoArena\\Truss\\TrussServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"AlbertoArena\\Truss\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alberto Arena",
"email": "arena.alberto@gmail.com",
"role": "Developer"
}
],
"description": "A live database structure viewer for Laravel. Renders your schema (never data) as a scrollable, zoomable ER diagram.",
"homepage": "https://trussphp.com",
"keywords": [
"DBML",
"data-dictionary",
"database",
"database-diagram",
"er-diagram",
"erd",
"introspection",
"laravel",
"mermaid",
"migrations",
"schema"
],
"support": {
"docs": "https://trussphp.com",
"issues": "https://github.com/albertoarena/laravel-truss/issues",
"source": "https://github.com/albertoarena/laravel-truss"
},
"funding": [
{
"url": "https://ko-fi.com/albertoarena",
"type": "ko-fi"
}
],
"time": "2026-08-12T14:11:27+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
@@ -11403,6 +11484,67 @@
],
"time": "2025-02-07T05:00:38+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git",
"reference": "d5552849801f2642aea710557463234b59ef65eb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
"reference": "d5552849801f2642aea710557463234b59ef65eb",
"shasum": ""
},
"require": {
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.5",
"orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
"pestphp/pest": "^2.1|^3.1|^4.0",
"phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
"phpunit/phpunit": "^10.5|^11.5|^12.5",
"spatie/pest-plugin-test-time": "^2.2|^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\LaravelPackageTools\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Tools for creating Laravel packages",
"homepage": "https://github.com/spatie/laravel-package-tools",
"keywords": [
"laravel-package-tools",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues",
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-05-19T14:06:37+00:00"
},
{
"name": "staabm/side-effects-detector",
"version": "1.0.5",
+2 -1
View File
@@ -38,7 +38,8 @@ return [
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => 5000,
// 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',
+5 -3
View File
@@ -44,7 +44,7 @@ return [
|
*/
'component_layout' => 'layouts::app.header',
'component_layout' => 'layouts.app',
/*
|---------------------------------------------------------------------------
@@ -130,15 +130,17 @@ return [
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
// 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' => 5, // Max duration (in minutes) before an upload is invalidated...
'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
+18
View File
@@ -15,6 +15,24 @@ return [
'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
+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),
],
];
@@ -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');
});
}
};
+9
View File
@@ -29,6 +29,15 @@ class DatabaseSeeder extends Seeder
],
);
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]);
+3 -1
View File
@@ -39,7 +39,9 @@ services:
image: node:22-bookworm
container_name: andytranscribe-vite
working_dir: /app
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 5173"
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:
+23 -28
View File
@@ -14,9 +14,11 @@ x-app-env: &app-env
LOG_CHANNEL: stderr
DB_CONNECTION: sqlite
DB_DATABASE: /app/database/database.sqlite
SESSION_DRIVER: database
# 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: database
CACHE_STORE: file
BROADCAST_CONNECTION: reverb
FILESYSTEM_DISK: local
REVERB_APP_ID: ${REVERB_APP_ID:-andytranscribe}
@@ -28,6 +30,10 @@ x-app-env: &app-env
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}
@@ -37,8 +43,14 @@ x-app-env: &app-env
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
@@ -48,8 +60,7 @@ services:
VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost}
VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081}
VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http}
image: andytranscribe-app:latest
container_name: andytranscribe-app
container_name: ${CONTAINER_PREFIX:-andytranscribe}-app
ports:
- "${APP_HOST_PORT:-8080}:80"
environment:
@@ -70,18 +81,11 @@ services:
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:
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}
image: andytranscribe-app:latest
container_name: andytranscribe-queue
<<: *app-image
container_name: ${CONTAINER_PREFIX:-andytranscribe}-queue
command:
- php
- artisan
@@ -107,17 +111,8 @@ services:
restart: unless-stopped
reverb:
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}
image: andytranscribe-app:latest
container_name: andytranscribe-reverb
<<: *app-image
container_name: ${CONTAINER_PREFIX:-andytranscribe}-reverb
command:
- php
- artisan
@@ -141,7 +136,7 @@ services:
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: andytranscribe-whisper
container_name: ${CONTAINER_PREFIX:-andytranscribe}-whisper
ports:
# Host 8090 avoids clashing with the FrankenPHP app on 8080
- "${WHISPER_HOST_PORT:-8090}:8000"
@@ -162,7 +157,7 @@ services:
whisper-gpu:
profiles: ["gpu"]
image: fedirz/faster-whisper-server:latest-cuda
container_name: andytranscribe-whisper-gpu
container_name: ${CONTAINER_PREFIX:-andytranscribe}-whisper-gpu
ports:
- "${WHISPER_HOST_PORT:-8090}:8000"
volumes:
+15
View File
@@ -36,7 +36,22 @@ 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 "$@"
+4
View File
@@ -32,5 +32,9 @@
<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"/>
</php>
</phpunit>
+2 -5
View File
@@ -8,11 +8,8 @@
@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';
--color-accent: var(--color-teal-700);
--color-accent-content: var(--color-teal-700);
--color-accent-foreground: var(--color-white);
--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 *));
-2
View File
@@ -1,7 +1,5 @@
import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription';
import { uploadDropzone } from './upload';
window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex;
window.uploadDropzone = uploadDropzone;
+28 -5
View File
@@ -3,13 +3,36 @@ 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: 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',
key,
wsHost: host,
wsPort: port || 80,
wssPort: port || 443,
forceTLS: scheme === 'https',
enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',
auth: {
+441 -76
View File
@@ -2,16 +2,21 @@
* Shared helpers and Alpine components for live transcription updates via Reverb.
*/
const BADGE_CLASSES = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
failed: 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
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 BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30';
return badgeColorFor(status);
}
export function formatElapsed(seconds) {
@@ -50,48 +55,112 @@ export function formatTimestamp(value) {
+ ':' + pad(date.getMinutes());
}
function formatWordCount(count) {
const n = Number(count) || 0;
return n > 0 ? n.toLocaleString() : '—';
function unwrapBroadcast(event) {
if (! event || typeof event !== 'object') {
return {};
}
function subscribeToRecording(recordingId, handler) {
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 channelName = 'recording.' + recordingId;
const channel = window.Echo.private(channelName);
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 () => {
window.Echo.leave(channelName);
channels.forEach((channel) => {
if (typeof channel.stopListening === 'function') {
channel.stopListening('.RecordingTranscriptionUpdated');
}
});
};
}
function subscribeToRecordings(recordingIds, handler) {
const leaveFns = recordingIds.map((id) => subscribeToRecording(id, handler));
return () => {
leaveFns.forEach((leave) => leave());
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';
}
/**
* Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate.
* 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 }) {
export function transcriptionMonitor({ statusUrl, initial, userId }) {
return {
statusUrl,
status: initial,
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 badgeClass() {
return badgeClassFor(this.status.status);
get badgeColor() {
return badgeColorFor(this.status.status);
},
get startButtonLabel() {
@@ -102,23 +171,56 @@ export function transcriptionMonitor({ statusUrl, initial }) {
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(this.status.id, (event) => {
if (Number(event.id) !== Number(this.status.id)) {
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(event);
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();
@@ -126,15 +228,38 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
},
applyPayload(payload) {
applyPayload(payload, { fromEcho = false } = {}) {
const wasActive = this.status.is_active;
this.status = { ...this.status, ...payload };
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
@@ -145,6 +270,209 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
},
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;
@@ -160,13 +488,50 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
},
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;
}
this.status.elapsed_seconds += 1;
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds);
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() {
@@ -177,6 +542,7 @@ export function transcriptionMonitor({ statusUrl, initial }) {
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (! response.ok) {
@@ -192,71 +558,70 @@ export function transcriptionMonitor({ statusUrl, initial }) {
formatElapsed,
formatDuration,
formatTimestamp,
formatClock,
wordConfidenceClass,
};
}
/**
* Index-page Alpine component: patch row status from Reverb events.
* 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({ recordings, pendingCount }) {
const byId = {};
for (const row of recordings) {
byId[row.id] = row;
}
export function recordingsIndex() {
return {
rows: byId,
pendingCount: Number(pendingCount) || 0,
leaveChannel: null,
playingId: null,
isPlaying: false,
start() {
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => {
this.applyPayload(event);
});
//
},
destroy() {
if (this.leaveChannel) {
this.leaveChannel();
this.leaveChannel = null;
const player = this.$refs.player;
if (player) {
player.pause();
player.removeAttribute('src');
player.load();
}
},
applyPayload(payload) {
const id = payload.id;
syncPlayer() {
const player = this.$refs.player;
if (!this.rows[id]) {
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;
}
const previousStatus = this.rows[id].status;
const next = {
...this.rows[id],
status: payload.status,
status_label: payload.status_label,
progress: payload.progress,
percent: payload.percent,
is_active: payload.is_active,
word_count: payload.word_count ?? this.rows[id].word_count,
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
badge_class: badgeClassFor(payload.status),
};
if (this.playingId === id && this.isPlaying) {
player.pause();
this.rows[id] = next;
const wasQueueable = ['pending', 'failed', 'cancelled'].includes(previousStatus);
const isQueueable = ['pending', 'failed', 'cancelled'].includes(payload.status);
if (wasQueueable && !isQueueable) {
this.pendingCount = Math.max(0, this.pendingCount - 1);
} else if (!wasQueueable && isQueueable) {
this.pendingCount += 1;
return;
}
},
row(id) {
return this.rows[id] || {};
if (this.playingId !== id) {
player.src = url;
this.playingId = id;
}
player.play().catch(() => {
this.playingId = null;
this.isPlaying = false;
});
},
};
}
-165
View File
@@ -1,165 +0,0 @@
/**
* Upload dropzone: discard duplicate files in the selection (and known server fingerprints)
* before submitting the form.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
export function uploadDropzone({ existingFingerprints = [] } = {}) {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
const known = new Set(existingFingerprints);
return {
files: [],
dragging: false,
uploading: false,
error: null,
notice: null,
get uploadLabel() {
if (this.uploading) {
return 'Uploading…';
}
if (this.files.length <= 1) {
return 'Upload';
}
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
this.notice = null;
const accepted = [];
let skippedUnsupported = 0;
let skippedTooLarge = 0;
let skippedDuplicates = 0;
for (const file of incoming) {
if (! this.isAccepted(file)) {
skippedUnsupported++;
continue;
}
if (file.size > MAX_FILE_BYTES) {
skippedTooLarge++;
continue;
}
const fingerprint = this.fingerprint(file);
if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
accepted.push(file);
}
this.files = [...this.files, ...accepted];
if (this.files.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = this.files.slice(0, 50);
}
if (skippedUnsupported > 0) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
} else if (skippedTooLarge > 0) {
this.error = 'Skipped a file larger than 2 GB.';
}
if (skippedDuplicates > 0) {
this.notice = skippedDuplicates === 1
? 'Skipped 1 duplicate file.'
: `Skipped ${skippedDuplicates} duplicate files.`;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) {
return true;
}
return (file.type || '').startsWith('audio/');
},
fingerprint(file) {
return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`;
},
fileListKey(file, index = 0) {
return `${this.fingerprint(file)}:${index}`;
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.notice = null;
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) {
return;
}
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) {
return bytes + ' B';
}
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
}
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
},
};
}
@@ -1,29 +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="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto max-w-5xl px-4 py-2 sm:px-6">
<div class="flex items-center justify-between gap-3 text-xs text-stone-600 dark:text-zinc-400">
<span class="font-medium text-stone-700 dark:text-zinc-200">Disk space</span>
<span class="tabular-nums">
{{ $disk['free_human'] }} free
<span class="text-stone-400 dark:text-zinc-500">·</span>
{{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }}
</span>
</div>
<div
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700"
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($disk['used_percent']) }}"
aria-label="Disk space used"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }}"
aria-valuenow="{{ (int) round($usedPercent) }}"
aria-label="Disk space {{ $usedPercentLabel }}% used"
>
<div
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}"
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
class="h-full rounded-full transition-[width] duration-300"
style="width: {{ $usedPercent }}%; background-color: {{ $fillColor }};"
></div>
</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
+28 -28
View File
@@ -1,30 +1,27 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null])
@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 dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar />
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<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" />
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<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>
@@ -32,57 +29,60 @@
<flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle />
@auth
<x-desktop-user-menu />
@endauth
</div>
</flux:header>
<flux:sidebar collapsible="mobile" sticky class="border-e border-stone-200 bg-white lg:hidden dark:border-zinc-700 dark:bg-zinc-800">
<flux:sidebar collapsible="mobile" sticky class="lg:hidden">
<flux:sidebar.header>
<a href="{{ route('recordings.index') }}" class="text-base font-semibold text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<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.*')">
<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')">
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')" wire:navigate>
{{ __('Upload') }}
</flux:sidebar.item>
</flux:sidebar.nav>
</flux:sidebar>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<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 dark:border-teal-800 dark:bg-teal-950 dark:text-teal-100">
{{ 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 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
{{ 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 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
<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>
+7 -12
View File
@@ -1,28 +1,23 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar />
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe
</a>
<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
</div>
</flux:header>
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
<flux:main container>
{{ $slot }}
</main>
</flux:main>
@fluxScripts
</body>
+10 -11
View File
@@ -1,21 +1,20 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<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">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<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 bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<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>
+10 -10
View File
@@ -1,21 +1,21 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
<style>[x-cloak]{display:none!important}</style>
</head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 bg-stone-100 p-6 md:p-10 dark:bg-zinc-900">
<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-2">
<a href="{{ url('/') }}" class="mb-1 flex flex-col items-center gap-2 font-medium">
<span class="flex h-9 w-9 items-center justify-center rounded-md bg-teal-700 text-sm font-semibold text-white dark:bg-teal-600">
AT
</span>
<span class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">{{ config('app.name', 'AndyTranscribe') }}</span>
</a>
<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>
+11 -15
View File
@@ -1,17 +1,14 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<body class="min-h-screen bg-white antialiased dark:bg-zinc-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div>
<div class="relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-zinc-800">
<div class="absolute inset-0 bg-zinc-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
<span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
</span>
{{ config('app.name', 'Laravel') }}
{{ config('app.name', 'AndyTranscribe') }}
</a>
@php
@@ -27,13 +24,12 @@
</div>
<div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
<span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<flux:brand
class="z-20 justify-center lg:hidden"
name="{{ config('app.name', 'AndyTranscribe') }}"
href="{{ route('home') }}"
wire:navigate
/>
{{ $slot }}
</div>
</div>
@@ -0,0 +1,7 @@
<div>
<div class="mb-8">
<flux:heading size="xl">Upload recordings</flux:heading>
</div>
<livewire:upload-recordings />
</div>
@@ -0,0 +1,241 @@
<div
@if ($hasActiveTranscriptions)
wire:poll.2s.visible
@endif
x-data="recordingsIndex()"
x-init="
start();
return () => destroy();
"
>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<flux:heading size="xl">Recordings</flux:heading>
<flux:text class="mt-1">Manage pocket-recorder audio and transcripts.</flux:text>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<div class="flex items-end gap-2">
<flux:input
type="search"
wire:model.live.debounce.400ms="search"
placeholder="Search title, artist, transcript…"
class="min-w-[16rem]"
/>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
@if ($pendingCount > 0)
<flux:button
type="button"
variant="ghost"
size="sm"
wire:click="queuePending"
>
Queue {{ $pendingCount }} pending
{{ $pendingCount === 1 ? 'transcription' : 'transcriptions' }}
</flux:button>
@endif
@if ($totalCount > 0)
<flux:modal.trigger name="delete-all-recordings">
<flux:button type="button" variant="danger" size="sm">
Delete all
</flux:button>
</flux:modal.trigger>
@endif
</div>
</div>
</div>
@if ($totalCount > 0)
<flux:modal name="delete-all-recordings" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">Delete all recordings?</flux:heading>
<flux:text class="mt-2">
This permanently removes
{{ $totalCount === 1 ? 'your 1 recording' : "all {$totalCount} recordings" }}
and their audio files. This cannot be undone.
</flux:text>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button type="button" variant="danger" wire:click="deleteAll">
Delete all
</flux:button>
</div>
</div>
</flux:modal>
@endif
@if ($recordings->isEmpty())
@if (filled($search))
<flux:card class="border-dashed py-16 text-center">
<flux:text>No recordings match {{ $search }}.</flux:text>
<div class="mt-4">
<flux:button variant="ghost" wire:click="$set('search', '')">Clear search</flux:button>
</div>
</flux:card>
@else
<livewire:upload-recordings :show-cancel="false" />
@endif
@else
<audio
x-ref="player"
class="hidden"
preload="none"
@play="syncPlayer()"
@pause="syncPlayer()"
@ended="playingId = null; syncPlayer()"
></audio>
<flux:table :paginate="$recordings">
<flux:table.columns>
<flux:table.column
sortable
:sorted="$sortBy === 'title'"
:direction="$sortDirection"
wire:click="sort('title')"
>
Title
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'duration'"
:direction="$sortDirection"
wire:click="sort('duration')"
>
Duration
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'words'"
:direction="$sortDirection"
wire:click="sort('words')"
>
Words
</flux:table.column>
<flux:table.column
class="w-44"
sortable
:sorted="$sortBy === 'status'"
:direction="$sortDirection"
wire:click="sort('status')"
>
Status
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'uploaded'"
:direction="$sortDirection"
wire:click="sort('uploaded')"
>
Uploaded
</flux:table.column>
<flux:table.column class="w-24"></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($recordings as $recording)
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
<flux:table.cell class="max-w-xl">
<div class="flex min-w-0 items-center gap-2">
<flux:link
href="{{ route('recordings.show', $recording) }}"
wire:navigate
class="shrink-0 font-medium"
>
{{ $recording->title }}
</flux:link>
<flux:button
type="button"
variant="ghost"
size="sm"
square
class="shrink-0"
data-audio-url="{{ route('recordings.audio', $recording) }}"
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
>
<flux:icon.play
variant="micro"
x-show="! isPlayingRow({{ $recording->id }})"
/>
<flux:icon.pause
variant="micro"
x-show="isPlayingRow({{ $recording->id }})"
x-cloak
/>
</flux:button>
@if ($preview = $recording->transcriptFirstLine())
<span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
{{ $preview }}
</span>
@endif
</div>
</flux:table.cell>
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
<flux:table.cell>
<span class="tabular-nums">
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</span>
</flux:table.cell>
<flux:table.cell class="w-44 whitespace-nowrap">
<span class="inline-flex items-center gap-1.5">
<x-transcription-status-badge
:status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()"
/>
@if ($recording->transcription_status === 'processing' && $recording->transcription_percent > 0)
<span class="tabular-nums text-xs text-zinc-500 dark:text-zinc-400">
{{ $recording->transcription_percent }}%
</span>
@endif
</span>
</flux:table.cell>
<flux:table.cell>
{{ $recording->created_at?->format('Y-m-d H:i') }}
</flux:table.cell>
<flux:table.cell>
<flux:modal.trigger name="delete-recording-{{ $recording->id }}">
<flux:button
type="button"
variant="danger"
size="sm"
square
aria-label="Delete {{ $recording->title }}"
>
<flux:icon.trash variant="micro" />
</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-recording-{{ $recording->id }}" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">Delete recording?</flux:heading>
<flux:text class="mt-2">
This permanently removes {{ $recording->title }} and its audio file.
</flux:text>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button
type="button"
variant="danger"
wire:click="delete({{ $recording->id }})"
>
Delete
</flux:button>
</div>
</div>
</flux:modal>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
@endif
</div>
@@ -0,0 +1,288 @@
{{--
Full wire:ignore: Livewire must not remorph this tree while Echo/Alpine own the live UI.
Status + whisper segments hydrate over HTTP while active (no wire:poll here).
--}}
<div
wire:ignore
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcribed_at?->timestamp }}"
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'userId' => (int) $recording->user_id,
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm"> Recordings</flux:link>
<flux:heading size="xl" class="mt-2">{{ $recording->title }}</flux:heading>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-transcription-status-badge
:status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()"
alpine-row="status"
/>
<flux:text
class="text-xs"
x-show="status.driver_label"
x-cloak
x-text="status.driver_label"
></flux:text>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<flux:button
type="button"
variant="primary"
icon="play"
@click="$refs.player.paused ? $refs.player.play() : $refs.player.pause()"
>
Play
</flux:button>
<flux:modal.trigger name="delete-recording">
<flux:button type="button" variant="danger">Delete</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-recording" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">Delete recording?</flux:heading>
<flux:text class="mt-2">
This permanently removes the recording and its audio file.
</flux:text>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button type="button" variant="danger" wire:click="delete">Delete</flux:button>
</div>
</div>
</flux:modal>
</div>
</div>
<flux:card class="mb-6">
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Audio</flux:heading>
<audio
x-ref="player"
class="mt-4 w-full"
controls
preload="metadata"
src="{{ route('recordings.audio', $recording) }}"
>
Your browser does not support audio playback.
</audio>
</flux:card>
<div class="grid gap-6 lg:grid-cols-2">
<flux:card>
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Metadata</flux:heading>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt><flux:text>Original file</flux:text></dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Duration</flux:text></dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Artist</flux:text></dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Album</flux:text></dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Recorded</flux:text></dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Size</flux:text></dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Uploaded</flux:text></dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
</dl>
</flux:card>
<flux:card>
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcribe</flux:heading>
<div class="mt-4">
<flux:button type="button" variant="primary" wire:click="startTranscription">
<span x-text="startButtonLabel"></span>
</flux:button>
</div>
<div
x-show="status.is_active"
x-cloak
class="mt-3"
>
<flux:button type="button" variant="outline" wire:click="cancelTranscription">
Stop transcription
</flux:button>
</div>
</flux:card>
</div>
<flux:card class="mt-6">
<div class="flex items-center justify-between gap-4">
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcript</flux:heading>
<flux:button
type="button"
variant="ghost"
size="sm"
x-show="!status.is_active && status.has_transcript"
x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')"
>
Copy
</flux:button>
</div>
<div x-show="status.is_active" x-cloak class="mt-4">
<flux:callout variant="warning" icon="arrow-path">
<flux:callout.heading>
<span class="inline-flex items-center gap-2">
<flux:icon.loading
variant="micro"
class="size-4"
x-show="status.status === 'processing'"
x-cloak
/>
<span x-text="status.progress || 'Working…'"></span>
</span>
</flux:callout.heading>
<flux:callout.text>
<span
class="tabular-nums"
x-show="status.status === 'processing' && Number(status.percent) > 0"
x-cloak
>
<span x-text="status.percent + '%'"></span>
·
</span>
Elapsed <span x-text="status.elapsed_human || '0s'"></span>
</flux:callout.text>
</flux:callout>
<div class="mt-3" x-show="status.status === 'processing' && Number(status.percent) > 0" x-cloak>
<flux:progress color="amber" x-bind:value="status.percent || 0" />
</div>
<div
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
x-show="status.transcript && !whisperWords.length"
x-text="status.transcript"
></div>
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<li>
Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span>
</li>
<li x-show="whisper.language">
Detected language:
<span class="font-medium uppercase" x-text="whisper.language"></span>
</li>
<li x-show="whisper.duration">
Whisper duration:
<span class="font-medium" x-text="formatClock(whisper.duration)"></span>
</li>
<template x-if="status.duration_seconds">
<li>
Audio length:
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
<span class="text-zinc-500">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-600 dark:text-red-400" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4">
<flux:callout icon="stop-circle">
<flux:callout.text>
Transcription stopped. Use the button above to start again.
</flux:callout.text>
</flux:callout>
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4">
<flux:callout variant="danger" icon="exclamation-triangle">
<flux:callout.heading>Transcription failed</flux:callout.heading>
<flux:callout.text>
<span x-text="status.error || 'Check the logs and try again.'"></span>
</flux:callout.text>
</flux:callout>
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p
class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100"
x-show="!whisperWords.length"
x-text="status.transcript"
></p>
<flux:text
class="mt-4 text-xs"
x-show="status.transcribed_at"
x-text="status.transcribed_at
? ('Transcribed ' + formatTimestamp(status.transcribed_at)
+ (status.transcription_duration_human ? (' · took ' + status.transcription_duration_human) : ''))
: ''"
></flux:text>
</div>
<div
class="mt-4 space-y-3"
x-show="whisper.language || whisperWords.length"
>
<div
class="flex flex-wrap gap-2 text-xs text-zinc-600 dark:text-zinc-400"
x-show="!status.is_active && (whisper.language || whisper.duration)"
>
<span x-show="whisper.language">
Language
<span class="font-medium uppercase text-zinc-800 dark:text-zinc-100" x-text="whisper.language"></span>
</span>
<span x-show="whisper.duration">
· Duration
<span class="font-medium text-zinc-800 dark:text-zinc-100" x-text="formatClock(whisper.duration)"></span>
</span>
</div>
<div
class="mt-4 text-sm leading-relaxed"
x-show="whisperWords.length"
>
<template x-for="word in whisperWords" :key="word.key">
<button
type="button"
class="mr-1 inline cursor-pointer hover:underline"
:class="wordConfidenceClass(word.probability)"
:title="(word.start != null ? formatClock(word.start) : '') + (word.probability != null ? (' · ' + Math.round(word.probability * 100) + '%') : '')"
@click="seekTo(word.start)"
x-text="word.word"
></button>
</template>
</div>
</div>
<flux:text
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak
class="mt-4"
>
No transcript yet. Transcription starts automatically after upload, or use the button above.
</flux:text>
</flux:card>
</div>
@@ -0,0 +1,96 @@
<div
class="max-w-2xl space-y-6 rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
x-data="{
uploading: false,
progress: 0,
dragging: false,
openPicker() {
this.$refs.fileInput.click();
},
onDrop(event) {
this.dragging = false;
const files = event.dataTransfer?.files;
if (! files?.length) {
return;
}
const transfer = new DataTransfer();
Array.from(files).forEach((file) => transfer.items.add(file));
this.$refs.fileInput.files = transfer.files;
this.$refs.fileInput.dispatchEvent(new Event('change', { bubbles: true }));
},
}"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-cancel="uploading = false"
x-on:livewire-upload-error="uploading = false"
x-on:livewire-upload-progress="progress = $event.detail.progress"
>
<div>
<flux:label>Audio files</flux:label>
<input
x-ref="fileInput"
type="file"
class="sr-only"
wire:model="audio"
multiple
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
>
<div
role="button"
tabindex="0"
x-on:click="openPicker()"
x-on:keydown.enter.prevent="openPicker()"
x-on:keydown.space.prevent="openPicker()"
x-on:dragenter.prevent="dragging = true"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="onDrop($event)"
x-bind:class="dragging ? 'border-accent bg-accent/5 dark:border-accent dark:bg-accent/10' : 'border-zinc-300 bg-zinc-50 dark:border-white/20 dark:bg-white/5'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed px-6 py-10 text-center transition-colors"
>
<div class="mb-3 flex size-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-white/10">
<flux:icon.cloud-arrow-up class="size-6 text-zinc-500 dark:text-zinc-400" />
</div>
<flux:heading size="sm" class="text-zinc-800 dark:text-zinc-100">
Drop audio files here or click to browse
</flux:heading>
<flux:text class="mt-1 max-w-sm text-zinc-500 dark:text-zinc-400">
MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files
</flux:text>
<div
x-show="uploading || $wire.saving"
x-cloak
class="mt-5 w-full max-w-sm space-y-2"
>
<div class="flex items-center justify-between gap-3 text-xs text-zinc-600 dark:text-zinc-400">
<span x-text="$wire.saving ? 'Saving recordings…' : 'Uploading…'"></span>
<span x-show="uploading" x-text="progress + '%'"></span>
</div>
<flux:progress color="teal" x-bind:value="uploading ? progress : 100" />
</div>
</div>
@error('audio')
<flux:error>{{ $message }}</flux:error>
@enderror
@error('audio.*')
<flux:error>{{ $message }}</flux:error>
@enderror
</div>
@if ($showCancel)
<div class="flex items-center gap-3">
<flux:link href="{{ route('recordings.index') }}" wire:navigate>Cancel</flux:link>
</div>
@endif
</div>
+39 -1
View File
@@ -1,12 +1,50 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="reverb-key" content="{{ config('reverb.client.key') }}">
<meta name="reverb-host" content="{{ config('reverb.client.host') }}">
<meta name="reverb-port" content="{{ config('reverb.client.port') }}">
<meta name="reverb-scheme" content="{{ config('reverb.client.scheme') }}">
<title>
{{ filled($title ?? null) ? $title.' — '.config('app.name', 'AndyTranscribe') : config('app.name', 'AndyTranscribe') }}
</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
{{-- Flux appearance with dark as the default (Flux ships with "system"). --}}
<style>
:root.dark {
color-scheme: dark;
}
</style>
<script>
window.Flux = {
applyAppearance (appearance) {
let applyDark = () => document.documentElement.classList.add('dark')
let applyLight = () => document.documentElement.classList.remove('dark')
if (appearance === 'system') {
let media = window.matchMedia('(prefers-color-scheme: dark)')
// Persist "system" explicitly so a missing key can mean "use app default (dark)".
window.localStorage.setItem('flux.appearance', 'system')
media.matches ? applyDark() : applyLight()
} else if (appearance === 'dark') {
window.localStorage.setItem('flux.appearance', 'dark')
applyDark()
} else if (appearance === 'light') {
window.localStorage.setItem('flux.appearance', 'light')
applyLight()
}
}
}
window.Flux.applyAppearance(window.localStorage.getItem('flux.appearance') || 'dark')
</script>
-109
View File
@@ -1,109 +0,0 @@
@extends('layouts.app')
@section('title', 'Upload recording')
@section('content')
<div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped.
</p>
</div>
<form
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
@submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
>
@csrf
<div>
<label class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Audio files</label>
<div
@dragenter.prevent="dragging = true"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop($event)"
@click="$refs.fileInput.click()"
:class="dragging ? 'border-teal-600 bg-teal-50 dark:bg-teal-950/40' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40 dark:border-zinc-600 dark:bg-zinc-900/50 dark:hover:border-teal-500 dark:hover:bg-teal-950/30'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors"
>
<p class="text-sm font-medium text-stone-800 dark:text-zinc-100">Drop audio files here</p>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">or click to browse</p>
<p class="mt-3 text-xs text-stone-500 dark:text-zinc-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files</p>
</div>
<input
x-ref="fileInput"
id="audio"
type="file"
name="audio[]"
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
multiple
class="sr-only"
@change="onBrowse($event)"
>
</div>
<div x-show="files.length > 0" x-cloak class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-stone-700 dark:text-zinc-200">
<span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span>
</p>
<button type="button" @click="clearFiles()" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">
Clear all
</button>
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200 dark:divide-zinc-700 dark:border-zinc-700">
<template x-for="(file, index) in files" :key="fileListKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800 dark:text-zinc-100" x-text="file.name"></p>
<p class="text-xs text-stone-500 dark:text-zinc-400" x-text="formatSize(file.size)"></p>
</div>
<button
type="button"
@click="removeFile(index)"
class="shrink-0 text-stone-500 hover:text-red-700 dark:text-zinc-400 dark:hover:text-red-400"
>
Remove
</button>
</li>
</template>
</ul>
</div>
<div x-show="files.length === 1" x-cloak>
<label for="title" class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Title (optional)</label>
<input
id="title"
type="text"
name="title"
value="{{ old('title') }}"
placeholder="Leave blank to use embedded title or filename"
class="mt-2 w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"
>
</div>
<p x-show="notice" x-cloak class="text-sm text-amber-800 dark:text-amber-300" x-text="notice"></p>
<p x-show="error" x-cloak class="text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<div class="flex items-center gap-3">
<button
type="submit"
:disabled="files.length === 0 || uploading"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<span x-text="uploadLabel"></span>
</button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">Cancel</a>
</div>
</form>
@endsection
-146
View File
@@ -1,146 +0,0 @@
@extends('layouts.app')
@section('title', 'Recordings')
@section('content')
@php
$indexRows = $recordings->map(function ($recording) {
return [
'id' => $recording->id,
'status' => $recording->transcription_status,
'status_label' => $recording->transcriptionStatusLabel(),
'progress' => $recording->transcription_progress,
'percent' => $recording->transcription_percent,
'is_active' => $recording->isTranscribing(),
'word_count' => $recording->word_count,
'word_count_display' => $recording->word_count > 0
? number_format($recording->word_count)
: '—',
'badge_class' => match ($recording->transcription_status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
},
];
})->values();
@endphp
<div
x-data="recordingsIndex(@js([
'recordings' => $indexRows,
'pendingCount' => $pendingCount ?? 0,
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-800 dark:hover:bg-zinc-700">
Search
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe-pending') }}"
x-show="pendingCount > 0"
x-cloak
>
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</button>
</form>
</div>
</div>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center dark:border-zinc-600 dark:bg-zinc-800">
<p class="text-stone-600 dark:text-zinc-400">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<table class="min-w-full divide-y divide-stone-200 text-sm dark:divide-zinc-700">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500 dark:bg-zinc-900/50 dark:text-zinc-400">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100 dark:divide-zinc-700">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline dark:text-teal-300">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500 dark:text-zinc-400">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500 dark:text-zinc-400">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->duration_formatted }}</td>
<td
class="px-4 py-3 tabular-nums text-stone-600 dark:text-zinc-400"
x-text="row({{ $recording->id }}).word_count_display"
>
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="row({{ $recording->id }}).badge_class"
x-text="row({{ $recording->id }}).status_label"
>
{{ $recording->transcriptionStatusLabel() }}
</span>
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
x-cloak
:title="row({{ $recording->id }}).progress"
>
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
<span x-text="row({{ $recording->id }}).progress"></span>
</div>
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
</div>
@endsection
@@ -1,21 +0,0 @@
@php
$label = $label ?? match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
};
@endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
{{ $label }}
</span>
-179
View File
@@ -1,179 +0,0 @@
@extends('layouts.app')
@section('title', $recording->title)
@section('content')
<div
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800 dark:text-zinc-400 dark:hover:text-zinc-200"> Recordings</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600 dark:text-zinc-400">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass"
x-text="status.status_label || status.status"
></span>
<template x-if="status.driver_label">
<span class="text-xs text-stone-500 dark:text-zinc-400" x-text="status.driver_label"></span>
</template>
</div>
</div>
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
@csrf
@method('DELETE')
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-900 dark:bg-zinc-800 dark:text-red-300 dark:hover:bg-red-950">
Delete
</button>
</form>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Original file</dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Recorded</dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Size</dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
</dl>
</section>
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcribe</h2>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf
<button
type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500"
>
<span x-text="startButtonLabel"></span>
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe.cancel', $recording) }}"
x-show="status.is_active"
x-cloak
class="mt-3"
>
@csrf
<button
type="submit"
class="rounded border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
Stop transcription
</button>
</form>
</section>
</div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcript</h2>
<button
type="button"
x-show="!status.is_active && status.has_transcript"
x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')"
class="text-sm text-teal-700 hover:underline dark:text-teal-300"
>
Copy
</button>
</div>
<div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<p class="font-medium text-amber-900 dark:text-amber-100" x-text="status.progress || 'Working…'"></p>
<p class="tabular-nums text-amber-800 dark:text-amber-200">
<span x-text="(status.percent ?? 0) + '%'"></span>
<span class="mx-1 text-amber-600 dark:text-amber-400">·</span>
<span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span>
</p>
</div>
<div class="h-2 overflow-hidden rounded-full bg-amber-100 dark:bg-amber-900/50">
<div
class="h-full rounded-full bg-amber-500 transition-all duration-500"
:style="`width: ${Math.max(status.percent || 5, 5)}%`"
></div>
</div>
<ul class="space-y-1 text-xs text-amber-900/80 dark:text-amber-200/80">
<li>
Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span>
</li>
<template x-if="status.duration_seconds">
<li>
Audio length:
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
<span class="text-amber-700 dark:text-amber-300">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-700 dark:text-red-300" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4 rounded border border-stone-200 bg-stone-50 p-4 text-sm text-stone-700 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-300">
Transcription stopped. Choose an engine above to start again.
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
<p class="font-medium">Transcription failed</p>
<p x-text="status.error || 'Check the logs and try again with another engine.'"></p>
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800 dark:text-zinc-100" x-text="status.transcript"></p>
<p
class="mt-4 text-xs text-stone-500 dark:text-zinc-400"
x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
></p>
</div>
<p
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak
class="mt-4 text-sm text-stone-500 dark:text-zinc-400"
>
No transcript yet. Transcription starts automatically after upload, or use the button above.
</p>
</section>
</div>
@endsection
+4
View File
@@ -10,3 +10,7 @@ Broadcast::channel('recording.{recordingId}', function (User $user, int $recordi
->where('user_id', $user->id)
->exists();
});
Broadcast::channel('user.{userId}.recordings', function (User $user, int $userId): bool {
return (int) $user->id === $userId;
});
+9 -11
View File
@@ -1,22 +1,20 @@
<?php
use App\Http\Controllers\CancelTranscriptionController;
use App\Http\Controllers\RecordingController;
use App\Http\Controllers\TranscribeController;
use App\Http\Controllers\TranscribePendingController;
use App\Http\Controllers\StreamRecordingController;
use App\Http\Controllers\TranscriptionStatusController;
use App\Livewire\Recordings\Create;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings')->name('home');
Route::middleware('auth')->group(function (): void {
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']);
Route::post('recordings/transcribe-pending', TranscribePendingController::class)
->name('recordings.transcribe-pending');
Route::post('recordings/{recording}/transcribe', TranscribeController::class)
->name('recordings.transcribe');
Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class)
->name('recordings.transcribe.cancel');
Route::get('recordings', Index::class)->name('recordings.index');
Route::get('recordings/create', Create::class)->name('recordings.create');
Route::get('recordings/{recording}', Show::class)->name('recordings.show');
Route::get('recordings/{recording}/audio', StreamRecordingController::class)
->name('recordings.audio');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class)
->name('recordings.transcription-status');
});
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Deploy a pre-built APP_IMAGE to one or more AndyTranscribe directories.
Environment:
APP_IMAGE Required. e.g. gitea.z00.nu/ben/andytranscribe:abc1234
DEPLOY_PATHS Comma-separated instance directories (default: current directory)
DEPLOY_SHA Optional git SHA to hard-reset each directory to (CI sets this)
GIT_PULL When 1 (default) and DEPLOY_SHA is empty, run git pull --ff-only
Usage:
APP_IMAGE=gitea.z00.nu/ben/andytranscribe:tag DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh
EOF
}
IMAGE="${APP_IMAGE:-${1:-}}"
if [[ -z "${IMAGE}" ]]; then
usage >&2
exit 1
fi
PATHS_CSV="${DEPLOY_PATHS:-${2:-.}}"
GIT_PULL="${GIT_PULL:-1}"
DEPLOY_SHA="${DEPLOY_SHA:-}"
IFS=',' read -r -a DIRS <<< "${PATHS_CSV}"
for dir in "${DIRS[@]}"; do
dir="${dir#"${dir%%[![:space:]]*}"}"
dir="${dir%"${dir##*[![:space:]]}"}"
if [[ ! -d "${dir}" ]]; then
echo "Missing instance directory: ${dir}" >&2
exit 1
fi
name="$(basename "${dir}")"
echo "==> Deploying ${IMAGE} in ${dir}"
(
cd "${dir}"
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if [[ -n "${DEPLOY_SHA}" ]]; then
git fetch --force origin '+refs/heads/*:refs/remotes/origin/*'
if ! git cat-file -e "${DEPLOY_SHA}^{commit}" 2>/dev/null; then
git fetch --force origin "${DEPLOY_SHA}"
fi
git checkout --force --detach "${DEPLOY_SHA}"
git reset --hard "${DEPLOY_SHA}"
# Drop local edits/hot-patches; keep runtime data and secrets.
git clean -fd \
--exclude=database/ \
--exclude=storage/ \
--exclude=.env \
--exclude=.env.*
elif [[ "${GIT_PULL}" == "1" ]]; then
git pull --ff-only
fi
fi
export APP_IMAGE="${IMAGE}"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-${name,,}}"
compose_files=(-f docker-compose.yml)
if [[ -f compose.z00.yaml ]]; then
compose_files+=(-f compose.z00.yaml)
fi
if grep -q '^APP_IMAGE=' .env 2>/dev/null; then
sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${IMAGE}|" .env
else
printf '\nAPP_IMAGE=%s\n' "${IMAGE}" >> .env
fi
docker compose "${compose_files[@]}" pull app queue reverb
docker compose "${compose_files[@]}" up -d --remove-orphans
docker compose "${compose_files[@]}" ps
app_port="$(awk -F= '/^APP_HOST_PORT=/ {print $2; exit}' .env 2>/dev/null || true)"
app_port="${app_port:-18080}"
health_url="http://127.0.0.1:${app_port}/up"
echo "waiting for ${health_url}"
ok=0
for _ in $(seq 1 45); do
if curl -fsS "${health_url}" >/dev/null 2>&1; then
echo "healthy"
ok=1
break
fi
sleep 2
done
if [[ "${ok}" -ne 1 ]]; then
echo "ERROR: health check failed for ${name}" >&2
exit 1
fi
# Hit /login as the public HTTPS edge would (trustProxies needs forwarded proto).
app_url="$(awk -F= '/^APP_URL=/ {print $2; exit}' .env 2>/dev/null || true)"
app_url="${app_url:-https://transcribe.z00.nu}"
public_host="$(python3 - <<PY
from urllib.parse import urlparse
print(urlparse("${app_url}").hostname or "transcribe.z00.nu")
PY
)"
asset_scheme="$(
curl -fsS \
-H "X-Forwarded-Proto: https" \
-H "X-Forwarded-Host: ${public_host}" \
-H "X-Forwarded-Port: 443" \
"http://127.0.0.1:${app_port}/login" \
| grep -oE 'https?://[^"'\'' ]+\.css' \
| head -1 || true
)"
if [[ "${asset_scheme}" == http://* ]]; then
echo "ERROR: login page still emits http:// asset URLs (${asset_scheme})" >&2
exit 1
fi
echo "asset check ok: ${asset_scheme:-no absolute css url (relative/ok)}"
)
done
echo "Deploy complete: ${IMAGE}"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Idempotent bootstrap for AndyTranscribe Gitea Actions secrets on z00.
# Requires an already-running Gitea + act_runner (see airports setup).
set -euo pipefail
GITEA_DIR="${GITEA_DIR:-${HOME}/gitea}"
REPO_OWNER="${REPO_OWNER:-ben}"
REPO_NAME="${REPO_NAME:-AndyTranscribe}"
REGISTRY_HOST="${REGISTRY_HOST:-gitea.z00.nu}"
DEPLOY_PATH="${DEPLOY_PATH:-${HOME}/andyTranscibe}"
STAGE_DEPLOY_PATH="${STAGE_DEPLOY_PATH:-${HOME}/andyTranscibe-stage}"
CREDENTIALS_FILE="${GITEA_DIR}/.credentials"
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
echo "Missing ${CREDENTIALS_FILE}" >&2
exit 1
fi
# shellcheck disable=SC1090
source "${CREDENTIALS_FILE}"
API="https://${REGISTRY_HOST}/api/v1"
AUTH=(-u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}")
if ! curl -fsS "${AUTH[@]}" "${API}/repos/${REPO_OWNER}/${REPO_NAME}" >/dev/null 2>&1; then
echo "Repository ${REPO_OWNER}/${REPO_NAME} not found on ${REGISTRY_HOST}" >&2
exit 1
fi
CI_TOKEN="$(
docker exec -u git gitea gitea admin user generate-access-token \
-u "${ADMIN_USERNAME}" \
-t "ci-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \
--scopes "write:package,read:package,write:repository,read:repository" \
--raw
)"
PULL_TOKEN="$(
docker exec -u git gitea gitea admin user generate-access-token \
-u "${ADMIN_USERNAME}" \
-t "pull-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \
--scopes "read:package" \
--raw
)"
set_secret() {
local name="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
python3 -c 'import json,sys; json.dump({"data": sys.argv[1]}, open(sys.argv[2], "w"))' "${value}" "${tmp}"
curl -fsS "${AUTH[@]}" -X PUT \
"${API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/secrets/${name}" \
-H "Content-Type: application/json" \
--data-binary @"${tmp}" >/dev/null
rm -f "${tmp}"
}
set_secret "REGISTRY_TOKEN" "${CI_TOKEN}"
set_secret "DEPLOY_PATHS" "${DEPLOY_PATH}"
set_secret "STAGE_DEPLOY_PATHS" "${STAGE_DEPLOY_PATH}"
printf '%s' "${PULL_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${ADMIN_USERNAME}" --password-stdin
REPO_LC="$(echo "${REPO_OWNER}/${REPO_NAME}" | tr '[:upper:]' '[:lower:]')"
if [[ -f "${DEPLOY_PATH}/.env" ]]; then
if grep -q '^APP_IMAGE=' "${DEPLOY_PATH}/.env"; then
sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${REGISTRY_HOST}/${REPO_LC}:latest|" "${DEPLOY_PATH}/.env"
else
printf '\nAPP_IMAGE=%s/%s:latest\n' "${REGISTRY_HOST}" "${REPO_LC}" >> "${DEPLOY_PATH}/.env"
fi
fi
# Ensure the existing host runner is up (shared with airports).
if systemctl --user is-enabled gitea-act-runner.service >/dev/null 2>&1; then
systemctl --user restart gitea-act-runner.service || true
systemctl --user --no-pager --lines=5 status gitea-act-runner.service || true
fi
echo "Gitea CI secrets configured for ${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}"
echo "Deploy path: ${DEPLOY_PATH}"
echo "Stage deploy path: ${STAGE_DEPLOY_PATH}"
echo "Push to main to deploy production; push to stage to deploy https://stage.transcribe.z00.nu"
echo "First-time stage instance: ./scripts/setup-stage.sh"
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# One-time bootstrap for the AndyTranscribe staging instance on z00.
# Creates ~/andyTranscibe-stage, a unique .env, the stage Caddy Docker network,
# and the Gitea STAGE_DEPLOY_PATHS secret.
set -euo pipefail
GITEA_DIR="${GITEA_DIR:-${HOME}/gitea}"
REPO_OWNER="${REPO_OWNER:-ben}"
REPO_NAME="${REPO_NAME:-AndyTranscribe}"
REGISTRY_HOST="${REGISTRY_HOST:-gitea.z00.nu}"
STAGE_PATH="${STAGE_PATH:-${HOME}/andyTranscibe-stage}"
GITEA_REPO_URL="https://${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}.git"
STAGE_URL="${STAGE_URL:-https://stage.transcribe.z00.nu}"
REVERB_PUBLIC_HOST="${REVERB_PUBLIC_HOST:-reverb.stage.transcribe.z00.nu}"
CONTAINER_PREFIX="${CONTAINER_PREFIX:-andytranscribe-stage}"
CADDY_NETWORK="${CADDY_NETWORK:-andytranscribe-stage-caddy}"
PROD_CADDY_NETWORK="${PROD_CADDY_NETWORK:-caddy-proxy-manager-test_caddy-test-network}"
CADDY_CONTAINER="${CADDY_CONTAINER:-}"
CREDENTIALS_FILE="${GITEA_DIR}/.credentials"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
upsert_env() {
local file="$1"
local key="$2"
local value="$3"
python3 - "$file" "$key" "$value" <<'PY'
import re
import sys
path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
text = open(path).read()
pattern = re.compile(r"^" + re.escape(key) + r"=.*$", re.M)
replacement = f"{key}={value}"
if pattern.search(text):
text = pattern.sub(replacement, text, count=1)
else:
text = text.rstrip("\n") + "\n" + replacement + "\n"
open(path, "w").write(text)
PY
}
if [[ ! -f "${CREDENTIALS_FILE}" ]]; then
echo "Missing ${CREDENTIALS_FILE}" >&2
exit 1
fi
# shellcheck disable=SC1090
source "${CREDENTIALS_FILE}"
API="https://${REGISTRY_HOST}/api/v1"
AUTH=(-u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}")
if ! curl -fsS "${AUTH[@]}" "${API}/repos/${REPO_OWNER}/${REPO_NAME}" >/dev/null 2>&1; then
echo "Repository ${REPO_OWNER}/${REPO_NAME} not found on ${REGISTRY_HOST}" >&2
exit 1
fi
if [[ ! -d "${STAGE_PATH}/.git" ]]; then
mkdir -p "$(dirname "${STAGE_PATH}")"
git clone "${GITEA_REPO_URL}" "${STAGE_PATH}"
fi
(
cd "${STAGE_PATH}"
# Never fetch from the prod checkout; CI deploys by SHA against this origin.
git remote set-url origin "${GITEA_REPO_URL}"
git fetch origin || true
if git rev-parse --verify origin/stage >/dev/null 2>&1; then
git checkout stage
git pull --ff-only origin stage || true
else
echo "Remote branch origin/stage not found yet; leaving $(git branch --show-current)."
fi
)
mkdir -p \
"${STAGE_PATH}/database" \
"${STAGE_PATH}/storage/app/private/recordings" \
"${STAGE_PATH}/storage/app/public" \
"${STAGE_PATH}/storage/framework/cache" \
"${STAGE_PATH}/storage/framework/sessions" \
"${STAGE_PATH}/storage/framework/views" \
"${STAGE_PATH}/storage/logs" \
"${STAGE_PATH}/bootstrap/cache"
if [[ ! -f "${STAGE_PATH}/database/database.sqlite" ]]; then
touch "${STAGE_PATH}/database/database.sqlite"
fi
ENV_SRC="${STAGE_PATH}/.env.example"
if [[ ! -f "${ENV_SRC}" ]]; then
ENV_SRC="${REPO_ROOT}/.env.example"
fi
if [[ ! -f "${STAGE_PATH}/.env" ]]; then
cp "${ENV_SRC}" "${STAGE_PATH}/.env"
upsert_env "${STAGE_PATH}/.env" "APP_ENV" "production"
upsert_env "${STAGE_PATH}/.env" "APP_DEBUG" "false"
upsert_env "${STAGE_PATH}/.env" "APP_KEY" "base64:$(openssl rand -base64 32)"
upsert_env "${STAGE_PATH}/.env" "APP_URL" "${STAGE_URL}"
upsert_env "${STAGE_PATH}/.env" "PUBLIC_APP_URL" "${STAGE_URL}"
# Prod already binds 18080 (app), 18180 (reverb), and 18090 (whisper).
upsert_env "${STAGE_PATH}/.env" "APP_HOST_PORT" "18280"
upsert_env "${STAGE_PATH}/.env" "REVERB_HOST_PORT" "18281"
upsert_env "${STAGE_PATH}/.env" "WHISPER_HOST_PORT" "18290"
upsert_env "${STAGE_PATH}/.env" "REVERB_APP_ID" "$(openssl rand -hex 8)"
upsert_env "${STAGE_PATH}/.env" "REVERB_APP_KEY" "$(openssl rand -hex 16)"
upsert_env "${STAGE_PATH}/.env" "REVERB_APP_SECRET" "$(openssl rand -hex 20)"
upsert_env "${STAGE_PATH}/.env" "REVERB_PUBLIC_HOST" "${REVERB_PUBLIC_HOST}"
upsert_env "${STAGE_PATH}/.env" "REVERB_PUBLIC_PORT" "443"
upsert_env "${STAGE_PATH}/.env" "REVERB_PUBLIC_SCHEME" "https"
upsert_env "${STAGE_PATH}/.env" "CONTAINER_PREFIX" "${CONTAINER_PREFIX}"
upsert_env "${STAGE_PATH}/.env" "CADDY_NETWORK" "${CADDY_NETWORK}"
echo "Wrote ${STAGE_PATH}/.env"
else
echo "Keeping existing ${STAGE_PATH}/.env"
fi
if ! docker network inspect "${CADDY_NETWORK}" >/dev/null 2>&1; then
docker network create "${CADDY_NETWORK}"
echo "Created Docker network ${CADDY_NETWORK}"
fi
if [[ -z "${CADDY_CONTAINER}" ]]; then
CADDY_CONTAINER="$(
docker network inspect "${PROD_CADDY_NETWORK}" \
--format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}' \
| grep -i caddy \
| head -1 || true
)"
fi
if [[ -z "${CADDY_CONTAINER}" ]]; then
echo "Could not find a Caddy container on ${PROD_CADDY_NETWORK}." >&2
echo "Set CADDY_CONTAINER and re-run: docker network connect ${CADDY_NETWORK} <caddy-container>" >&2
exit 1
fi
if docker network inspect "${CADDY_NETWORK}" --format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}' | grep -qx "${CADDY_CONTAINER}"; then
echo "Caddy container ${CADDY_CONTAINER} already on ${CADDY_NETWORK}"
else
docker network connect "${CADDY_NETWORK}" "${CADDY_CONTAINER}"
echo "Attached ${CADDY_CONTAINER} to ${CADDY_NETWORK}"
fi
set_secret() {
local name="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
python3 -c 'import json,sys; json.dump({"data": sys.argv[1]}, open(sys.argv[2], "w"))' "${value}" "${tmp}"
curl -fsS "${AUTH[@]}" -X PUT \
"${API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/secrets/${name}" \
-H "Content-Type: application/json" \
--data-binary @"${tmp}" >/dev/null
rm -f "${tmp}"
}
set_secret "STAGE_DEPLOY_PATHS" "${STAGE_PATH}"
echo "Stage instance ready at ${STAGE_PATH}"
echo "Public URL: ${STAGE_URL}"
echo "Reverb host: ${REVERB_PUBLIC_HOST}"
echo "Caddy network: ${CADDY_NETWORK} (container ${CADDY_CONTAINER})"
echo "Gitea secret STAGE_DEPLOY_PATHS=${STAGE_PATH}"
echo
echo "Still required: DNS for stage.transcribe.z00.nu and ${REVERB_PUBLIC_HOST},"
echo "plus Caddy Proxy Manager hosts pointing at ${CONTAINER_PREFIX}-app:80"
echo "and ${CONTAINER_PREFIX}-reverb:8080."
echo "Then: git checkout -b stage && git push -u origin stage"
+12
View File
@@ -24,12 +24,24 @@ class DemoUserSeederTest extends TestCase
$this->assertTrue(Hash::check('password', $user->password));
}
public function test_seeder_creates_admin_user(): void
{
$this->seed(DatabaseSeeder::class);
$user = User::query()->where('email', 'admin@example.com')->first();
$this->assertNotNull($user);
$this->assertSame('Admin', $user->name);
$this->assertTrue(Hash::check('password', $user->password));
}
public function test_seeder_is_idempotent(): void
{
$this->seed(DatabaseSeeder::class);
$this->seed(DatabaseSeeder::class);
$this->assertSame(1, User::query()->where('email', 'demo@example.com')->count());
$this->assertSame(1, User::query()->where('email', 'admin@example.com')->count());
}
public function test_seeder_assigns_orphaned_recordings_to_demo_user(): void
+10 -3
View File
@@ -50,10 +50,17 @@ class DiskSpaceTest extends TestCase
{
Cache::flush();
$this->get(route('recordings.index'))
$response = $this->get(route('recordings.index'))
->assertOk()
->assertSee('Disk space')
->assertSee('Disk space', false)
->assertSee('% used', false)
->assertSee('free')
->assertSee('role="progressbar"', false);
->assertSee('role="progressbar"', false)
->assertSee('background-color:', false);
$this->assertMatchesRegularExpression(
'/width:\s*[\d.]+%;\s*background-color:\s*#(0d9488|f59e0b|dc2626)/',
$response->getContent(),
);
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class RecordingAudioStreamTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/note.mp3', 'fake-audio-bytes');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertOk()
->assertHeader('content-type', 'audio/mpeg')
->assertHeader('content-disposition', 'inline; filename=note.mp3');
}
public function test_show_page_includes_audio_player(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Playable',
'original_filename' => 'playable.mp3',
'file_path' => 'recordings/playable.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSee('Play', false)
->assertSee(route('recordings.audio', $recording), false)
->assertSee('<audio', false);
}
public function test_index_page_links_to_recording_show(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'List playable',
'original_filename' => 'list.mp3',
'file_path' => 'recordings/list.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSee('List playable')
->assertSee(route('recordings.show', $recording), false);
}
public function test_other_user_cannot_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/secret.mp3', 'fake-audio-bytes');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Secret',
'original_filename' => 'secret.mp3',
'file_path' => 'recordings/secret.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($intruder)
->get(route('recordings.audio', $recording))
->assertForbidden();
}
public function test_missing_audio_file_returns_not_found(): void
{
Storage::fake('local');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Gone',
'original_filename' => 'gone.mp3',
'file_path' => 'recordings/gone.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertNotFound();
}
}
+25 -34
View File
@@ -3,12 +3,14 @@
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingDuplicateUploadTest extends TestCase
@@ -33,19 +35,18 @@ class RecordingDuplicateUploadTest extends TestCase
$first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes');
$duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes');
$this->post(route('recordings.store'), [
'audio' => [$first],
])->assertRedirect();
Livewire::test(UploadRecordings::class)
->set('audio', [$first])
->assertRedirect();
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
$response = $this->post(route('recordings.store'), [
'audio' => [$duplicate],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$duplicate])
->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
}
@@ -59,33 +60,24 @@ class RecordingDuplicateUploadTest extends TestCase
$two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes');
$three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes');
$response = $this->post(route('recordings.store'), [
'audio' => [$one, $two, $three],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$one, $two, $three])
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertStringContainsString('Skipped 1 duplicate', session('success'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void
public function test_upload_page_shows_dropzone(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Existing',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 2048,
'content_hash' => str_repeat('a', 64),
'transcription_status' => 'done',
]);
$this->get(route('recordings.create'))
->assertOk()
->assertSee('note.mp3:2048', false)
->assertSee('Duplicate files', false);
->assertSeeLivewire('upload-recordings')
->assertSee('Drop audio files here or click to browse')
->assertDontSee('Title (optional)')
->assertDontSee('Uploads start as soon as you drop');
}
public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void
@@ -103,12 +95,11 @@ class RecordingDuplicateUploadTest extends TestCase
'transcription_status' => 'done',
]);
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')])
->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertNothingDispatched();
}
@@ -120,9 +111,9 @@ class RecordingDuplicateUploadTest extends TestCase
$file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash');
$this->post(route('recordings.store'), [
'audio' => [$file],
])->assertRedirect();
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect();
$recording = Recording::query()->first();
$this->assertNotNull($recording);
+5 -2
View File
@@ -2,9 +2,11 @@
namespace Tests\Feature;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingOwnershipTest extends TestCase
@@ -74,8 +76,9 @@ class RecordingOwnershipTest extends TestCase
'transcription_status' => 'done',
]);
$this->actingAs($intruder)
->delete(route('recordings.destroy', $recording))
$this->actingAs($intruder);
Livewire::test(Show::class, ['recording' => $recording])
->assertForbidden();
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
+440 -48
View File
@@ -4,14 +4,20 @@ namespace Tests\Feature;
use App\Http\Requests\StoreRecordingRequest;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingUploadTest extends TestCase
@@ -54,18 +60,17 @@ class RecordingUploadTest extends TestCase
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
$response = $this->post(route('recordings.store'), [
'audio' => [$file],
'title' => 'Team meeting',
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$response->assertRedirect(route('recordings.show', $recording));
$this->assertSame('Team meeting', $recording->title);
$this->assertSame('meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
$this->assertSame('local', $recording->transcription_driver);
$this->assertNull($recording->transcription_percent);
$this->assertNotNull($recording->transcription_started_at);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
@@ -76,16 +81,13 @@ class RecordingUploadTest extends TestCase
Storage::fake('local');
Bus::fake();
$response = $this->post(route('recordings.store'), [
'audio' => [
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
],
]);
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
])
->assertRedirect(route('recordings.index'));
$this->assertSame(3, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 3);
@@ -99,9 +101,9 @@ class RecordingUploadTest extends TestCase
{
$this->get(route('recordings.create'))
->assertOk()
->assertSee('Drop audio files here')
->assertSee('Drop audio files here or click to browse')
->assertSee('max 2 GB each', false)
->assertSee('name="audio[]"', false);
->assertSeeLivewire('upload-recordings');
}
public function test_files_larger_than_two_gigabytes_are_rejected(): void
@@ -113,11 +115,9 @@ class RecordingUploadTest extends TestCase
->create('huge.mp3', 10, 'audio/mpeg')
->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1);
$this->from(route('recordings.create'))
->post(route('recordings.store'), [
'audio' => [$file],
])
->assertSessionHasErrors(['audio.0']);
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertHasErrors(['audio.0']);
$this->assertSame(0, Recording::query()->count());
Bus::assertNothingDispatched();
@@ -133,15 +133,14 @@ class RecordingUploadTest extends TestCase
['clip.ogg', 'audio/ogg', 'ogg-bytes'],
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) {
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)],
'title' => $name,
]);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
->assertRedirect();
$recording = Recording::query()->where('title', $name)->first();
$expectedTitle = pathinfo($name, PATHINFO_FILENAME);
$recording = Recording::query()->where('title', $expectedTitle)->first();
$this->assertNotNull($recording, "Failed uploading {$name}");
$response->assertRedirect(route('recordings.show', $recording));
Storage::disk('local')->assertExists($recording->file_path);
}
@@ -152,11 +151,9 @@ class RecordingUploadTest extends TestCase
{
Storage::fake('local');
$this->from(route('recordings.create'))
->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')],
])
->assertSessionHasErrors(['audio.0']);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')])
->assertHasErrors(['audio.0']);
}
public function test_user_can_queue_local_transcription(): void
@@ -173,8 +170,8 @@ class RecordingUploadTest extends TestCase
// Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -207,6 +204,333 @@ class RecordingUploadTest extends TestCase
->assertJsonPath('driver_label', 'Local (faster-whisper)');
}
public function test_transcription_status_endpoint_includes_whisper_snapshot(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Verbose status',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 40,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcription_verbose' => [
'language' => 'en',
'duration' => 8.5,
'segments' => [
['text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
],
],
]);
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('whisper.language', 'en')
->assertJsonPath('whisper.duration', 8.5)
->assertJsonPath('whisper.segments.0.text', 'Hello');
}
public function test_transcription_service_calls_local_whisper_http(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
Http::fake([
'*' => Http::response(['text' => 'Hello from whisper.']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Long whisper',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$text = app(TranscriptionService::class)->transcribe($recording);
$this->assertSame('Hello from whisper.', $text);
Http::assertSent(function ($request): bool {
$body = $request->body();
return str_contains($request->url(), '/audio/transcriptions')
&& str_contains($body, 'name="stream"')
&& str_contains($body, 'true')
&& str_contains($body, 'verbose_json')
&& str_contains($body, 'timestamp_granularities[]')
&& str_contains($body, 'word');
});
}
public function test_transcription_service_streams_sse_progress_and_text(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \",\"end\":10}\n\n"
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\",\"end\":30}\n\n"
."data: {\"type\":\"transcript.text.done\",\"text\":\"Hello from whisper.\",\"end\":40}\n\n";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Streamed whisper',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'duration_seconds' => 40,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$partials = [];
$text = app(TranscriptionService::class)->transcribe(
$recording,
function (string $message, int $percent, ?string $partial = null) use (&$partials): void {
$partials[] = [
'percent' => $percent,
'partial' => $partial,
];
},
);
$this->assertSame('Hello from whisper.', $text);
$this->assertNotEmpty(array_filter($partials, fn (array $row): bool => $row['partial'] === 'Hello '));
$this->assertSame('Hello from whisper.', $partials[array_key_last($partials)]['partial'] ?? $text);
$this->assertContains(0, array_column($partials, 'percent'));
$this->assertContains(25, array_column($partials, 'percent'));
$this->assertContains(99, array_column($partials, 'percent'));
$this->assertLessThan(100, max(array_column($partials, 'percent')));
}
public function test_transcription_service_streams_legacy_segments_with_timestamp_percent(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
$sse = "data: {\"text\":\"Hello from\",\"start\":0,\"end\":20}\n\n"
."data: {\"text\":\"whisper.\",\"start\":20,\"end\":40}\n\n";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Legacy stream',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'duration_seconds' => 40,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$percents = [];
$partials = [];
$text = app(TranscriptionService::class)->transcribe(
$recording,
function (string $message, int $percent, ?string $partial = null) use (&$percents, &$partials): void {
$percents[] = $percent;
if ($partial !== null) {
$partials[] = $partial;
}
},
);
$this->assertSame('Hello from whisper.', $text);
$this->assertContains('Hello from', $partials);
$this->assertContains(50, $percents);
$this->assertContains(99, $percents);
}
public function test_transcription_service_streams_verbose_segment_metadata(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
$segment = [
'id' => 0,
'start' => 0.0,
'end' => 20.0,
'text' => 'Hello from whisper.',
'tokens' => [50364, 2425],
'avg_logprob' => -0.18,
'compression_ratio' => 1.2,
'no_speech_prob' => 0.02,
'language' => 'en',
'duration' => 40.0,
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.96],
],
];
$sse = 'data: '.json_encode($segment)."\n\n";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Verbose stream',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'duration_seconds' => 40,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$whispers = [];
$text = app(TranscriptionService::class)->transcribe(
$recording,
function (string $message, int $percent, ?string $partial = null, ?array $whisper = null) use (&$whispers): void {
if ($whisper !== null) {
$whispers[] = $whisper;
}
},
);
$this->assertSame('Hello from whisper.', $text);
$this->assertNotEmpty($whispers);
$this->assertSame('en', $whispers[0]['language']);
$this->assertSame(-0.18, $whispers[0]['segment']['avg_logprob']);
$this->assertSame('Hello', $whispers[0]['segment']['words'][0]['word']);
}
public function test_transcription_service_does_not_invent_percent_when_events_lack_timestamps(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
$this->freezeTime();
$sse = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello \"}\n\n"
."data: {\"type\":\"transcript.text.delta\",\"delta\":\"from whisper.\"}\n\n";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'No timestamp percent',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'duration_seconds' => 40,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now()->subSeconds(20),
]);
$partials = [];
$text = app(TranscriptionService::class)->transcribe(
$recording,
function (string $message, int $percent, ?string $partial = null) use (&$partials): void {
$partials[] = [
'percent' => $percent,
'partial' => $partial,
];
},
);
$this->assertSame('Hello from whisper.', $text);
$this->assertContains('Hello ', array_column($partials, 'partial'));
$this->assertContains('Hello from whisper.', array_column($partials, 'partial'));
$this->assertSame([0], array_values(array_unique(array_column($partials, 'percent'))));
}
public function test_transcription_service_uses_word_timestamps_for_percent(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
$sse = 'data: '.json_encode([
'type' => 'transcript.text.delta',
'delta' => 'Hello ',
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 10.0],
],
])."\n\n"
.'data: '.json_encode([
'type' => 'transcript.text.delta',
'delta' => 'from whisper.',
'words' => [
['word' => 'from', 'start' => 10.0, 'end' => 20.0],
['word' => 'whisper.', 'start' => 20.0, 'end' => 30.0],
],
])."\n\n";
Http::fake([
'*' => Http::response($sse, 200, ['Content-Type' => 'text/event-stream']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Word timestamps',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'duration_seconds' => 40,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$percents = [];
$text = app(TranscriptionService::class)->transcribe(
$recording,
function (string $message, int $percent, ?string $partial = null) use (&$percents): void {
$percents[] = $percent;
},
);
$this->assertSame('Hello from whisper.', $text);
$this->assertContains(25, $percents);
$this->assertContains(75, $percents);
$this->assertLessThan(100, max($percents));
}
public function test_report_progress_persists_partial_transcript_without_completing(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Partial',
'original_filename' => 'partial.mp3',
'file_path' => 'recordings/partial.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcript' => 'Previous transcript',
]);
$recording->reportProgress('Transcribing locally…', 62, partialTranscript: 'Hello from the ');
$recording->refresh();
$this->assertSame('processing', $recording->transcription_status);
$this->assertSame(62, $recording->transcription_percent);
$this->assertSame('Hello from the ', $recording->transcript);
$this->assertNull($recording->transcribed_at);
$this->assertTrue($recording->transcriptionStatusPayload()['has_transcript']);
$this->assertSame('Hello from the ', $recording->transcriptionStatusPayload()['transcript']);
}
public function test_transcription_job_stores_transcript(): void
{
Storage::fake('local');
@@ -214,6 +538,8 @@ class RecordingUploadTest extends TestCase
Transcription::fake(['Hello from the recorder.']);
$this->travelTo(now()->startOfSecond());
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Sample',
@@ -222,6 +548,7 @@ class RecordingUploadTest extends TestCase
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
'transcription_started_at' => now()->subSeconds(42),
]);
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
@@ -232,6 +559,12 @@ class RecordingUploadTest extends TestCase
$this->assertSame(100, $recording->transcription_percent);
$this->assertSame('Transcription complete', $recording->transcription_progress);
$this->assertNotNull($recording->transcribed_at);
$this->assertSame(
$recording->transcribed_at->getTimestamp() - $recording->transcription_started_at->getTimestamp(),
$recording->transcription_duration_seconds,
);
$this->assertSame(42, $recording->transcription_duration_seconds);
$this->assertSame('42s', $recording->transcriptionStatusPayload()['transcription_duration_human']);
}
public function test_transcription_job_stores_error_on_failure(): void
@@ -288,7 +621,70 @@ class RecordingUploadTest extends TestCase
$recording->refresh();
$this->assertSame('failed', $recording->transcription_status);
$this->assertStringContainsString('worker stopped', $recording->transcription_error);
$this->assertStringContainsString('timed out', $recording->transcription_error);
}
public function test_stale_reserved_job_is_treated_as_orphaned(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Wedged whisper',
'original_filename' => 'wedged.mp3',
'file_path' => 'recordings/wedged.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 50,
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
$job = new TranscribeRecording($recording);
$payload = json_encode([
'displayName' => TranscribeRecording::class,
'data' => [
'command' => serialize($job),
],
], JSON_THROW_ON_ERROR);
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => $payload,
'attempts' => 1,
'reserved_at' => now()->subMinutes(15)->timestamp,
'available_at' => now()->subMinutes(20)->timestamp,
'created_at' => now()->subMinutes(20)->timestamp,
]);
$this->assertFalse($recording->hasActiveTranscriptionJob());
$this->assertTrue($recording->isOrphanedTranscription());
$deleted = $recording->discardQueuedTranscriptionJobs();
$this->assertSame(1, $deleted, 'stale reserved job should be discarded');
$this->assertDatabaseCount('jobs', 0);
// Put the stale job back to exercise status-poll recovery.
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => $payload,
'attempts' => 1,
'reserved_at' => now()->subMinutes(15)->timestamp,
'available_at' => now()->subMinutes(20)->timestamp,
'created_at' => now()->subMinutes(20)->timestamp,
]);
$recording->forceFill([
'transcription_status' => 'processing',
'transcription_error' => null,
])->save();
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'failed');
$this->assertDatabaseCount('jobs', 0);
$recording->refresh();
$this->assertSame('failed', $recording->transcription_status);
}
public function test_recent_processing_is_not_marked_orphaned(): void
@@ -332,8 +728,8 @@ class RecordingUploadTest extends TestCase
'updated_at' => now()->subMinutes(10),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -355,9 +751,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now(),
]);
$this->post(route('recordings.transcribe.cancel', $recording))
->assertRedirect()
->assertSessionHas('success');
Livewire::test(Show::class, ['recording' => $recording])
->call('cancelTranscription');
$recording->refresh();
$this->assertSame('cancelled', $recording->transcription_status);
@@ -380,9 +775,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now()->subMinute(),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect()
->assertSessionHas('success');
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -464,8 +858,8 @@ class RecordingUploadTest extends TestCase
'transcribed_at' => now()->subHour(),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript);
@@ -497,10 +891,8 @@ class RecordingUploadTest extends TestCase
'transcript' => 'Finished text',
]);
$this->from(route('recordings.index'))
->post(route('recordings.transcribe-pending'))
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
+376
View File
@@ -0,0 +1,376 @@
<?php
namespace Tests\Feature\Recordings;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Models\Recording;
use App\Models\User;
use App\Services\DiskSpaceService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class IndexTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->mock(DiskSpaceService::class, function ($mock): void {
$mock->shouldReceive('snapshot')->andReturn(null);
});
}
public function test_index_page_renders_as_livewire(): void
{
$user = User::factory()->create();
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Pocket note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 1024,
'transcription_status' => 'done',
'transcript' => 'hello world',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire(Index::class)
->assertSee('Pocket note');
}
public function test_empty_index_shows_upload_dropzone(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire('upload-recordings')
->assertSee('Drop audio files here or click to browse')
->assertDontSee('No recordings yet.')
->assertDontSee('Upload your first MP3');
}
public function test_search_filters_recordings(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Office chat',
'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => "talking about the pocket recorder today\nsecond line stays hidden",
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Unrelated',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'nothing useful',
]);
Livewire::test(Index::class)
->set('search', 'pocket recorder')
->assertSee('Office chat')
->assertSee('talking about the pocket recorder today')
->assertDontSee('second line stays hidden')
->assertDontSee('Unrelated');
}
public function test_queue_pending_dispatches_jobs(): void
{
Bus::fake();
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
public function test_index_polls_while_transcriptions_are_active(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'In progress',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 40,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Index::class)
->assertSee('wire:poll', false)
->assertDontSee('echo-private', false)
->assertSee('Transcribing')
->assertSee('40%')
->assertDontSee('Transcribing locally…')
->assertSeeHtml('bg-amber-400');
}
public function test_processing_recordings_do_not_show_zero_percent(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Just started',
'original_filename' => 'fresh.mp3',
'file_path' => 'recordings/fresh.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 0,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Index::class)
->assertSee('Just started')
->assertSee('Transcribing')
->assertDontSeeHtml('tabular-nums text-xs text-zinc-500');
}
public function test_queued_recordings_do_not_show_fake_percent(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Waiting in line',
'original_filename' => 'queued.mp3',
'file_path' => 'recordings/queued.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Index::class)
->assertSee('Waiting in line')
->assertSee('Queued')
->assertDontSee('Queued — waiting to start…')
->assertDontSee('5%')
->assertSeeHtml('bg-zinc-400/15')
->assertDontSeeHtml('bg-amber-400');
}
public function test_index_does_not_poll_when_all_transcriptions_are_idle(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Finished',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'all done',
]);
Livewire::test(Index::class)
->assertDontSee('wire:poll', false);
}
public function test_user_can_delete_recording_from_index(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete from list',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->assertSee('Delete from list')
->call('delete', $recording->id)
->assertDontSee('Delete from list');
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
Storage::disk('local')->assertMissing('recordings/delete-me.mp3');
}
public function test_user_can_delete_all_recordings_from_index(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/one.mp3', 'one');
Storage::disk('local')->put('recordings/two.mp3', 'two');
Storage::disk('local')->put('recordings/other.mp3', 'other');
$user = User::factory()->create();
$other = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Mine one',
'original_filename' => 'one.mp3',
'file_path' => 'recordings/one.mp3',
'file_size_bytes' => 3,
'transcription_status' => 'done',
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Mine two',
'original_filename' => 'two.mp3',
'file_path' => 'recordings/two.mp3',
'file_size_bytes' => 3,
'transcription_status' => 'pending',
]);
Recording::query()->create([
'user_id' => $other->id,
'title' => 'Someone else',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->assertSee('Delete all')
->call('deleteAll')
->assertDontSee('Mine one')
->assertDontSee('Mine two');
$this->assertDatabaseMissing('recordings', ['user_id' => $user->id]);
$this->assertDatabaseHas('recordings', ['user_id' => $other->id, 'title' => 'Someone else']);
Storage::disk('local')->assertMissing('recordings/one.mp3');
Storage::disk('local')->assertMissing('recordings/two.mp3');
Storage::disk('local')->assertExists('recordings/other.mp3');
}
public function test_user_cannot_delete_another_users_recording_from_index(): void
{
Storage::fake('local');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Keep me',
'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'done',
]);
$this->actingAs($intruder);
try {
Livewire::test(Index::class)
->call('delete', $recording->id);
$this->fail('Expected deleting another user\'s recording to fail.');
} catch (ModelNotFoundException) {
// Owned-query findOrFail hides other users' recordings.
}
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
}
public function test_recordings_can_be_sorted_by_title(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Zebra',
'original_filename' => 'z.mp3',
'file_path' => 'recordings/z.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'created_at' => now()->subDay(),
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Alpha',
'original_filename' => 'a.mp3',
'file_path' => 'recordings/a.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'created_at' => now(),
]);
Livewire::test(Index::class)
->call('sort', 'title')
->assertSet('sortBy', 'title')
->assertSet('sortDirection', 'asc')
->assertSeeInOrder(['Alpha', 'Zebra'])
->call('sort', 'title')
->assertSet('sortDirection', 'desc')
->assertSeeInOrder(['Zebra', 'Alpha']);
}
public function test_invalid_sort_column_is_ignored(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Only one',
'original_filename' => 'one.mp3',
'file_path' => 'recordings/one.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
]);
Livewire::test(Index::class)
->call('sort', 'not_a_column')
->assertSet('sortBy', 'uploaded')
->assertSet('sortDirection', 'desc');
}
}
+283
View File
@@ -0,0 +1,283 @@
<?php
namespace Tests\Feature\Recordings;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class ShowTest extends TestCase
{
use RefreshDatabase;
public function test_show_page_renders_as_livewire(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Show me',
'original_filename' => 'show.mp3',
'file_path' => 'recordings/show.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Finished text',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSeeLivewire(Show::class)
->assertSee('Show me')
->assertSee('Play', false);
}
public function test_user_can_delete_own_recording(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete me',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('delete')
->assertRedirect(route('recordings.index'));
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
}
public function test_show_uses_alpine_hydrate_while_transcription_is_queued(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'In progress',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_percent' => null,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertDontSee('wire:poll', false)
->assertSee('wire:ignore', false)
->assertSee('transcriptionMonitor', false)
->assertSee('Queued — waiting to start…');
}
public function test_show_does_not_use_livewire_poll_while_transcription_is_processing(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Live stream',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 20,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertDontSee('wire:poll', false)
->assertSee('wire:ignore', false)
->assertSee('userId', false);
}
public function test_show_page_includes_partial_transcript_while_processing(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Live words',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 62,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcript' => 'Live partial sentence from whisper',
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertSee('Live partial sentence from whisper')
->assertSee('status.transcript', false);
}
public function test_show_page_includes_live_whisper_segment_bindings(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Verbose live',
'original_filename' => 'active.mp3',
'file_path' => 'recordings/active.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcript' => 'Hello',
'transcription_verbose' => [
'language' => 'en',
'duration' => 4.0,
'segments' => [
[
'text' => 'Hello',
'start' => 0.0,
'end' => 1.0,
'avg_logprob' => -0.1,
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'probability' => 0.9],
],
],
],
],
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertSee('whisperWords', false)
->assertSee('whisper.language', false)
->assertSee('Hello');
}
public function test_show_does_not_poll_when_transcription_is_idle(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Finished',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'all done',
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertDontSee('wire:poll', false);
}
public function test_show_poll_refresh_picks_up_processing_status(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Live update',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
'transcription_progress' => 'Queued — waiting to start…',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$component = Livewire::test(Show::class, ['recording' => $recording]);
$recording->update([
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 40,
]);
$component
->call('$refresh')
->assertSet('recording.transcription_status', 'processing')
->assertSet('recording.transcription_percent', 40)
->assertSee('Transcribing locally…');
}
public function test_show_poll_refresh_picks_up_completed_transcript(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Almost done',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 90,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcript' => 'Partial',
]);
$component = Livewire::test(Show::class, ['recording' => $recording]);
$recording->update([
'transcription_status' => 'done',
'transcription_percent' => 100,
'transcript' => 'Finished live transcript',
'transcribed_at' => now(),
]);
$component
->call('$refresh')
->assertSet('recording.transcription_status', 'done')
->assertSet('recording.transcription_percent', 100)
->assertSee('Finished live transcript');
}
public function test_show_alpine_root_key_does_not_include_percent(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Stable key',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 62,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->assertSee('wire:ignore', false)
->assertSee('transcription-ui-'.$recording->id.'-processing-', false)
->assertDontSee('transcription-ui-'.$recording->id.'-processing-62', false);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ReverbClientConfigTest extends TestCase
{
public function test_login_page_exposes_environment_reverb_client_settings(): void
{
$this->get(route('login'))
->assertOk()
->assertSee('<meta name="reverb-key" content="testing-reverb-key">', false)
->assertSee('<meta name="reverb-host" content="reverb.testing.example">', false)
->assertSee('<meta name="reverb-port" content="443">', false)
->assertSee('<meta name="reverb-scheme" content="https">', false);
}
public function test_login_page_reflects_runtime_reverb_client_config(): void
{
config([
'reverb.client.key' => 'stage-reverb-key',
'reverb.client.host' => 'reverb.stage.transcribe.z00.nu',
'reverb.client.port' => '443',
'reverb.client.scheme' => 'https',
]);
$this->get(route('login'))
->assertOk()
->assertSee('<meta name="reverb-key" content="stage-reverb-key">', false)
->assertSee('<meta name="reverb-host" content="reverb.stage.transcribe.z00.nu">', false);
}
}
@@ -158,5 +158,223 @@ class TranscriptionBroadcastTest extends TestCase
$this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs());
$this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name);
$this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name);
}
public function test_broadcast_payload_omits_transcript_text(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Long text',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_percent' => 70,
'transcript' => str_repeat('word ', 5000),
]);
$payload = (new RecordingTranscriptionUpdated($recording))->broadcastWith();
$this->assertArrayNotHasKey('transcript', $payload);
$this->assertArrayNotHasKey('whisper', $payload);
$this->assertTrue($payload['has_transcript']);
$this->assertSame(70, $payload['percent']);
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
}
public function test_report_progress_broadcasts_transcript_deltas_for_append(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Deltas',
'original_filename' => 'delta.mp3',
'file_path' => 'recordings/delta.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
'transcript' => 'Previous finished transcript',
]);
$recording->reportProgress('Transcribing locally…', 55, partialTranscript: 'Hello ');
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['transcript_delta'] ?? null) === 'Hello '
&& ($payload['transcript_replace'] ?? false) === true
&& ! array_key_exists('transcript', $payload);
});
$recording->refresh()->reportProgress('Transcribing locally…', 60, partialTranscript: 'Hello from whisper.');
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['transcript_delta'] ?? null) === 'from whisper.'
&& ($payload['transcript_replace'] ?? true) === false;
});
}
public function test_report_progress_broadcasts_whisper_delta_without_full_verbose_snapshot(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Verbose live',
'original_filename' => 'delta.mp3',
'file_path' => 'recordings/delta.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: 'Hello', whisperDelta: [
'language' => 'en',
'duration' => 12.5,
'segment' => [
'id' => 0,
'start' => 0.0,
'end' => 1.2,
'text' => 'Hello',
'avg_logprob' => -0.2,
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 1.2, 'probability' => 0.91],
],
],
]);
$recording->refresh();
$this->assertSame('en', $recording->transcription_verbose['language']);
$this->assertCount(1, $recording->transcription_verbose['segments']);
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['whisper_delta']['language'] ?? null) === 'en'
&& ($payload['whisper_delta']['segment']['text'] ?? null) === 'Hello'
&& ! array_key_exists('whisper', $payload)
&& ! array_key_exists('transcript', $payload)
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? []);
});
}
public function test_full_whisper_segments_snapshot_is_persisted_but_not_broadcast(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Snapshot',
'original_filename' => 'snap.mp3',
'file_path' => 'recordings/snap.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 90, partialTranscript: 'Hello world', whisperDelta: [
'language' => 'en',
'duration' => 4.2,
'segments' => [
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
['id' => 1, 'text' => 'world', 'start' => 1.0, 'end' => 2.0],
],
]);
$recording->refresh();
$this->assertCount(2, $recording->transcription_verbose['segments']);
Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool {
$payload = $event->broadcastWith();
return ($payload['whisper_delta']['language'] ?? null) === 'en'
&& ($payload['whisper_delta']['duration'] ?? null) === 4.2
&& ! array_key_exists('segments', $payload['whisper_delta'] ?? [])
&& ! array_key_exists('whisper', $payload);
});
}
public function test_report_progress_replaces_verbose_segments_when_snapshot_arrives(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Replace segments',
'original_filename' => 'replace.mp3',
'file_path' => 'recordings/replace.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 40, whisperDelta: [
'segment' => ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0],
]);
$recording->refresh()->reportProgress('Transcribing locally…', 99, whisperDelta: [
'language' => 'en',
'segments' => [
['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'avg_logprob' => -0.1],
],
]);
$this->assertCount(1, $recording->refresh()->transcription_verbose['segments']);
$this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']);
}
public function test_consecutive_live_whisper_segments_are_appended(): void
{
Event::fake([RecordingTranscriptionUpdated::class]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Append live',
'original_filename' => 'append.mp3',
'file_path' => 'recordings/append.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
$recording->reportProgress('Transcribing locally…', 40, partialTranscript: "didn't I wasn't aware", whisperDelta: [
'language' => 'en',
'segment' => ['id' => 0, 'text' => "didn't I wasn't aware", 'start' => 254.5, 'end' => 260.8],
]);
$recording->refresh()->reportProgress('Transcribing locally…', 45, partialTranscript: "didn't I wasn't aware other people", whisperDelta: [
'language' => 'en',
'segment' => ['id' => 0, 'text' => 'other people', 'start' => 260.8, 'end' => 267.4],
]);
$recording->refresh();
$this->assertCount(2, $recording->transcription_verbose['segments']);
$this->assertSame('other people', $recording->transcription_verbose['segments'][1]['text']);
}
public function test_oversized_transcript_delta_is_not_broadcast(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Huge delta',
'original_filename' => 'huge.mp3',
'file_path' => 'recordings/huge.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
]);
$huge = str_repeat('a', RecordingTranscriptionUpdated::MAX_DELTA_BYTES + 1);
$payload = (new RecordingTranscriptionUpdated($recording, $huge, true))->broadcastWith();
$this->assertArrayNotHasKey('transcript_delta', $payload);
$this->assertArrayNotHasKey('transcript', $payload);
$this->assertLessThan(10_000, strlen((string) json_encode($payload)));
}
}
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class UploadRecordingsLivewireTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_dropping_files_saves_and_queues_transcription(): void
{
Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$this->assertSame('meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_batch_upload_redirects_to_index(): void
{
Storage::fake('local');
Bus::fake();
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
])
->assertRedirect(route('recordings.index'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
}
+7 -1
View File
@@ -6,5 +6,11 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
protected function setUp(): void
{
parent::setUp();
// Feature tests render Blade without a Vite build in CI.
$this->withoutVite();
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class SetupStageScriptTest extends TestCase
{
public function test_clones_from_gitea_and_retargets_origin(): void
{
$script = file_get_contents(dirname(__DIR__, 2).'/scripts/setup-stage.sh');
$this->assertIsString($script);
$this->assertStringContainsString('git clone "${GITEA_REPO_URL}" "${STAGE_PATH}"', $script);
$this->assertStringContainsString('git remote set-url origin "${GITEA_REPO_URL}"', $script);
$this->assertStringNotContainsString('git clone "${PROD_PATH}"', $script);
$this->assertStringContainsString('APP_HOST_PORT" "18280"', $script);
$this->assertStringNotContainsString('APP_HOST_PORT" "18180"', $script);
}
public function test_deploy_fetches_advertised_refs_before_sha(): void
{
$script = file_get_contents(dirname(__DIR__, 2).'/scripts/deploy-production.sh');
$this->assertIsString($script);
$this->assertStringContainsString("git fetch --force origin '+refs/heads/*:refs/remotes/origin/*'", $script);
$this->assertStringContainsString('git checkout --force --detach "${DEPLOY_SHA}"', $script);
}
}
@@ -0,0 +1,250 @@
<?php
namespace Tests\Unit;
use App\Services\WhisperTranscriptionStream;
use PHPUnit\Framework\TestCase;
class WhisperTranscriptionStreamTest extends TestCase
{
private WhisperTranscriptionStream $stream;
protected function setUp(): void
{
parent::setUp();
$this->stream = new WhisperTranscriptionStream;
}
public function test_extracts_sse_data_payloads_from_buffer(): void
{
$buffer = "data: {\"type\":\"transcript.text.delta\",\"delta\":\"Hello\"}\n\npartial";
$payloads = $this->stream->extractPayloads($buffer);
$this->assertSame(['{"type":"transcript.text.delta","delta":"Hello"}'], $payloads);
$this->assertSame('partial', $buffer);
}
public function test_parses_openai_delta_and_done_events(): void
{
$delta = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"Hello "}');
$done = $this->stream->parseEvent('{"type":"transcript.text.done","text":"Hello world"}');
$this->assertSame('Hello ', $delta['append']);
$this->assertFalse($delta['done']);
$this->assertSame('Hello world', $done['replace']);
$this->assertTrue($done['done']);
}
public function test_parses_legacy_segment_events(): void
{
$event = $this->stream->parseEvent('{"text":"First segment","start":0,"end":12.5}');
$this->assertTrue($event['legacy']);
$this->assertSame('First segment', $event['append']);
$this->assertSame(12.5, $event['end']);
$this->assertSame('First segment', $event['whisper']['segment']['text']);
$this->assertSame(12.5, $event['whisper']['segment']['end']);
}
public function test_parses_typed_segment_events_as_legacy_chunks(): void
{
$event = $this->stream->parseEvent(json_encode([
'type' => 'segment',
'start' => 0.0,
'end' => 2.4,
'text' => 'Hello, how are you?',
'avg_logprob' => -0.12,
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.99],
],
]));
$this->assertTrue($event['legacy']);
$this->assertSame('Hello, how are you?', $event['append']);
$this->assertSame(2.4, $event['end']);
$this->assertSame(-0.12, $event['whisper']['segment']['avg_logprob']);
$this->assertSame('Hello', $event['whisper']['segment']['words'][0]['word']);
}
public function test_parses_verbose_segment_words_language_and_logprobs(): void
{
$json = json_encode([
'language' => 'en',
'duration' => 8.5,
'text' => 'Hello world',
'id' => 0,
'start' => 0.0,
'end' => 1.6,
'tokens' => [50364, 2425, 1002],
'avg_logprob' => -0.21,
'compression_ratio' => 1.15,
'no_speech_prob' => 0.01,
'temperature' => 0.0,
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 0.6, 'probability' => 0.94],
['word' => 'world', 'start' => 0.7, 'end' => 1.5, 'probability' => 0.88],
],
]);
$event = $this->stream->parseEvent($json);
$this->assertSame('en', $event['whisper']['language']);
$this->assertSame(8.5, $event['whisper']['duration']);
$this->assertSame([50364, 2425, 1002], $event['whisper']['segment']['tokens']);
$this->assertSame(-0.21, $event['whisper']['segment']['avg_logprob']);
$this->assertCount(2, $event['whisper']['segment']['words']);
$this->assertSame('Hello', $event['whisper']['segment']['words'][0]['word']);
}
public function test_parses_openai_delta_logprobs_without_treating_them_as_a_segment(): void
{
$event = $this->stream->parseEvent(json_encode([
'type' => 'transcript.text.delta',
'delta' => 'Hel',
'logprobs' => [
['token' => 'Hel', 'logprob' => -0.05],
],
]));
$this->assertSame('Hel', $event['append']);
$this->assertArrayNotHasKey('segment', $event['whisper']);
$this->assertSame('Hel', $event['whisper']['logprobs'][0]['token']);
$this->assertSame(-0.05, $event['whisper']['logprobs'][0]['logprob']);
}
public function test_applies_delta_then_done_replace(): void
{
$delta = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"Hel"}');
$text = $this->stream->applyEvent('', $delta);
$delta2 = $this->stream->parseEvent('{"type":"transcript.text.delta","delta":"lo"}');
$text = $this->stream->applyEvent($text, $delta2);
$done = $this->stream->parseEvent('{"type":"transcript.text.done","text":"Hello world"}');
$text = $this->stream->applyEvent($text, $done);
$this->assertSame('Hello world', $text);
}
public function test_parses_verbose_json_segments_array(): void
{
$event = $this->stream->parseEvent(json_encode([
'task' => 'transcribe',
'language' => 'en',
'duration' => 4.2,
'text' => 'Hello world',
'segments' => [
[
'id' => 0,
'start' => 0.0,
'end' => 4.2,
'text' => ' Hello world',
'avg_logprob' => -0.3,
'tokens' => [1, 2],
'words' => [
['word' => ' Hello', 'start' => 0.0, 'end' => 0.5, 'probability' => 0.9],
],
],
],
]));
$this->assertSame('Hello world', $event['replace']);
$this->assertSame(4.2, $event['end']);
$this->assertSame('en', $event['whisper']['language']);
$this->assertCount(1, $event['whisper']['segments']);
$this->assertSame(-0.3, $event['whisper']['segments'][0]['avg_logprob']);
}
public function test_live_segment_with_nested_segments_array_appends_instead_of_replacing(): void
{
$first = $this->stream->parseEvent(json_encode([
'language' => 'en',
'duration' => 6.3,
'id' => 0,
'start' => 254.5,
'end' => 260.8,
'text' => "didn't I wasn't aware",
'segments' => [
['id' => 0, 'start' => 254.5, 'end' => 260.8, 'text' => "didn't I wasn't aware"],
],
]));
$second = $this->stream->parseEvent(json_encode([
'language' => 'en',
'duration' => 6.96,
'id' => 0,
'start' => 260.8,
'end' => 267.4,
'text' => ' other people so I am trying',
'segments' => [
['id' => 0, 'start' => 260.8, 'end' => 267.4, 'text' => ' other people so I am trying'],
],
]));
$this->assertTrue($first['legacy']);
$this->assertNull($first['replace']);
$this->assertArrayNotHasKey('segments', $first['whisper']);
$this->assertSame("didn't I wasn't aware", $first['whisper']['segment']['text']);
$text = $this->stream->applyEvent('', $first);
$text = $this->stream->applyEvent($text, $second);
$this->assertSame("didn't I wasn't aware other people so I am trying", $text);
}
public function test_joins_legacy_segments_with_spaces(): void
{
$first = $this->stream->parseEvent('{"text":"Hello from","end":10}');
$second = $this->stream->parseEvent('{"text":"whisper.","end":20}');
$text = $this->stream->applyEvent('', $first);
$text = $this->stream->applyEvent($text, $second);
$this->assertSame('Hello from whisper.', $text);
}
public function test_ignores_done_sentinel_and_non_json(): void
{
$buffer = "data: [DONE]\n\ndata: not-json\n\n";
$payloads = $this->stream->extractPayloads($buffer);
$this->assertSame(['not-json'], $payloads);
$this->assertNull($this->stream->parseEvent('not-json'));
}
public function test_flush_buffer_emits_trailing_frame(): void
{
$buffer = 'data: {"text":"Last"}';
$payloads = $this->stream->flushBuffer($buffer);
$this->assertSame(['{"text":"Last"}'], $payloads);
$this->assertSame('', $buffer);
}
public function test_openai_delta_uses_word_end_as_audio_position(): void
{
$event = $this->stream->parseEvent(json_encode([
'type' => 'transcript.text.delta',
'delta' => 'Hello',
'words' => [
['word' => 'Hello', 'start' => 0.0, 'end' => 1.6, 'probability' => 0.9],
],
]));
$this->assertSame(1.6, $event['end']);
$this->assertSame('Hello', $event['whisper']['words'][0]['word']);
}
public function test_file_duration_is_not_used_as_audio_position(): void
{
$event = $this->stream->parseEvent(json_encode([
'type' => 'transcript.text.delta',
'delta' => 'Hello',
'duration' => 40.0,
]));
$this->assertNull($event['end']);
$this->assertSame(40.0, $event['whisper']['duration']);
}
}