Audio and video transcription platform with real-time progress and automatic translation. A file is uploaded, its audio is extracted, transcribed by Whisper (cloud-first with a local fallback), optionally translated, and streamed back to the browser step by step over WebSocket.
Scriptum is a full-stack application I designed and built solo as a portfolio project, focused on a real engineering problem: turning a long-running, multi-step, potentially unreliable external-API workflow (audio extraction, cloud transcription, translation) into a responsive product experience.
What it demonstrates
- Backend architecture: A Spring Boot 3 REST API with a clean layered design (controller → service → repository), JPA/Hibernate persistence on PostgreSQL, and centralised error handling.
- Resilient transcription pipeline: Audio/video is extracted and chunked with ffmpeg, then transcribed through the Groq Whisper API. If Groq is unavailable or rate-limited, the pipeline automatically falls back to a self-hosted
faster-whispermicroservice, no manual intervention needed. - Correct use of async in Spring: The transcription pipeline runs in a dedicated
@Asyncservice, separated from the request-handling service, to avoid the classic self-invocation pitfall that silently disables@Asyncproxying. - Real-time UX: Job progress (extracting → transcribing → translating → done) is pushed live to the Angular frontend over WebSocket (STOMP).
- Stateless security: JWT authentication (HMAC-SHA256), BCrypt password hashing, and per-user data isolation (a user only ever sees their own transcription jobs).
- Scheduled housekeeping: A scheduled task automatically purges temporary media files from disk after a configurable retention period.
- Modern frontend: An Angular 18 standalone-components SPA styled with Tailwind CSS, built around a custom design system (a "vintage manuscript / typewriter" visual identity), with drag-and-drop upload, live progress, and a searchable archive of past transcriptions.
Stack: Java 21 · Spring Boot 3.5 · Spring Security · Spring Data JPA · WebSocket (STOMP) · PostgreSQL · Angular 18 · Tailwind CSS · RxJS · Python · FastAPI · faster-whisper · Groq API · Docker
- Architecture
- Tech stack
- Project structure
- Data model
- Transcription job lifecycle
- Transcription pipeline & fallback strategy
- REST API reference
- WebSocket real-time progress
- Angular frontend
- JWT authentication
- Environment variables
- Running locally
- API documentation
- Scheduled cleanup
- Error handling
- Roadmap
scriptum/
├── scriptum-api/ Spring Boot backend (REST + WebSocket)
├── scriptum-ui/ Angular frontend (SPA)
├── scriptum-whisper/ Python microservice (FastAPI + faster-whisper)
└── docker-compose.yml PostgreSQL, backend, frontend, whisper service
Angular client ──HTTPS/WS──▶ Spring Boot API ──JDBC──▶ PostgreSQL
│
├──▶ Groq API (primary transcription, Whisper large-v3)
├──▶ scriptum-whisper (local fallback, faster-whisper)
└──▶ MyMemory API (translation)
- The Angular frontend uploads the media file to the Spring Boot backend.
- The backend stores the file, creates a
TranscriptionJobinPENDINGstatus, and immediately returns its id, the heavy processing happens asynchronously. - A dedicated async service extracts the audio track with ffmpeg and splits it into chunks if the file is too long for a single API call.
- Each chunk is sent to the Groq API for transcription. If Groq fails or is rate-limited after retries, the backend falls back to the local
scriptum-whispermicroservice. - The detected source language and transcribed text are reassembled. If a target language was requested, the text is translated via MyMemory.
- At every step, a progress update is pushed to the frontend over a WebSocket topic dedicated to that job (
/topic/job/{jobId}).
| Technology | Role |
|---|---|
| Java 21 | Language |
| Spring Boot 3.5 | Application framework |
| Spring Security 6 | JWT authentication / authorization |
| Spring Data JPA (Hibernate) | ORM |
| Spring WebSocket | Real-time progress (STOMP) |
| Spring WebFlux (WebClient) | Non-blocking calls to Groq / MyMemory / whisper fallback |
| PostgreSQL | Database |
| Springdoc OpenAPI | API documentation (Swagger UI) |
| JJWT | JWT generation / validation |
ffmpeg (via ProcessBuilder) |
Audio extraction and chunking |
| Lombok | Boilerplate reduction |
| Technology | Role |
|---|---|
| Angular 18 | SPA framework, standalone components |
| Tailwind CSS | Utility-first styling, custom design tokens |
| RxJS | Async streams |
| @stomp/stompjs | Native WebSocket STOMP client |
| Angular Router | Navigation, route guards |
| Technology | Role |
|---|---|
| Python | Language |
| FastAPI | HTTP API |
| faster-whisper | Local speech-to-text model (fallback provider) |
scriptum-api/src/main/java/com/yann/scriptum/
├── config/
│ ├── SecurityConfig.java # Spring Security, CORS, JWT filter, public routes
│ ├── WebSocketConfig.java # STOMP broker configuration
│ ├── WebClientConfig.java # WebClient beans (Groq, whisper fallback)
│ ├── OpenApiConfig.java # Swagger UI + bearer auth scheme
│ └── AsyncConfig.java # Named task executor, @EnableAsync / @EnableScheduling
├── controller/ # REST layer (Media, Job, Auth)
├── service/ # Business logic
│ ├── MediaUploadService.java
│ ├── FfmpegService.java
│ ├── TranscriptionService.java # Groq call + local fallback + retry
│ ├── TranslationService.java # MyMemory call + chunking
│ ├── JobOrchestratorService.java # Creates jobs, exposed to controllers
│ ├── JobProcessingService.java # @Async pipeline execution (separate bean on purpose)
│ ├── NotificationService.java # WebSocket progress publishing
│ ├── JobCleanupScheduler.java # Scheduled temp file cleanup
│ ├── AuthService.java
│ └── JwtService.java
├── model/ # JPA entities
├── repository/ # Spring Data repositories
├── dto/ # Request/response records
├── enums/ # JobStatus, ChunkStatus, Role
└── exception/ # GlobalExceptionHandler + domain exceptions
scriptum-ui/src/app/
├── core/
│ ├── guards/ # authGuard
│ ├── interceptors/ # authInterceptor (JWT injection)
│ ├── models/ # TypeScript interfaces
│ └── services/ # AuthService, MediaService, JobService, WebSocketService
├── features/
│ ├── home/ # Landing page
│ ├── auth/ # Login, Register
│ ├── scripts/ # Upload + live transcription view (core feature)
│ │ ├── upload-panel/
│ │ ├── config-panel/
│ │ └── transcript-viewer/
│ ├── archive/ # Searchable history with stats and filters
│ └── ledger/ # Account usage summary
└── shared/
└── header/ # Navigation, auth state
User: Account with email, hashed password and role (USER, ADMIN). Implements UserDetails directly for Spring Security integration.
MediaFile: An uploaded audio or video file. Stores the original filename, MIME type, size, and the path on disk (never exposed to the frontend).
TranscriptionJob: The central entity. Tracks the full lifecycle of one transcription request: status, progress percentage, detected source language, requested target language, raw transcribed text, translated text, and error message on failure. Linked to both a MediaFile and the owning User.
TranscriptionChunk: One audio segment of a job when the source file exceeds the maximum duration accepted by the transcription API in a single call. Stores its index, time range, transcribed text and status.
JobStatus: PENDING → EXTRACTING_AUDIO → TRANSCRIBING → (TRANSLATING) → DONE, or FAILED at any step.
ChunkStatus: PENDING → PROCESSING → DONE, or FAILED.
Role: USER, ADMIN.
1. UPLOAD Client uploads a file, MediaFile persisted
2. JOB CREATION TranscriptionJob created in PENDING, id returned immediately
3. EXTRACTION Async pipeline extracts audio via ffmpeg, EXTRACTING_AUDIO
4. CHUNKING File split into segments if longer than the API limit
5. TRANSCRIPTION Each chunk sent to Groq (fallback: local whisper), TRANSCRIBING
6. TRANSLATION If a target language was requested, TRANSLATING
7. COMPLETION Final text (and translation) saved, DONE
FAILURE Any exception along the way, FAILED, with errorMessage recorded
Every transition publishes a ProgressMessage over the job's dedicated WebSocket topic, so the frontend never needs to poll while the job is in flight.
TranscriptionService tries the Groq Whisper API first (whisper-large-v3, requested in verbose_json format to also capture the detected source language). Calls are retried with exponential backoff on 5xx and 429 responses.
If Groq still fails after retries, the service transparently falls back to the local scriptum-whisper microservice, which exposes the exact same response shape (text, language), so the rest of the pipeline needs no special-casing:
private TranscriptionResult transcribeChunk(Path chunkFile) {
try {
return callGroqApi(chunkFile);
} catch (Exception e) {
log.warn("Groq call failed, falling back to local whisper: {}", e.getMessage());
return callLocalFallback(chunkFile);
}
}Translation (TranslationService) converts the language detected by Whisper (a full name like "french") into an ISO 639-1 code before calling the MyMemory API, splits long texts into byte-sized chunks to respect the provider's per-request limit, and skips the call entirely if source and target languages are the same.
Local base URL:
http://localhost:8080/apiLocal Swagger UI:http://localhost:8080/swagger-ui.html
All endpoints are JWT-protected except /api/auth/**, /swagger-ui/**, /api-docs/** and the WebSocket handshake endpoint.
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/register |
Create an account, returns a JWT |
| POST | /auth/login |
Authenticate, returns a JWT |
| Method | Endpoint | Description |
|---|---|---|
| POST | /media/upload |
Upload an audio or video file (multipart/form-data) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /jobs |
Start a transcription job from an uploaded media file id |
| GET | /jobs |
List all jobs belonging to the authenticated user |
| GET | /jobs/{id} |
Get a single job (status, progress, text, translation) |
STOMP endpoint: /ws (native WebSocket).
Broker prefix: /topic.
Once a job is created, the frontend subscribes to:
/topic/job/{jobId}
and receives a ProgressMessage (jobId, status, progress) at every pipeline transition, until DONE or FAILED, at which point it fetches the full job via GET /jobs/{id} to display the final text.
Standalone components: No NgModule, each component declares its own imports.
Route guard: authGuard protects /scripts, /archive and /ledger, redirecting unauthenticated users to /login.
HTTP interceptor: authInterceptor automatically attaches Authorization: Bearer <token> to every outgoing request.
Design system: A custom "vintage manuscript / typewriter" visual identity (EB Garamond, Courier Prime, JetBrains Mono, parchment textures, typewriter-key buttons) implemented as Tailwind design tokens rather than default utility classes.
| View | Route | Description |
|---|---|---|
| Home | / |
Landing page |
| Login / Register | /login, /register |
Authentication |
| Scripts | /scripts |
Upload a file, configure translation, watch live progress, read the result |
| Archive | /archive |
Searchable history of all past jobs with stats and status filters |
| Ledger | /ledger |
Account summary (manuscripts count, completed/failed, characters transcribed) |
The JWT is signed with HMAC-SHA256 via the JJWT library. Default lifetime: 24 hours (jwt.expiration-ms).
Backend: JwtAuthenticationFilter extracts the token from the Authorization: Bearer ... header, validates it against jwt.secret, and loads the User (which implements UserDetails directly) via UserDetailsServiceImpl. Sessions are stateless (SessionCreationPolicy.STATELESS).
Frontend: The token (and the user's email) is stored in localStorage by AuthService and injected automatically by authInterceptor.
⚠️ jwt.secretshould be a long, random value provided via an environment variable, never committed inapplication.properties.
Create a .env file at the project root:
| Variable | Required | Description |
|---|---|---|
GROQ_API_KEY |
Yes | Groq API key for Whisper transcription (console.groq.com) |
JWT_SECRET |
Yes | Secret used to sign JWTs (≥ 32 random characters, never commit) |
| Property | Default | Description |
|---|---|---|
whisper.local.url |
http://localhost:5000 |
URL of the scriptum-whisper fallback service |
translation.api.url |
https://api.mymemory.translated.net |
Translation provider base URL |
scriptum.storage.path |
/tmp/scriptum |
Temporary storage path for uploaded/extracted media |
scriptum.cleanup.max-age-hours |
2 |
Retention period before a temp file is auto-deleted |
jwt.expiration-ms |
86400000 |
JWT lifetime in milliseconds (24h) |
- Java 21
- Node.js and npm
- Python 3.10+
- Docker and Docker Compose
- ffmpeg installed and available on your PATH
- A Groq API key
git clone https://github.com/Yan739/scriptum.git
cd scriptumCreate the .env file described above at the project root.
docker-compose up -d dbcd scriptum-api
./mvnw spring-boot:runAPI available at http://localhost:8080.
cd scriptum-whisper
python -m venv venv
venv\Scripts\activate # or: source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 5000cd scriptum-ui
npm install
ng serveApp available at http://localhost:4200.
Interactive OpenAPI documentation, once the backend is running:
http://localhost:8080/swagger-ui.html
Authenticate with the Authorize button using the JWT returned by /api/auth/login or /api/auth/register to test protected endpoints.
JobCleanupScheduler runs every hour and deletes any file in the temporary storage directory older than scriptum.cleanup.max-age-hours, preventing uploaded and extracted media from accumulating on disk after processing.
GlobalExceptionHandler centralises all error responses:
| Exception | HTTP code | Use |
|---|---|---|
MethodArgumentNotValidException |
400 | Bean Validation failed |
UnsupportedMediaFormatException |
400 | Invalid or oversized upload |
ResourceNotFoundException |
404 | Entity not found |
NoResourceFoundException |
404 | Unresolved static resource / bad URL |
MaxUploadSizeExceededException |
413 | File too large |
Exception (catch-all) |
500 | Internal error (logged server-side) |
All error responses follow this format:
{
"timestamp": "2026-07-25T22:16:49.827Z",
"status": 400,
"error": "Bad Request",
"message": "Descriptive message"
}- Subtitle export (SRT/VTT)
- Support for additional translation providers
- Admin view for monitoring job queue and failures
- Docker image publishing for one-command deployment
Portfolio project · designed and built by Yann