Update agent
This commit is contained in:
82
AGENTS.md
Normal file
82
AGENTS.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# MeetVault — Agent Guide
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
MeetVault/
|
||||||
|
├── Overview/ # Architecture docs, schema, data flow
|
||||||
|
│ └── README.md
|
||||||
|
├── Wireframes/ # Desktop client design notes
|
||||||
|
│ └── Desktop/
|
||||||
|
│ ├── Desktop_README.md
|
||||||
|
│ └── Desktop-Demo/ # Runnable Tauri + React demo
|
||||||
|
│ └── speech-engine/ # Local Whisper transcription engine
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core architecture (remember this)
|
||||||
|
|
||||||
|
- **User machine**: records audio/video, runs local Whisper for STT, sends transcript to backend. Keeps compute and cost on the device.
|
||||||
|
- **Backend API**: auth, meeting metadata, presigned upload URLs, transcript ingestion, summarization jobs, task extraction, reminder scheduling.
|
||||||
|
- **PostgreSQL**: structured app data + `processing_jobs` table used as a job queue (no external broker needed for MVP).
|
||||||
|
- **SeaweedFS**: S3-compatible object storage for large media files.
|
||||||
|
|
||||||
|
## PostgreSQL gotchas
|
||||||
|
|
||||||
|
### Enable UUID generation first
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Always set `updated_at` manually
|
||||||
|
|
||||||
|
PostgreSQL does not auto-update it. Create a shared trigger function and apply to every table that tracks changes:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- then attach per-table triggers, e.g.:
|
||||||
|
CREATE TRIGGER trg_meetings_updated_at
|
||||||
|
BEFORE UPDATE ON meetings FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job queue pattern (no external broker)
|
||||||
|
|
||||||
|
Workers pick jobs with this exact query:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT *
|
||||||
|
FROM processing_jobs
|
||||||
|
WHERE status = 'queued'
|
||||||
|
AND available_at <= NOW()
|
||||||
|
ORDER BY priority DESC, created_at
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Key indexes for workers:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_schedules_due ON meeting_schedules(status, scheduled_at);
|
||||||
|
CREATE INDEX idx_processing_jobs_queue ON processing_jobs(status, available_at, priority DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### SeaweedFS object paths
|
||||||
|
|
||||||
|
Files live under: `meeting-storage/users/{user_id}/meetings/{meeting_id}/original/`
|
||||||
|
|
||||||
|
## Testing flow
|
||||||
|
|
||||||
|
1. Run the desktop demo locally (Tauri + React) to verify UI and local transcription without backend dependencies.
|
||||||
|
2. Spin up PostgreSQL, run the schema SQL from `Overview/README.md`, then start the backend API.
|
||||||
|
3. End-to-end: record a short clip on the desktop client → upload via presigned URL → ingest transcript → trigger summarization job → verify summary appears in DB.
|
||||||
|
|
||||||
|
## What is intentionally out of scope for MVP
|
||||||
|
|
||||||
|
- Kafka, Elasticsearch, Kubernetes, vector DBs, Redis (unless job volume grows), microservices. PostgreSQL + SeaweedFS + workers is sufficient.
|
||||||
44
Readme.md
44
Readme.md
@@ -0,0 +1,44 @@
|
|||||||
|
# MeetVault
|
||||||
|
|
||||||
|
Self-hosted meeting recording and intelligence platform. The user machine handles local transcription; the backend API manages auth, metadata, uploads, summarization jobs, tasks, and reminders. Large media files are stored in SeaweedFS via its S3-compatible API.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
User machine:
|
||||||
|
- Record audio/video
|
||||||
|
- Local Whisper STT → transcript
|
||||||
|
- Upload to SeaweedFS (presigned URLs)
|
||||||
|
- Send transcript to backend
|
||||||
|
|
||||||
|
Backend API:
|
||||||
|
- Auth, users, subscriptions
|
||||||
|
- Meeting metadata
|
||||||
|
- Transcript ingestion
|
||||||
|
- Summarization jobs
|
||||||
|
- Task extraction
|
||||||
|
- Reminder scheduling
|
||||||
|
- PostgreSQL for structured data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. Start PostgreSQL and run the schema SQL from `Overview/README.md` (includes `pgcrypto` extension, all tables, indexes, triggers).
|
||||||
|
2. Run the backend API server.
|
||||||
|
3. Spin up SeaweedFS and configure presigned URL endpoints on the backend.
|
||||||
|
|
||||||
|
### Desktop client
|
||||||
|
|
||||||
|
Run the Tauri + React demo in `Wireframes/Desktop/MeetVault-Desktop-Demo/`. It supports local Whisper transcription, speaker diarization via diart, live overlay window, recording capture, and mock speech-engine mode for UI testing without ML models.
|
||||||
|
|
||||||
|
## Stack (MVP)
|
||||||
|
|
||||||
|
Frontend: React / Next.js
|
||||||
|
Backend: FastAPI or Node.js
|
||||||
|
Database: PostgreSQL + `processing_jobs` table as job queue
|
||||||
|
Media storage: SeaweedFS
|
||||||
|
Local STT: Whisper / whisper.cpp
|
||||||
|
|
||||||
|
No Kafka, Elasticsearch, Kubernetes, vector DBs, or Redis are needed for the MVP.
|
||||||
|
|||||||
8
Wireframes/Desktop/MeetVault-Desktop-Demo/.gitignore
vendored
Normal file
8
Wireframes/Desktop/MeetVault-Desktop-Demo/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
speech-engine/.venv/
|
||||||
|
speech-engine/__pycache__/
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
14
Wireframes/Desktop/MeetVault-Desktop-Demo/PROJECT_SCOPE.md
Normal file
14
Wireframes/Desktop/MeetVault-Desktop-Demo/PROJECT_SCOPE.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Demo scope lock
|
||||||
|
|
||||||
|
This file records the requested scope for the current MeetVault desktop demo.
|
||||||
|
|
||||||
|
- No login UI or authentication flow.
|
||||||
|
- No subscription UI or billing logic.
|
||||||
|
- Recordings and transcript files remain local.
|
||||||
|
- Meeting Details must support local video/audio playback.
|
||||||
|
- Live transcription is local Whisper.
|
||||||
|
- Speaker identifier means session speaker diarization (`Speaker 1`, `Speaker 2`, etc.), not biometric identity recognition.
|
||||||
|
- Speaker labels can be renamed live.
|
||||||
|
- Realtime overlay is included.
|
||||||
|
- Settings are included and locally persisted.
|
||||||
|
- Backend actions are placeholders and display exactly: `Processing (The job). Please wait a moment`.
|
||||||
421
Wireframes/Desktop/MeetVault-Desktop-Demo/README.md
Normal file
421
Wireframes/Desktop/MeetVault-Desktop-Demo/README.md
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
# MeetVault Desktop Demo
|
||||||
|
|
||||||
|
This repository is a runnable desktop-demo scaffold based on the supplied MeetVault wireframes and desktop architecture notes.
|
||||||
|
|
||||||
|
## Demo scope
|
||||||
|
|
||||||
|
Included now:
|
||||||
|
|
||||||
|
- Tauri + React + TypeScript desktop UI
|
||||||
|
- Dashboard
|
||||||
|
- Meetings list
|
||||||
|
- Meeting details
|
||||||
|
- Local video/audio playback
|
||||||
|
- Live meeting screen
|
||||||
|
- Real-time Whisper transcription through a local Python speech engine
|
||||||
|
- Real-time speaker diarization through diart
|
||||||
|
- `Speaker 1`, `Speaker 2`, ... labels
|
||||||
|
- Live speaker rename
|
||||||
|
- Transparent always-on-top overlay window
|
||||||
|
- Microphone capture
|
||||||
|
- Optional system-audio/screen capture through the WebView capture API
|
||||||
|
- Local recording files
|
||||||
|
- Local transcript data
|
||||||
|
- Functional Settings screen
|
||||||
|
- Mock speech-engine mode for UI testing without ML models
|
||||||
|
|
||||||
|
Not included in this demo:
|
||||||
|
|
||||||
|
- User login
|
||||||
|
- User identification/account verification
|
||||||
|
- Subscription/payment
|
||||||
|
- PostgreSQL
|
||||||
|
- SeaweedFS
|
||||||
|
- Remote cloud storage
|
||||||
|
- Real summary/task/reminder backend
|
||||||
|
|
||||||
|
The backend-facing demo state is intentionally:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Processing (The job). Please wait a moment
|
||||||
|
```
|
||||||
|
|
||||||
|
Summary/task/reminder controls show this processing state but do not call a production backend yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Desktop UI (Tauri + React)
|
||||||
|
│
|
||||||
|
├── microphone / optional system audio + screen
|
||||||
|
│
|
||||||
|
├── MediaRecorder ───────────► local recording.webm
|
||||||
|
│
|
||||||
|
└── 16 kHz PCM
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Local Speech Engine
|
||||||
|
Python WebSocket server
|
||||||
|
│
|
||||||
|
┌──────┴───────┐
|
||||||
|
▼ ▼
|
||||||
|
faster-whisper diart
|
||||||
|
transcription diarization
|
||||||
|
│ │
|
||||||
|
└──────┬───────┘
|
||||||
|
▼
|
||||||
|
speaker-aware segments
|
||||||
|
│
|
||||||
|
┌──────┴──────────┐
|
||||||
|
▼ ▼
|
||||||
|
Live transcript Overlay window
|
||||||
|
```
|
||||||
|
|
||||||
|
The local speech engine is **not** the future MeetVault backend. It is a local ML side process used by the desktop demo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
MeetVault-Desktop-Demo/
|
||||||
|
├── src/ React desktop UI
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── capture.ts microphone/system capture + MediaRecorder
|
||||||
|
│ │ ├── speechClient.ts local WebSocket speech client
|
||||||
|
│ │ ├── liveBus.ts main-window ↔ overlay events
|
||||||
|
│ │ └── localFiles.ts local recording/transcript persistence
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── Dashboard.tsx
|
||||||
|
│ │ ├── LiveMeeting.tsx
|
||||||
|
│ │ ├── Meetings.tsx
|
||||||
|
│ │ ├── MeetingDetails.tsx
|
||||||
|
│ │ ├── Settings.tsx
|
||||||
|
│ │ └── Overlay.tsx
|
||||||
|
│ └── store/
|
||||||
|
├── src-tauri/ Tauri 2 desktop shell
|
||||||
|
├── speech-engine/ local Whisper + diart process
|
||||||
|
├── docs/ supplied source wireframes/README
|
||||||
|
└── scripts/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Desktop UI
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- Rust toolchain compatible with your installed Tauri 2 release
|
||||||
|
- Platform-specific Tauri prerequisites
|
||||||
|
|
||||||
|
### Real speech engine
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
- Python 3.10 or 3.11
|
||||||
|
- ffmpeg
|
||||||
|
- PortAudio
|
||||||
|
- libsndfile
|
||||||
|
- Python packages in `speech-engine/requirements.txt`
|
||||||
|
- Hugging Face/pyannote model access required by your diart setup
|
||||||
|
|
||||||
|
For UI-only testing, use mock speech mode instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Install frontend dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test the UI in a browser
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:1420
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser mode is useful for UI development. Tauri-only functions such as the native overlay window and persistent AppLocalData storage use reduced fallbacks in browser mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Install the local speech engine
|
||||||
|
|
||||||
|
Create a Python 3.10/3.11 virtual environment inside `speech-engine`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd speech-engine
|
||||||
|
python -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux/macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py --mock
|
||||||
|
```
|
||||||
|
|
||||||
|
The application expects the local engine at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://127.0.0.1:8765/ws/live
|
||||||
|
```
|
||||||
|
|
||||||
|
This can be changed from Settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Run as a desktop application
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, convenience scripts are also provided:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\start-demo.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
or for UI testing with mock speech:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\start-demo-mock.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Live meeting behavior
|
||||||
|
|
||||||
|
When a meeting starts:
|
||||||
|
|
||||||
|
1. MeetVault requests microphone access.
|
||||||
|
2. If **Capture system audio / screen** is enabled, it also opens the system screen/audio picker.
|
||||||
|
3. Audio is mixed locally.
|
||||||
|
4. The mixed audio is recorded through `MediaRecorder`.
|
||||||
|
5. The same audio is converted to mono 16 kHz signed PCM.
|
||||||
|
6. PCM is streamed to `speech-engine/server.py`.
|
||||||
|
7. Whisper produces timestamped text.
|
||||||
|
8. diart produces speaker time ranges.
|
||||||
|
9. The local engine merges the two timelines.
|
||||||
|
10. The UI receives segments such as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"speakerId": "speaker_1",
|
||||||
|
"start": 18.2,
|
||||||
|
"end": 21.7,
|
||||||
|
"text": "We should finish the API before Friday.",
|
||||||
|
"final": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
11. The user can rename `speaker_1` to `Alice` without changing the underlying diarization ID.
|
||||||
|
12. The main transcript and overlay immediately display the renamed speaker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Live overlay
|
||||||
|
|
||||||
|
The Tauri build creates a second transparent window.
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
|
||||||
|
- Always on top
|
||||||
|
- Transparent
|
||||||
|
- Resizable
|
||||||
|
- No decorations
|
||||||
|
- Hidden from the taskbar
|
||||||
|
- Shows the latest transcript lines
|
||||||
|
- Speaker name can be clicked to rename
|
||||||
|
- Receives the same live transcript event stream as the main window
|
||||||
|
|
||||||
|
The overlay is opened from **Show overlay** on the live meeting screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Local files
|
||||||
|
|
||||||
|
There is no cloud storage in this demo.
|
||||||
|
|
||||||
|
In Tauri mode, media is stored below the application's local-data directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
meetings/{meeting_uuid}/recording.webm
|
||||||
|
meetings/{meeting_uuid}/meeting.json
|
||||||
|
meetings/{meeting_uuid}/transcript.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The Meeting Details page can play the stored video/audio file.
|
||||||
|
|
||||||
|
If screen capture is enabled, the recording normally contains video + mixed audio.
|
||||||
|
|
||||||
|
If only microphone capture is enabled, the recording is audio-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Video playback
|
||||||
|
|
||||||
|
The Meeting Details screen includes a real HTML5 video/audio player.
|
||||||
|
|
||||||
|
The Tauri asset protocol is scoped only to MeetVault's local `meetings` directory.
|
||||||
|
|
||||||
|
This allows locally recorded media to be played without uploading it anywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Settings
|
||||||
|
|
||||||
|
The Settings screen is functional and persisted locally.
|
||||||
|
|
||||||
|
Current settings include:
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- Launch at startup preference
|
||||||
|
- Keep in tray preference
|
||||||
|
- Global hotkey value
|
||||||
|
- Theme preference
|
||||||
|
|
||||||
|
The startup/tray/hotkey preferences are stored now; OS registration is intentionally left for the packaging phase.
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
|
||||||
|
- Microphone device ID
|
||||||
|
- System audio/screen capture toggle
|
||||||
|
- Microphone level monitoring
|
||||||
|
|
||||||
|
### Transcription
|
||||||
|
|
||||||
|
- Whisper model: `base`, `small`, `medium`
|
||||||
|
- Compute device: auto/CPU/CUDA
|
||||||
|
- Language
|
||||||
|
- Speaker diarization toggle
|
||||||
|
|
||||||
|
### Overlay
|
||||||
|
|
||||||
|
- Enabled
|
||||||
|
- Opacity
|
||||||
|
- Font size
|
||||||
|
- Visible line count
|
||||||
|
|
||||||
|
### Speech engine
|
||||||
|
|
||||||
|
- Real Whisper + diart mode
|
||||||
|
- Mock mode
|
||||||
|
- Local WebSocket URL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend placeholder
|
||||||
|
|
||||||
|
This demo deliberately has no remote backend implementation.
|
||||||
|
|
||||||
|
Any backend-only action returns/displays:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Processing (The job). Please wait a moment
|
||||||
|
```
|
||||||
|
|
||||||
|
This currently applies to example actions such as:
|
||||||
|
|
||||||
|
- Generate/regenerate summary
|
||||||
|
- Create reminder
|
||||||
|
- Action-item processing
|
||||||
|
|
||||||
|
The UI is structured so a real API can replace `src/lib/backend.ts` later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current limitations
|
||||||
|
|
||||||
|
## System audio
|
||||||
|
|
||||||
|
System-audio capture depends on the operating system, WebView implementation, selected source, and whether that source permits audio capture.
|
||||||
|
|
||||||
|
Microphone capture is the reliable baseline for the demo.
|
||||||
|
|
||||||
|
## diart model setup
|
||||||
|
|
||||||
|
Real diart execution can require pyannote model terms/authentication. The exact dependency combinations can also be sensitive to Python, PyTorch, torchaudio, and platform versions.
|
||||||
|
|
||||||
|
Use the recommended Python version and validate the speech-engine environment on the target development machine before packaging.
|
||||||
|
|
||||||
|
## Realtime accuracy
|
||||||
|
|
||||||
|
Speaker diarization can temporarily swap speaker labels, especially with:
|
||||||
|
|
||||||
|
- overlapping speakers
|
||||||
|
- short utterances
|
||||||
|
- background noise
|
||||||
|
- similar voices
|
||||||
|
|
||||||
|
The rename function changes the display name but does not perform biometric identity recognition.
|
||||||
|
|
||||||
|
## Long recordings
|
||||||
|
|
||||||
|
The media recorder writes the final Blob when the meeting ends in this initial demo. Before production use, change this to incremental/chunked file writing so multi-hour meetings do not accumulate the entire media Blob in memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommended next development steps
|
||||||
|
|
||||||
|
1. Validate real Whisper + diart on the target Windows machine.
|
||||||
|
2. Replace final-Blob recording with incremental local file writes.
|
||||||
|
3. Add proper microphone device enumeration.
|
||||||
|
4. Add native Windows loopback audio capture if system-audio reliability is required.
|
||||||
|
5. Add session crash recovery.
|
||||||
|
6. Add transcript segment editing/reassignment.
|
||||||
|
7. Package the Python speech engine as a sidecar or replace it with native whisper.cpp + a native/ONNX diarization runtime.
|
||||||
|
8. Implement the real backend jobs/API later.
|
||||||
|
9. Replace local-only media with SeaweedFS when backend/storage work begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Demo design decisions from the supplied specification
|
||||||
|
|
||||||
|
```text
|
||||||
|
Login / subscription REMOVED
|
||||||
|
Remote backend PLACEHOLDER ONLY
|
||||||
|
Backend message "Processing (The job). Please wait a moment"
|
||||||
|
Media storage LOCAL
|
||||||
|
Video playback INCLUDED
|
||||||
|
Live transcription INCLUDED
|
||||||
|
Speaker diarization INCLUDED
|
||||||
|
Live speaker rename INCLUDED
|
||||||
|
Realtime overlay INCLUDED
|
||||||
|
Settings INCLUDED
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,577 @@
|
|||||||
|
<mxfile host="Electron" agent="ChatGPT" pages="7">
|
||||||
|
<diagram id="dashboard" name="1. Dashboard">
|
||||||
|
<mxGraphModel dx="2054" dy="1201" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1000" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="d_frame" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fb;strokeColor=#cfd6df;strokeWidth=1.5;" vertex="1">
|
||||||
|
<mxGeometry height="860" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_sidebar" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="MeetVault" vertex="1">
|
||||||
|
<mxGeometry height="860" width="230" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_nav1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="⌂ Dashboard" vertex="1">
|
||||||
|
<mxGeometry height="44" width="180" x="85" y="130" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_nav2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="◷ Meetings" vertex="1">
|
||||||
|
<mxGeometry height="44" width="180" x="85" y="190" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_nav3" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="⚙ Settings" vertex="1">
|
||||||
|
<mxGeometry height="44" width="180" x="85" y="250" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_user" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="Signed in as alex@example.com" vertex="1">
|
||||||
|
<mxGeometry height="70" width="180" x="85" y="800" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_title" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#111827;" value="Good morning" vertex="1">
|
||||||
|
<mxGeometry height="40" width="420" x="340" y="90" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_desc" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Record, transcribe, summarize, and follow up on meetings." vertex="1">
|
||||||
|
<mxGeometry height="30" width="560" x="340" y="135" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_start" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="+ Start new meeting" vertex="1">
|
||||||
|
<mxGeometry height="48" width="250" x="1210" y="90" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" vertex="1">
|
||||||
|
<mxGeometry height="180" width="350" x="340" y="220" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card1h" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Quick start" vertex="1">
|
||||||
|
<mxGeometry height="30" width="180" x="365" y="245" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card1b" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Microphone: Default System&nbsp;<div>audio: On Local STT: Whisper small</div>" vertex="1">
|
||||||
|
<mxGeometry height="80" width="260" x="360" y="290" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card1btn" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Start recording" vertex="1">
|
||||||
|
<mxGeometry height="38" width="160" x="365" y="350" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" vertex="1">
|
||||||
|
<mxGeometry height="180" width="350" x="720" y="220" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card2h" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Live overlay" vertex="1">
|
||||||
|
<mxGeometry height="30" width="180" x="745" y="245" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card2b" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Overlay: Enabled&nbsp;<div>Position: Bottom center&nbsp;</div><div>Speaker labels: On</div>" vertex="1">
|
||||||
|
<mxGeometry height="80" width="260" x="745" y="280" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card2btn" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Preview overlay" vertex="1">
|
||||||
|
<mxGeometry height="38" width="170" x="745" y="350" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card3" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" vertex="1">
|
||||||
|
<mxGeometry height="180" width="360" x="1100" y="220" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card3h" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Storage" vertex="1">
|
||||||
|
<mxGeometry height="30" width="180" x="1125" y="245" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_card3b" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="SeaweedFS connected&nbsp;<div>Storage used: 12.8 GB Uploads: healthy</div>" vertex="1">
|
||||||
|
<mxGeometry height="80" width="260" x="1125" y="290" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_storagechip" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Connected" vertex="1">
|
||||||
|
<mxGeometry height="26" width="110" x="1270" y="245" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_recenth" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Recent meetings" vertex="1">
|
||||||
|
<mxGeometry height="30" width="220" x="340" y="455" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_table" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" vertex="1">
|
||||||
|
<mxGeometry height="300" width="1120" x="340" y="500" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_th0" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Title" vertex="1">
|
||||||
|
<mxGeometry height="28" width="230" x="360" y="515" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_th1" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Date" vertex="1">
|
||||||
|
<mxGeometry height="28" width="140" x="760" y="515" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_th2" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Duration" vertex="1">
|
||||||
|
<mxGeometry height="28" width="120" x="930" y="515" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_th3" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Status" vertex="1">
|
||||||
|
<mxGeometry height="28" width="120" x="1090" y="515" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_th4" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Actions" vertex="1">
|
||||||
|
<mxGeometry height="28" width="140" x="1270" y="515" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r0t" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Weekly Product Sync" vertex="1">
|
||||||
|
<mxGeometry height="34" width="340" x="360" y="555" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r0d" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Sep 27, 2026" vertex="1">
|
||||||
|
<mxGeometry height="34" width="150" x="760" y="555" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r0dur" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="48 min" vertex="1">
|
||||||
|
<mxGeometry height="34" width="120" x="930" y="555" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r0s" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Ready" vertex="1">
|
||||||
|
<mxGeometry height="28" width="100" x="1090" y="558" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r0a" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Open" vertex="1">
|
||||||
|
<mxGeometry height="30" width="90" x="1270" y="557" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r1t" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Backend Architecture Review" vertex="1">
|
||||||
|
<mxGeometry height="34" width="340" x="360" y="625" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r1d" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Sep 26, 2026" vertex="1">
|
||||||
|
<mxGeometry height="34" width="150" x="760" y="625" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r1dur" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="61 min" vertex="1">
|
||||||
|
<mxGeometry height="34" width="120" x="930" y="625" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r1s" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Ready" vertex="1">
|
||||||
|
<mxGeometry height="28" width="100" x="1090" y="628" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r1a" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Open" vertex="1">
|
||||||
|
<mxGeometry height="30" width="90" x="1270" y="627" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r2t" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Client Planning Call" vertex="1">
|
||||||
|
<mxGeometry height="34" width="340" x="360" y="695" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r2d" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Sep 25, 2026" vertex="1">
|
||||||
|
<mxGeometry height="34" width="150" x="760" y="695" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r2dur" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="35 min" vertex="1">
|
||||||
|
<mxGeometry height="34" width="120" x="930" y="695" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r2s" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Processing" vertex="1">
|
||||||
|
<mxGeometry height="28" width="100" x="1090" y="698" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="d_r2a" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Open" vertex="1">
|
||||||
|
<mxGeometry height="30" width="90" x="1270" y="697" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
<diagram id="live" name="2. Live Meeting">
|
||||||
|
<mxGraphModel grid="1" page="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" pageScale="1" pageWidth="1600" pageHeight="1000" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="l_frame" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fb;strokeColor=#cfd6df;strokeWidth=1.5;" vertex="1">
|
||||||
|
<mxGeometry height="860" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_top" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="MeetVault • Live Meeting" vertex="1">
|
||||||
|
<mxGeometry height="60" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_title" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#111827;" value="Weekly Product Sync" vertex="1">
|
||||||
|
<mxGeometry height="40" width="500" x="100" y="140" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_timer" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fee2e2;strokeColor=#fecaca;fontColor=#991b1b;" value="● REC 00:18:42" vertex="1">
|
||||||
|
<mxGeometry height="40" width="200" x="1260" y="140" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_left" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" vertex="1">
|
||||||
|
<mxGeometry height="570" width="860" x="100" y="210" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_transh" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Live transcription" vertex="1">
|
||||||
|
<mxGeometry height="30" width="240" x="125" y="235" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_s1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Speaker 1" vertex="1">
|
||||||
|
<mxGeometry height="28" width="110" x="125" y="290" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_s1txt" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="We should finalize the upload flow before Friday." vertex="1">
|
||||||
|
<mxGeometry height="42" width="630" x="250" y="285" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_s2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Speaker 2" vertex="1">
|
||||||
|
<mxGeometry height="28" width="110" x="125" y="360" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_s2txt" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="I can take the SeaweedFS integration and the presigned URL endpoint." vertex="1">
|
||||||
|
<mxGeometry height="54" width="630" x="250" y="355" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_s1b" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Speaker 1" vertex="1">
|
||||||
|
<mxGeometry height="28" width="110" x="125" y="435" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_s1btxt" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Great. I will handle the meeting job queue and reminders." vertex="1">
|
||||||
|
<mxGeometry height="54" width="630" x="250" y="430" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_partial" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="Speaker 2 • listening…" vertex="1">
|
||||||
|
<mxGeometry height="48" width="760" x="125" y="525" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_wave" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="▁▃▅▆▅▃▂▁▂▅▇▆▃▂▁" vertex="1">
|
||||||
|
<mxGeometry height="40" width="760" x="125" y="600" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_controls" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Pause" vertex="1">
|
||||||
|
<mxGeometry height="40" width="110" x="125" y="690" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_overlaybtn" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Show overlay" vertex="1">
|
||||||
|
<mxGeometry height="40" width="150" x="250" y="690" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_finish" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fee2e2;strokeColor=#fecaca;fontColor=#991b1b;" value="Finish meeting" vertex="1">
|
||||||
|
<mxGeometry height="40" width="160" x="725" y="690" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_right" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" vertex="1">
|
||||||
|
<mxGeometry height="570" width="460" x="1000" y="210" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_speakersh" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Speakers" vertex="1">
|
||||||
|
<mxGeometry height="30" width="200" x="1025" y="235" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_sp1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Speaker 1" vertex="1">
|
||||||
|
<mxGeometry height="40" width="260" x="1025" y="285" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_rename1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Rename" vertex="1">
|
||||||
|
<mxGeometry height="40" width="110" x="1300" y="285" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_sp2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Speaker 2" vertex="1">
|
||||||
|
<mxGeometry height="40" width="260" x="1025" y="345" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_rename2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Rename" vertex="1">
|
||||||
|
<mxGeometry height="40" width="110" x="1300" y="345" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_audioh" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Audio sources" vertex="1">
|
||||||
|
<mxGeometry height="30" width="200" x="1025" y="435" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_mic" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="✓ Microphone" vertex="1">
|
||||||
|
<mxGeometry height="38" width="180" x="1025" y="480" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_sys" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="✓ System audio" vertex="1">
|
||||||
|
<mxGeometry height="38" width="190" x="1220" y="480" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_stth" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Local transcription" vertex="1">
|
||||||
|
<mxGeometry height="30" width="220" x="1025" y="555" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_stt" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Whisper small Device: GPU Latency: ~1.2 s" vertex="1">
|
||||||
|
<mxGeometry height="90" width="260" x="1025" y="595" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="l_sttchip" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Healthy" vertex="1">
|
||||||
|
<mxGeometry height="28" width="100" x="1300" y="610" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
<diagram id="overlay" name="3. Live Overlay">
|
||||||
|
<mxGraphModel grid="1" page="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" pageScale="1" pageWidth="1600" pageHeight="1000" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="o_bg" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="Desktop / meeting app background" vertex="1">
|
||||||
|
<mxGeometry height="860" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_fakeapp" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="Video conference / presentation area" vertex="1">
|
||||||
|
<mxGeometry height="600" width="1100" x="250" y="130" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_toolbar" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="MeetVault overlay controls" vertex="1">
|
||||||
|
<mxGeometry height="42" width="320" x="1030" y="90" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_box" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#111827;strokeColor=#374151;fontColor=#ffffff;opacity=92;" vertex="1">
|
||||||
|
<mxGeometry height="190" width="820" x="390" y="590" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_sp1" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=14;fontStyle=1;fontColor=#93c5fd;" value="Alice" vertex="1">
|
||||||
|
<mxGeometry height="28" width="100" x="425" y="620" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_txt1" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=20;fontColor=#ffffff;" value="We should finalize the API contract before tomorrow." vertex="1">
|
||||||
|
<mxGeometry height="40" width="720" x="425" y="650" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_sp2" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=14;fontStyle=1;fontColor=#86efac;" value="Bob" vertex="1">
|
||||||
|
<mxGeometry height="28" width="100" x="425" y="700" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_txt2" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=20;fontColor=#ffffff;" value="I will update the database migration tonight." vertex="1">
|
||||||
|
<mxGeometry height="40" width="720" x="425" y="730" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="o_hint" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Overlay options: drag • resize • opacity • font size • click-through • show/hide speaker labels" vertex="1">
|
||||||
|
<mxGeometry height="30" width="820" x="390" y="805" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="6jg8ZA6zPTfZGke1Gjl6-1" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=20;fontColor=#ffffff;" value="<font style="font-size: 11px;">Click the speaker's name to rename</font>" vertex="1">
|
||||||
|
<mxGeometry height="40" width="720" x="410" y="580" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
<diagram id="speaker" name="4. Rename Speakers">
|
||||||
|
<mxGraphModel dx="2054" dy="1201" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1000" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="s_bg" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="Live meeting screen (dimmed)" vertex="1">
|
||||||
|
<mxGeometry height="860" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_modal" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" vertex="1">
|
||||||
|
<mxGeometry height="600" width="660" x="470" y="160" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_title" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#111827;" value="Rename speakers" vertex="1">
|
||||||
|
<mxGeometry height="40" width="300" x="510" y="200" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_desc" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Speaker labels are local to this meeting. Rename them anytime during the session." vertex="1">
|
||||||
|
<mxGeometry height="40" width="520" x="510" y="245" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_card1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" vertex="1">
|
||||||
|
<mxGeometry height="120" width="580" x="510" y="320" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_label1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Speaker 1" vertex="1">
|
||||||
|
<mxGeometry height="28" width="110" x="535" y="342" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_input1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Alice" vertex="1">
|
||||||
|
<mxGeometry height="42" width="360" x="535" y="382" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_save1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Save" vertex="1">
|
||||||
|
<mxGeometry height="42" width="120" x="915" y="382" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_card2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" vertex="1">
|
||||||
|
<mxGeometry height="120" width="580" x="510" y="470" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_label2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Speaker 2" vertex="1">
|
||||||
|
<mxGeometry height="28" width="110" x="535" y="492" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_input2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Bob" vertex="1">
|
||||||
|
<mxGeometry height="42" width="360" x="535" y="532" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_save2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Save" vertex="1">
|
||||||
|
<mxGeometry height="42" width="120" x="915" y="532" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_note" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Tip: if diarization temporarily swaps speakers, you can reassign the latest segment from the transcript view." vertex="1">
|
||||||
|
<mxGeometry height="60" width="540" x="510" y="630" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="s_close" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Close" vertex="1">
|
||||||
|
<mxGeometry height="42" width="150" x="900" y="690" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
<diagram id="review" name="6. Meeting Review">
|
||||||
|
<mxGraphModel dx="2054" dy="1201" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1000" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="r_frame" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fb;strokeColor=#cfd6df;strokeWidth=1.5;" vertex="1">
|
||||||
|
<mxGeometry height="860" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_top" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="MeetVault / Weekly Product Sync" vertex="1">
|
||||||
|
<mxGeometry height="60" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_title" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#111827;" value="Weekly Product Sync" vertex="1">
|
||||||
|
<mxGeometry height="40" width="500" x="100" y="140" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_meta" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Sep 27, 2026 • 48 min • 2 speakers" vertex="1">
|
||||||
|
<mxGeometry height="30" width="420" x="100" y="185" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_player" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="Video / audio player 00:18:42 / 00:48:05" vertex="1">
|
||||||
|
<mxGeometry height="220" width="700" x="100" y="240" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_trans" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Transcript" vertex="1">
|
||||||
|
<mxGeometry height="30" width="180" x="100" y="510" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_tbox" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="Alice 00:18:41 We should finalize the API contract before tomorrow. Bob 00:18:49 I will update the database migration tonight. Alice 00:19:02 Perfect, then I'll prepare the release notes." vertex="1">
|
||||||
|
<mxGeometry height="260" width="700" x="100" y="550" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_sumh" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Summary" vertex="1">
|
||||||
|
<mxGeometry height="30" width="200" x="850" y="240" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_sum" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="The team agreed to finalize the API contract before tomorrow. Bob will update the database migration tonight, while Alice prepares the release notes." vertex="1">
|
||||||
|
<mxGeometry height="140" width="590" x="850" y="280" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_taskh" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Action items" vertex="1">
|
||||||
|
<mxGeometry height="30" width="200" x="850" y="465" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_task1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="☐ Update database migration Bob • Due tonight" vertex="1">
|
||||||
|
<mxGeometry height="70" width="590" x="850" y="505" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_task2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="☐ Prepare release notes Alice • Due tomorrow" vertex="1">
|
||||||
|
<mxGeometry height="70" width="590" x="850" y="590" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_rem" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Create reminder" vertex="1">
|
||||||
|
<mxGeometry height="42" width="180" x="850" y="705" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="r_export" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Export transcript" vertex="1">
|
||||||
|
<mxGeometry height="42" width="180" x="1050" y="705" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="9EBs0guQWN_vwKdUdXgs-3" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Change speaker's name" vertex="1">
|
||||||
|
<mxGeometry height="42" width="180" x="420" y="505" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="9EBs0guQWN_vwKdUdXgs-4" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Copy transcript" vertex="1">
|
||||||
|
<mxGeometry height="42" width="180" x="620" y="505" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
<diagram id="meeting_details" name="7. Meeting Details">
|
||||||
|
<mxGraphModel grid="1" page="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" pageScale="1" pageWidth="1600" pageHeight="1000" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="md_frame" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fb;strokeColor=#cfd6df;strokeWidth=1.5;" value="" vertex="1">
|
||||||
|
<mxGeometry height="860" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_top" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="MeetVault / Meetings / Weekly Product Sync" vertex="1">
|
||||||
|
<mxGeometry height="60" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_back" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="← Back to meetings" vertex="1">
|
||||||
|
<mxGeometry height="38" width="160" x="100" y="135" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_title" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#111827;" value="Weekly Product Sync" vertex="1">
|
||||||
|
<mxGeometry height="42" width="500" x="100" y="190" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_meta" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Sep 27, 2026 • 48 min • 2 detected speakers • Ready" vertex="1">
|
||||||
|
<mxGeometry height="30" width="520" x="100" y="235" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_edit" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Edit details" vertex="1">
|
||||||
|
<mxGeometry height="40" width="120" x="1180" y="185" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_delete" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fee2e2;strokeColor=#fecaca;fontColor=#991b1b;" value="Delete" vertex="1">
|
||||||
|
<mxGeometry height="40" width="110" x="1320" y="185" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_player" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="Video / Audio Player ▶ 00:18:42 ━━━━━━━━━━━━━━━━━━━ 00:48:05 Volume • Speed 1.0x • Full screen" vertex="1">
|
||||||
|
<mxGeometry height="230" width="760" x="100" y="300" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_info" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Meeting information" vertex="1">
|
||||||
|
<mxGeometry height="30" width="220" x="910" y="300" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_info_box" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="Title Weekly Product Sync Started Sep 27, 2026 09:00 Ended Sep 27, 2026 09:48 Language English Status Ready" vertex="1">
|
||||||
|
<mxGeometry height="190" width="520" x="910" y="340" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_tabs" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Transcript Summary Action items Files" vertex="1">
|
||||||
|
<mxGeometry height="42" width="1330" x="100" y="575" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_transcript" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Transcript" vertex="1">
|
||||||
|
<mxGeometry height="30" width="200" x="100" y="640" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_search" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Search transcript..." vertex="1">
|
||||||
|
<mxGeometry height="38" width="300" x="1030" y="635" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_export" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Export" vertex="1">
|
||||||
|
<mxGeometry height="38" width="85" x="1345" y="635" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_t1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Alice 00:18:41" vertex="1">
|
||||||
|
<mxGeometry height="28" width="150" x="100" y="690" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_t1txt" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="We should finalize the API contract before tomorrow." vertex="1">
|
||||||
|
<mxGeometry height="38" width="690" x="270" y="686" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_t2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Bob 00:18:49" vertex="1">
|
||||||
|
<mxGeometry height="28" width="150" x="100" y="740" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_t2txt" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="I will update the database migration tonight." vertex="1">
|
||||||
|
<mxGeometry height="38" width="690" x="270" y="736" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_t3" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ecfdf5;strokeColor=#a7f3d0;fontColor=#065f46;fontSize=11;" value="Alice 00:19:02" vertex="1">
|
||||||
|
<mxGeometry height="28" width="150" x="100" y="790" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_t3txt" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Perfect. I'll prepare the release notes after that." vertex="1">
|
||||||
|
<mxGeometry height="38" width="690" x="270" y="786" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_rightsum" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Generated summary" vertex="1">
|
||||||
|
<mxGeometry height="30" width="190" x="1010" y="690" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_summary" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="The team agreed to finalize the API contract before tomorrow. Bob will update the database migration, while Alice prepares release notes." vertex="1">
|
||||||
|
<mxGeometry height="95" width="420" x="1010" y="730" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_regen" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Regenerate summary" vertex="1">
|
||||||
|
<mxGeometry height="38" width="170" x="1010" y="845" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="md_reminder" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Create reminder" vertex="1">
|
||||||
|
<mxGeometry height="38" width="150" x="1195" y="845" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="4IYnDq8jVSWbccKvp8l8-3" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#eef2ff;strokeColor=#c7d2fe;fontColor=#1e3a8a;" value="Copy" vertex="1">
|
||||||
|
<mxGeometry height="38" width="170" x="140" y="850" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="4IYnDq8jVSWbccKvp8l8-4" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Change speaker name" vertex="1">
|
||||||
|
<mxGeometry height="38" width="150" x="325" y="850" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
<diagram id="settings_full" name="8. Settings - Full">
|
||||||
|
<mxGraphModel dx="2054" dy="1201" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1000" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
<mxCell id="sf_frame" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fb;strokeColor=#cfd6df;strokeWidth=1.5;" value="" vertex="1">
|
||||||
|
<mxGeometry height="860" width="1480" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_sidebar" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="MeetVault Settings" vertex="1">
|
||||||
|
<mxGeometry height="860" width="250" x="60" y="50" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_nav1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="General" vertex="1">
|
||||||
|
<mxGeometry height="42" width="190" x="85" y="150" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_nav2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="Audio" vertex="1">
|
||||||
|
<mxGeometry height="42" width="190" x="85" y="205" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_nav3" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="Transcription" vertex="1">
|
||||||
|
<mxGeometry height="42" width="190" x="85" y="260" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_nav4" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="Overlay" vertex="1">
|
||||||
|
<mxGeometry height="42" width="190" x="85" y="315" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_nav5" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="Storage & Sync" vertex="1">
|
||||||
|
<mxGeometry height="42" width="190" x="85" y="370" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_nav6" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#20242b;strokeColor=#20242b;fontColor=#ffffff;" value="Account" vertex="1">
|
||||||
|
<mxGeometry height="42" width="190" x="85" y="425" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_title" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=22;fontStyle=1;fontColor=#111827;" value="Settings" vertex="1">
|
||||||
|
<mxGeometry height="40" width="300" x="360" y="90" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_desc" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontColor=#374151;" value="Configure MeetVault desktop behavior and local transcription." vertex="1">
|
||||||
|
<mxGeometry height="30" width="620" x="360" y="135" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_general" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="General" vertex="1">
|
||||||
|
<mxGeometry height="30" width="180" x="360" y="200" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_card1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="" vertex="1">
|
||||||
|
<mxGeometry height="150" width="1070" x="360" y="240" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_launch" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="✓ Launch MeetVault at startup" vertex="1">
|
||||||
|
<mxGeometry height="38" width="320" x="400" y="270" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_tray" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="✓ Keep running in system tray" vertex="1">
|
||||||
|
<mxGeometry height="38" width="320" x="400" y="320" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_hotkey_lbl" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Global hotkey" vertex="1">
|
||||||
|
<mxGeometry height="28" width="150" x="780" y="270" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_hotkey" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Ctrl + Shift + M" vertex="1">
|
||||||
|
<mxGeometry height="42" width="260" x="780" y="305" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_theme_lbl" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Theme" vertex="1">
|
||||||
|
<mxGeometry height="28" width="120" x="1090" y="270" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_theme" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="System default ▼" vertex="1">
|
||||||
|
<mxGeometry height="42" width="260" x="1090" y="305" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_audio" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Audio" vertex="1">
|
||||||
|
<mxGeometry height="30" width="180" x="360" y="430" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_card2" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="" vertex="1">
|
||||||
|
<mxGeometry height="160" width="1070" x="360" y="470" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_mic_lbl" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Microphone" vertex="1">
|
||||||
|
<mxGeometry height="28" width="160" x="400" y="500" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_mic" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Default microphone ▼" vertex="1">
|
||||||
|
<mxGeometry height="42" width="300" x="400" y="535" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_sys_lbl" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="System audio" vertex="1">
|
||||||
|
<mxGeometry height="28" width="160" x="760" y="500" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_sys" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Default output ▼" vertex="1">
|
||||||
|
<mxGeometry height="42" width="300" x="760" y="535" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_monitor" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="✓ Monitor microphone level" vertex="1">
|
||||||
|
<mxGeometry height="42" width="250" x="1110" y="535" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_trans" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=15;fontStyle=1;fontColor=#111827;" value="Transcription" vertex="1">
|
||||||
|
<mxGeometry height="30" width="180" x="360" y="675" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_card3" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d9dee7;" value="" vertex="1">
|
||||||
|
<mxGeometry height="150" width="1070" x="360" y="715" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_model_lbl" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Model" vertex="1">
|
||||||
|
<mxGeometry height="28" width="120" x="400" y="740" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_model" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Whisper small ▼" vertex="1">
|
||||||
|
<mxGeometry height="42" width="260" x="400" y="775" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_device_lbl" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Compute" vertex="1">
|
||||||
|
<mxGeometry height="28" width="120" x="700" y="740" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_device" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="GPU ▼" vertex="1">
|
||||||
|
<mxGeometry height="42" width="220" x="700" y="775" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_lang_lbl" parent="1" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fontColor=#6b7280;" value="Language" vertex="1">
|
||||||
|
<mxGeometry height="28" width="120" x="960" y="740" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_lang" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#cbd5e1;align=left;spacingLeft=10;fontColor=#64748b;" value="Auto detect ▼" vertex="1">
|
||||||
|
<mxGeometry height="42" width="220" x="960" y="775" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_diar" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3f4f6;strokeColor=#e5e7eb;fontColor=#374151;" value="✓ Speaker diarization" vertex="1">
|
||||||
|
<mxGeometry height="42" width="180" x="1210" y="775" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="sf_save" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#2563eb;strokeColor=#2563eb;fontColor=#ffffff;fontStyle=1;" value="Save changes" vertex="1">
|
||||||
|
<mxGeometry height="40" width="150" x="1240" y="875" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>
|
||||||
13
Wireframes/Desktop/MeetVault-Desktop-Demo/index.html
Normal file
13
Wireframes/Desktop/MeetVault-Desktop-Demo/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f5f7fb" />
|
||||||
|
<title>MeetVault Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2271
Wireframes/Desktop/MeetVault-Desktop-Demo/package-lock.json
generated
Normal file
2271
Wireframes/Desktop/MeetVault-Desktop-Demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
Wireframes/Desktop/MeetVault-Desktop-Demo/package.json
Normal file
33
Wireframes/Desktop/MeetVault-Desktop-Demo/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "meetvault-desktop-demo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1 --port 1420",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1 --port 1420",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"desktop": "tauri dev",
|
||||||
|
"speech": "python speech-engine/server.py",
|
||||||
|
"speech:mock": "python speech-engine/server.py --mock"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Write-Host "Starting MeetVault mock speech engine..."
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$PSScriptRoot\..\speech-engine'; python server.py --mock"
|
||||||
|
Set-Location "$PSScriptRoot\.."
|
||||||
|
npm run desktop
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Write-Host "Starting MeetVault speech engine..."
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$PSScriptRoot\..\speech-engine'; python server.py"
|
||||||
|
Set-Location "$PSScriptRoot\.."
|
||||||
|
Write-Host "Starting Tauri desktop app..."
|
||||||
|
npm run desktop
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
python speech-engine/server.py &
|
||||||
|
ENGINE_PID=$!
|
||||||
|
trap 'kill $ENGINE_PID 2>/dev/null || true' EXIT
|
||||||
|
npm run desktop
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# diart uses pyannote models. Authenticate with Hugging Face before real mode.
|
||||||
|
# You can either run `huggingface-cli login` or provide a token here if your setup consumes it.
|
||||||
|
HF_TOKEN=
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# MeetVault Local Speech Engine
|
||||||
|
|
||||||
|
This process is **local**, not the future MeetVault backend. It provides the demo's real-time Whisper + diart functionality.
|
||||||
|
|
||||||
|
## Recommended environment
|
||||||
|
|
||||||
|
Use Python **3.10 or 3.11**. `diart`/PyTorch/pyannote compatibility is much better there than on very new Python releases.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
# Windows
|
||||||
|
.venv\Scripts\activate
|
||||||
|
# Linux/macOS
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
For diart, install the native audio dependencies required by your OS and authenticate with Hugging Face if the pyannote models are gated in your environment. Follow the current diart/pyannote model instructions and accept their model terms before starting real mode.
|
||||||
|
|
||||||
|
## Run real mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The desktop application connects to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://127.0.0.1:8765/ws/live
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run mock mode
|
||||||
|
|
||||||
|
Mock mode requires only the FastAPI stack and is intended to validate UI behavior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py --mock
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also choose **Mock demo** under MeetVault Settings → Local speech engine.
|
||||||
|
|
||||||
|
## Audio protocol
|
||||||
|
|
||||||
|
- mono
|
||||||
|
- signed int16 little-endian PCM
|
||||||
|
- 16 kHz
|
||||||
|
- binary WebSocket frames
|
||||||
|
|
||||||
|
The first WebSocket message is JSON configuration. The engine replies with JSON events such as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "final",
|
||||||
|
"segment": {
|
||||||
|
"id": "uuid",
|
||||||
|
"speakerId": "speaker_1",
|
||||||
|
"start": 12.1,
|
||||||
|
"end": 15.4,
|
||||||
|
"text": "We should deploy tomorrow.",
|
||||||
|
"final": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Speaker names such as `Alice` are **not** generated by the engine. The engine produces stable session labels (`speaker_1`, `speaker_2`, ...), and the MeetVault UI lets the user rename those labels live.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Recommended Python: 3.12
|
||||||
|
|
||||||
|
fastapi>=0.115,<1
|
||||||
|
uvicorn[standard]>=0.30,<1
|
||||||
|
|
||||||
|
# diart 0.9.2 requires NumPy < 2
|
||||||
|
numpy>=1.26.4,<2
|
||||||
|
|
||||||
|
faster-whisper>=1.1,<2
|
||||||
|
python-dotenv>=1,<2
|
||||||
|
|
||||||
|
# diart 0.9.2 supports Python 3.10–3.12
|
||||||
|
diart==0.9.2
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""MeetVault local live speech engine.
|
||||||
|
|
||||||
|
Protocol
|
||||||
|
--------
|
||||||
|
WebSocket: ws://127.0.0.1:8765/ws/live
|
||||||
|
1. Client sends a JSON config message.
|
||||||
|
2. Client streams signed 16-bit little-endian mono PCM at 16 kHz.
|
||||||
|
3. Server returns JSON events: ready, partial, final, health, error.
|
||||||
|
|
||||||
|
Real mode combines faster-whisper with diart speaker diarization. Mock mode is
|
||||||
|
available so the desktop UI can be exercised without ML dependencies/models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EngineConfig:
|
||||||
|
model: str = "small"
|
||||||
|
compute: str = "auto"
|
||||||
|
language: str = "auto"
|
||||||
|
diarization: bool = True
|
||||||
|
sample_rate: int = SAMPLE_RATE
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerTimeline:
|
||||||
|
"""Thread-safe speaker timeline built from diart streaming predictions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._segments: list[tuple[float, float, str]] = []
|
||||||
|
self._label_map: dict[str, str] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _stable_label(self, raw: str) -> str:
|
||||||
|
with self._lock:
|
||||||
|
if raw not in self._label_map:
|
||||||
|
self._label_map[raw] = f"speaker_{len(self._label_map) + 1}"
|
||||||
|
return self._label_map[raw]
|
||||||
|
|
||||||
|
def update_from_annotation(self, annotation) -> None:
|
||||||
|
fresh: list[tuple[float, float, str]] = []
|
||||||
|
try:
|
||||||
|
for segment, _, label in annotation.itertracks(yield_label=True):
|
||||||
|
fresh.append((float(segment.start), float(segment.end), self._stable_label(str(label))))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not fresh:
|
||||||
|
return
|
||||||
|
latest_end = max(end for _, end, _ in fresh)
|
||||||
|
with self._lock:
|
||||||
|
# Keep a rolling two-minute history, then merge the latest prediction.
|
||||||
|
self._segments = [s for s in self._segments if s[1] >= latest_end - 120]
|
||||||
|
self._segments.extend(fresh)
|
||||||
|
|
||||||
|
def resolve(self, start: float, end: float) -> str:
|
||||||
|
with self._lock:
|
||||||
|
candidates = list(self._segments)
|
||||||
|
best_label = "speaker_1"
|
||||||
|
best_overlap = 0.0
|
||||||
|
for s, e, label in candidates:
|
||||||
|
overlap = max(0.0, min(end, e) - max(start, s))
|
||||||
|
if overlap > best_overlap:
|
||||||
|
best_overlap = overlap
|
||||||
|
best_label = label
|
||||||
|
return best_label
|
||||||
|
|
||||||
|
|
||||||
|
class QueueAudioSource:
|
||||||
|
"""diart AudioSource fed by PCM pushed from the WebSocket thread."""
|
||||||
|
|
||||||
|
def __init__(self, sample_rate: int):
|
||||||
|
from diart.sources import AudioSource
|
||||||
|
|
||||||
|
class _Source(AudioSource):
|
||||||
|
def __init__(self, sr: int):
|
||||||
|
super().__init__("meetvault-live", sr)
|
||||||
|
self.q: queue.Queue[Optional[np.ndarray]] = queue.Queue()
|
||||||
|
self.cursor = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_regular(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def push(self, samples: np.ndarray):
|
||||||
|
self.q.put(samples.astype(np.float32, copy=False))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.q.put(None)
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
from pyannote.core import SlidingWindow, SlidingWindowFeature
|
||||||
|
|
||||||
|
while True:
|
||||||
|
item = self.q.get()
|
||||||
|
if item is None:
|
||||||
|
self.stream.on_completed()
|
||||||
|
return
|
||||||
|
start = self.cursor / self.sample_rate
|
||||||
|
window = SlidingWindow(start=start, duration=1 / self.sample_rate, step=1 / self.sample_rate)
|
||||||
|
feature = SlidingWindowFeature(item.reshape(-1, 1), window)
|
||||||
|
self.cursor += len(item)
|
||||||
|
self.stream.on_next(feature)
|
||||||
|
|
||||||
|
self.source = _Source(sample_rate)
|
||||||
|
|
||||||
|
def push(self, samples: np.ndarray) -> None:
|
||||||
|
self.source.push(samples)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.source.close()
|
||||||
|
|
||||||
|
|
||||||
|
class DiartWorker:
|
||||||
|
def __init__(self, enabled: bool, sample_rate: int = SAMPLE_RATE):
|
||||||
|
self.enabled = enabled
|
||||||
|
self.timeline = SpeakerTimeline()
|
||||||
|
self.audio_source: Optional[QueueAudioSource] = None
|
||||||
|
self.thread: Optional[threading.Thread] = None
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
if enabled:
|
||||||
|
self._start(sample_rate)
|
||||||
|
|
||||||
|
def _start(self, sample_rate: int) -> None:
|
||||||
|
try:
|
||||||
|
from diart import SpeakerDiarization
|
||||||
|
from diart.inference import StreamingInference
|
||||||
|
|
||||||
|
pipeline = SpeakerDiarization()
|
||||||
|
expected_rate = int(getattr(pipeline.config, "sample_rate", sample_rate))
|
||||||
|
if expected_rate != sample_rate:
|
||||||
|
raise RuntimeError(f"diart expects {expected_rate} Hz but MeetVault sends {sample_rate} Hz")
|
||||||
|
self.audio_source = QueueAudioSource(sample_rate)
|
||||||
|
inference = StreamingInference(pipeline, self.audio_source.source, do_plot=False)
|
||||||
|
|
||||||
|
def hook(annotation_and_waveform):
|
||||||
|
try:
|
||||||
|
self.timeline.update_from_annotation(annotation_and_waveform[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
inference.attach_hooks(hook)
|
||||||
|
self.thread = threading.Thread(target=inference, name="meetvault-diart", daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
except Exception as exc:
|
||||||
|
self.error = str(exc)
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
def push(self, samples: np.ndarray) -> None:
|
||||||
|
if self.enabled and self.audio_source:
|
||||||
|
self.audio_source.push(samples)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.audio_source:
|
||||||
|
self.audio_source.close()
|
||||||
|
|
||||||
|
def resolve(self, start: float, end: float) -> str:
|
||||||
|
return self.timeline.resolve(start, end) if self.enabled else "speaker_1"
|
||||||
|
|
||||||
|
|
||||||
|
class LiveSpeechSession:
|
||||||
|
def __init__(self, config: EngineConfig):
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
self.config = config
|
||||||
|
device = config.compute
|
||||||
|
if device == "auto":
|
||||||
|
device = "cpu"
|
||||||
|
compute_type = "float16" if device == "cuda" else "int8"
|
||||||
|
try:
|
||||||
|
self.model = WhisperModel(config.model, device=device, compute_type=compute_type)
|
||||||
|
except Exception:
|
||||||
|
# Useful fallback when CUDA was selected but is not available.
|
||||||
|
self.model = WhisperModel(config.model, device="cpu", compute_type="int8")
|
||||||
|
|
||||||
|
self.diarizer = DiartWorker(config.diarization, config.sample_rate)
|
||||||
|
self.samples = np.empty(0, dtype=np.float32)
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.emitted_until = 0.0
|
||||||
|
self.last_process_samples = 0
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def push_pcm(self, raw: bytes) -> None:
|
||||||
|
pcm = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
|
||||||
|
if pcm.size == 0:
|
||||||
|
return
|
||||||
|
self.diarizer.push(pcm)
|
||||||
|
with self.lock:
|
||||||
|
self.samples = np.concatenate([self.samples, pcm])
|
||||||
|
# Keep at most 20 minutes in memory. Long meetings are still recorded by the desktop app.
|
||||||
|
max_samples = self.config.sample_rate * 60 * 20
|
||||||
|
if self.samples.size > max_samples:
|
||||||
|
dropped = self.samples.size - max_samples
|
||||||
|
self.samples = self.samples[dropped:]
|
||||||
|
self.emitted_until = max(0.0, self.emitted_until - dropped / self.config.sample_rate)
|
||||||
|
|
||||||
|
def total_seconds(self) -> float:
|
||||||
|
with self.lock:
|
||||||
|
return self.samples.size / self.config.sample_rate
|
||||||
|
|
||||||
|
def should_process(self) -> bool:
|
||||||
|
with self.lock:
|
||||||
|
new_samples = self.samples.size - self.last_process_samples
|
||||||
|
return new_samples >= int(self.config.sample_rate * 1.4)
|
||||||
|
|
||||||
|
def transcribe_once(self) -> tuple[list[dict], Optional[str]]:
|
||||||
|
with self.lock:
|
||||||
|
total = self.samples.size
|
||||||
|
if total < self.config.sample_rate:
|
||||||
|
return [], None
|
||||||
|
window_size = min(total, self.config.sample_rate * 10)
|
||||||
|
audio = self.samples[-window_size:].copy()
|
||||||
|
self.last_process_samples = total
|
||||||
|
window_start = max(0.0, total / self.config.sample_rate - len(audio) / self.config.sample_rate)
|
||||||
|
|
||||||
|
segments_iter, _ = self.model.transcribe(
|
||||||
|
audio,
|
||||||
|
language=None if self.config.language in ("", "auto") else self.config.language,
|
||||||
|
beam_size=1,
|
||||||
|
best_of=1,
|
||||||
|
vad_filter=True,
|
||||||
|
word_timestamps=True,
|
||||||
|
condition_on_previous_text=False,
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
segments = list(segments_iter)
|
||||||
|
results: list[dict] = []
|
||||||
|
partial: Optional[str] = None
|
||||||
|
safe_cutoff = total / self.config.sample_rate - 0.65
|
||||||
|
|
||||||
|
for seg in segments:
|
||||||
|
text = seg.text.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
abs_start = window_start + float(seg.start)
|
||||||
|
abs_end = window_start + float(seg.end)
|
||||||
|
if abs_end <= self.emitted_until + 0.08:
|
||||||
|
continue
|
||||||
|
if abs_end > safe_cutoff:
|
||||||
|
partial = text
|
||||||
|
continue
|
||||||
|
speaker_id = self.diarizer.resolve(abs_start, abs_end)
|
||||||
|
results.append({
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"speakerId": speaker_id,
|
||||||
|
"start": abs_start,
|
||||||
|
"end": abs_end,
|
||||||
|
"text": text,
|
||||||
|
"final": True,
|
||||||
|
})
|
||||||
|
self.emitted_until = max(self.emitted_until, abs_end)
|
||||||
|
|
||||||
|
return results, partial
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
self.diarizer.close()
|
||||||
|
|
||||||
|
|
||||||
|
class MockSession:
|
||||||
|
def __init__(self, config: EngineConfig):
|
||||||
|
self.config = config
|
||||||
|
self.sample_count = 0
|
||||||
|
self.index = 0
|
||||||
|
self.lines = [
|
||||||
|
("speaker_1", "We should finalize the upload flow before Friday."),
|
||||||
|
("speaker_2", "I can take the local recording and video playback work."),
|
||||||
|
("speaker_1", "Great. Keep the backend in processing state for this demo."),
|
||||||
|
("speaker_3", "The overlay should keep updating while another application is open."),
|
||||||
|
]
|
||||||
|
|
||||||
|
def push_pcm(self, raw: bytes) -> Optional[dict]:
|
||||||
|
self.sample_count += len(raw) // 2
|
||||||
|
threshold = self.config.sample_rate * 2.4
|
||||||
|
if self.sample_count < threshold:
|
||||||
|
return None
|
||||||
|
self.sample_count = 0
|
||||||
|
speaker, text = self.lines[self.index % len(self.lines)]
|
||||||
|
self.index += 1
|
||||||
|
end = self.index * 2.4
|
||||||
|
return {
|
||||||
|
"id": str(uuid.uuid4()), "speakerId": speaker,
|
||||||
|
"start": end - 2.1, "end": end, "text": text, "final": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="MeetVault Local Speech Engine")
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
MOCK_ONLY = False
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"ok": True, "mode": "mock" if MOCK_ONLY else "real-capable", "sample_rate": SAMPLE_RATE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/live")
|
||||||
|
async def live(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
session = None
|
||||||
|
process_task = None
|
||||||
|
try:
|
||||||
|
first = await websocket.receive_text()
|
||||||
|
payload = json.loads(first)
|
||||||
|
config = EngineConfig(
|
||||||
|
model=str(payload.get("model", "small")),
|
||||||
|
compute=str(payload.get("compute", "auto")),
|
||||||
|
language=str(payload.get("language", "auto")),
|
||||||
|
diarization=bool(payload.get("diarization", True)),
|
||||||
|
sample_rate=int(payload.get("sample_rate", SAMPLE_RATE)),
|
||||||
|
)
|
||||||
|
if config.sample_rate != SAMPLE_RATE:
|
||||||
|
await websocket.send_json({"type": "error", "message": "MeetVault speech engine currently requires 16 kHz mono PCM."})
|
||||||
|
return
|
||||||
|
|
||||||
|
if MOCK_ONLY:
|
||||||
|
session = MockSession(config)
|
||||||
|
await websocket.send_json({"type": "ready", "engine": "mock-whisper", "diarization": "mock-diart"})
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
session = await asyncio.to_thread(LiveSpeechSession, config)
|
||||||
|
except Exception as exc:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": (
|
||||||
|
f"Could not initialize Whisper/diart: {exc}. "
|
||||||
|
"Use --mock for UI testing. Real diarization may require Hugging Face authentication and accepted pyannote model terms."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
diarization_state = "diart ready" if session.diarizer.enabled else f"diart unavailable: {session.diarizer.error}"
|
||||||
|
await websocket.send_json({"type": "ready", "engine": f"Whisper {config.model}", "diarization": diarization_state})
|
||||||
|
|
||||||
|
async def processor():
|
||||||
|
while not session.closed:
|
||||||
|
await asyncio.sleep(0.35)
|
||||||
|
if not session.should_process():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
finals, partial = await asyncio.to_thread(session.transcribe_once)
|
||||||
|
for segment in finals:
|
||||||
|
await websocket.send_json({"type": "final", "segment": segment})
|
||||||
|
if partial:
|
||||||
|
await websocket.send_json({"type": "partial", "text": partial})
|
||||||
|
except Exception as exc:
|
||||||
|
await websocket.send_json({"type": "error", "message": f"Live transcription error: {exc}"})
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
process_task = asyncio.create_task(processor())
|
||||||
|
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message.get("bytes") is not None:
|
||||||
|
raw = message["bytes"]
|
||||||
|
if isinstance(session, MockSession):
|
||||||
|
segment = session.push_pcm(raw)
|
||||||
|
if segment:
|
||||||
|
await websocket.send_json({"type": "final", "segment": segment})
|
||||||
|
else:
|
||||||
|
session.push_pcm(raw)
|
||||||
|
elif message.get("text"):
|
||||||
|
command = json.loads(message["text"])
|
||||||
|
if command.get("type") == "stop":
|
||||||
|
break
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if session:
|
||||||
|
session.close()
|
||||||
|
if process_task:
|
||||||
|
process_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
|
parser.add_argument("--mock", action="store_true", help="Run without Whisper/diart models")
|
||||||
|
args = parser.parse_args()
|
||||||
|
global MOCK_ONLY
|
||||||
|
MOCK_ONLY = args.mock
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "meetvault_demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MeetVault desktop demo"
|
||||||
|
authors = ["MeetVault"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "meetvault_demo_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "MeetVault demo desktop permissions",
|
||||||
|
"windows": ["main", "overlay"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"fs:allow-mkdir",
|
||||||
|
"fs:allow-write-file",
|
||||||
|
"fs:allow-write-text-file",
|
||||||
|
"fs:allow-read-file",
|
||||||
|
{
|
||||||
|
"identifier": "fs:scope",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$APPLOCALDATA/meetings/**/*" },
|
||||||
|
{ "path": "$APPLOCALDATA/meetings/*" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dialog:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running MeetVault");
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
meetvault_demo_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "MeetVault Demo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.meetvault.demo",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://127.0.0.1:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "MeetVault Demo",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 1080,
|
||||||
|
"minHeight": 720,
|
||||||
|
"resizable": true,
|
||||||
|
"center": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null,
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["$APPLOCALDATA/meetings/**/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Wireframes/Desktop/MeetVault-Desktop-Demo/src/App.tsx
Normal file
25
Wireframes/Desktop/MeetVault-Desktop-Demo/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { LiveMeeting } from './pages/LiveMeeting';
|
||||||
|
import { MeetingDetails } from './pages/MeetingDetails';
|
||||||
|
import { Meetings } from './pages/Meetings';
|
||||||
|
import { Overlay } from './pages/Overlay';
|
||||||
|
import { SettingsPage } from './pages/Settings';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<HashRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/overlay" element={<Overlay />} />
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/live" element={<LiveMeeting />} />
|
||||||
|
<Route path="/meetings" element={<Meetings />} />
|
||||||
|
<Route path="/meetings/:id" element={<MeetingDetails />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Gauge, History, Settings } from 'lucide-react';
|
||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand">MeetVault</div>
|
||||||
|
<nav>
|
||||||
|
<NavLink to="/" end><Gauge size={18} /> Dashboard</NavLink>
|
||||||
|
<NavLink to="/meetings"><History size={18} /> Meetings</NavLink>
|
||||||
|
<NavLink to="/settings"><Settings size={18} /> Settings</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-note">
|
||||||
|
<strong>Demo mode</strong>
|
||||||
|
<span>Local files · no login</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="main-area"><Outlet /></main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ProcessingCard() {
|
||||||
|
return (
|
||||||
|
<div className="processing-card">
|
||||||
|
<LoaderCircle className="spin" size={22} />
|
||||||
|
<div>
|
||||||
|
<strong>Processing</strong>
|
||||||
|
<p>Processing (The job). Please wait a moment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
return <span className={`badge badge-${status}`}>{status[0].toUpperCase() + status.slice(1)}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Film } from 'lucide-react';
|
||||||
|
import { mediaUrl } from '../lib/localFiles';
|
||||||
|
|
||||||
|
export function VideoPlayer({ path, mime }: { path?: string; mime?: string }) {
|
||||||
|
const url = mediaUrl(path);
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<div className="video-empty">
|
||||||
|
<Film size={34} />
|
||||||
|
<strong>No local recording yet</strong>
|
||||||
|
<span>Start a meeting with system audio/screen capture to create a video, or microphone-only for audio.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mime?.startsWith('audio/')) {
|
||||||
|
return <div className="audio-wrap"><audio controls src={url} /></div>;
|
||||||
|
}
|
||||||
|
return <video className="video-player" controls src={url} />;
|
||||||
|
}
|
||||||
11
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/backend.ts
Normal file
11
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/backend.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface DemoJobResult {
|
||||||
|
status: 'processing';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDemoBackendJob(): Promise<DemoJobResult> {
|
||||||
|
return {
|
||||||
|
status: 'processing',
|
||||||
|
message: 'Processing (The job). Please wait a moment',
|
||||||
|
};
|
||||||
|
}
|
||||||
124
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/capture.ts
Normal file
124
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/capture.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
export interface CaptureOptions {
|
||||||
|
captureSystemAudio: boolean;
|
||||||
|
microphoneDeviceId?: string;
|
||||||
|
onPcm: (pcm: Int16Array) => void;
|
||||||
|
onLevel?: (level: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureController {
|
||||||
|
hasVideo: boolean;
|
||||||
|
stop: () => Promise<Blob>;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downsampleFloat32(input: Float32Array, sourceRate: number, targetRate = 16000): Float32Array {
|
||||||
|
if (sourceRate === targetRate) return input;
|
||||||
|
const ratio = sourceRate / targetRate;
|
||||||
|
const length = Math.round(input.length / ratio);
|
||||||
|
const output = new Float32Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const next = Math.round((i + 1) * ratio);
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (; offset < next && offset < input.length; offset++) {
|
||||||
|
sum += input[offset];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
output[i] = count ? sum / count : 0;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floatToInt16(input: Float32Array): Int16Array {
|
||||||
|
const output = new Int16Array(input.length);
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const sample = Math.max(-1, Math.min(1, input[i]));
|
||||||
|
output[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMime(hasVideo: boolean): string {
|
||||||
|
const candidates = hasVideo
|
||||||
|
? ['video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9,opus', 'video/webm']
|
||||||
|
: ['audio/webm;codecs=opus', 'audio/webm'];
|
||||||
|
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCapture(options: CaptureOptions): Promise<CaptureController> {
|
||||||
|
const mic = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: options.microphoneDeviceId && options.microphoneDeviceId !== 'default'
|
||||||
|
? { deviceId: { exact: options.microphoneDeviceId }, noiseSuppression: true, autoGainControl: true }
|
||||||
|
: { noiseSuppression: true, autoGainControl: true },
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let display: MediaStream | undefined;
|
||||||
|
if (options.captureSystemAudio) {
|
||||||
|
display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = new AudioContext();
|
||||||
|
await context.resume();
|
||||||
|
const mix = context.createGain();
|
||||||
|
const destination = context.createMediaStreamDestination();
|
||||||
|
const processor = context.createScriptProcessor(4096, 1, 1);
|
||||||
|
const silent = context.createGain();
|
||||||
|
silent.gain.value = 0;
|
||||||
|
|
||||||
|
context.createMediaStreamSource(mic).connect(mix);
|
||||||
|
if (display && display.getAudioTracks().length) {
|
||||||
|
context.createMediaStreamSource(new MediaStream(display.getAudioTracks())).connect(mix);
|
||||||
|
}
|
||||||
|
|
||||||
|
mix.connect(destination);
|
||||||
|
mix.connect(processor);
|
||||||
|
processor.connect(silent);
|
||||||
|
silent.connect(context.destination);
|
||||||
|
|
||||||
|
processor.onaudioprocess = (event) => {
|
||||||
|
const channel = event.inputBuffer.getChannelData(0);
|
||||||
|
let rms = 0;
|
||||||
|
for (let i = 0; i < channel.length; i++) rms += channel[i] * channel[i];
|
||||||
|
options.onLevel?.(Math.sqrt(rms / channel.length));
|
||||||
|
const downsampled = downsampleFloat32(channel, context.sampleRate, 16000);
|
||||||
|
options.onPcm(floatToInt16(downsampled));
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordingStream = new MediaStream();
|
||||||
|
destination.stream.getAudioTracks().forEach((track) => recordingStream.addTrack(track));
|
||||||
|
const videoTrack = display?.getVideoTracks()[0];
|
||||||
|
if (videoTrack) recordingStream.addTrack(videoTrack);
|
||||||
|
|
||||||
|
const hasVideo = Boolean(videoTrack);
|
||||||
|
const mimeType = pickMime(hasVideo);
|
||||||
|
const recorder = new MediaRecorder(recordingStream, mimeType ? { mimeType } : undefined);
|
||||||
|
const chunks: BlobPart[] = [];
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size) chunks.push(event.data);
|
||||||
|
};
|
||||||
|
recorder.start(1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasVideo,
|
||||||
|
pause: () => {
|
||||||
|
if (recorder.state === 'recording') recorder.pause();
|
||||||
|
},
|
||||||
|
resume: () => {
|
||||||
|
if (recorder.state === 'paused') recorder.resume();
|
||||||
|
},
|
||||||
|
stop: () => new Promise<Blob>((resolve) => {
|
||||||
|
recorder.onstop = async () => {
|
||||||
|
processor.disconnect();
|
||||||
|
silent.disconnect();
|
||||||
|
mic.getTracks().forEach((track) => track.stop());
|
||||||
|
display?.getTracks().forEach((track) => track.stop());
|
||||||
|
await context.close();
|
||||||
|
resolve(new Blob(chunks, { type: recorder.mimeType || (hasVideo ? 'video/webm' : 'audio/webm') }));
|
||||||
|
};
|
||||||
|
recorder.stop();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
45
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/liveBus.ts
Normal file
45
Wireframes/Desktop/MeetVault-Desktop-Demo/src/lib/liveBus.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { emit } from '@tauri-apps/api/event';
|
||||||
|
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||||
|
import type { LiveState } from '../types';
|
||||||
|
import { isTauriRuntime } from './tauri';
|
||||||
|
|
||||||
|
const KEY = 'meetvault-live-state-v1';
|
||||||
|
|
||||||
|
export async function broadcastLiveState(state: LiveState): Promise<void> {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(state));
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
try { await emit('meetvault:live-state', state); } catch { /* ignored in web preview */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedLiveState(): LiveState | undefined {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try { return JSON.parse(raw) as LiveState; } catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openOverlayWindow(): Promise<boolean> {
|
||||||
|
if (!isTauriRuntime()) return false;
|
||||||
|
const existing = await WebviewWindow.getByLabel('overlay');
|
||||||
|
if (existing) {
|
||||||
|
await existing.show();
|
||||||
|
await existing.setFocus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const overlay = new WebviewWindow('overlay', {
|
||||||
|
url: 'index.html#/overlay',
|
||||||
|
title: 'MeetVault Live Overlay',
|
||||||
|
width: 900,
|
||||||
|
height: 250,
|
||||||
|
decorations: false,
|
||||||
|
transparent: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
resizable: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
shadow: false,
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
overlay.once('tauri://created', () => resolve(true));
|
||||||
|
overlay.once('tauri://error', () => resolve(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
|
import { appLocalDataDir, join } from '@tauri-apps/api/path';
|
||||||
|
import { BaseDirectory, mkdir, writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
|
||||||
|
import type { Meeting } from '../types';
|
||||||
|
import { isTauriRuntime } from './tauri';
|
||||||
|
|
||||||
|
export async function saveMeetingLocally(meeting: Meeting, media: Blob): Promise<{ mediaPath: string; mediaMime: string }> {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
return { mediaPath: URL.createObjectURL(media), mediaMime: media.type || 'video/webm' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = `meetings/${meeting.id}`;
|
||||||
|
await mkdir(dir, { baseDir: BaseDirectory.AppLocalData, recursive: true });
|
||||||
|
const extension = media.type.startsWith('audio/') ? 'webm' : 'webm';
|
||||||
|
const relativeMediaPath = `${dir}/recording.${extension}`;
|
||||||
|
const bytes = new Uint8Array(await media.arrayBuffer());
|
||||||
|
await writeFile(relativeMediaPath, bytes, { baseDir: BaseDirectory.AppLocalData });
|
||||||
|
await writeTextFile(`${dir}/meeting.json`, JSON.stringify(meeting, null, 2), { baseDir: BaseDirectory.AppLocalData });
|
||||||
|
await writeTextFile(`${dir}/transcript.json`, JSON.stringify(meeting.transcript, null, 2), { baseDir: BaseDirectory.AppLocalData });
|
||||||
|
|
||||||
|
const root = await appLocalDataDir();
|
||||||
|
const mediaPath = await join(root, relativeMediaPath);
|
||||||
|
return { mediaPath, mediaMime: media.type || 'video/webm' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaUrl(path?: string): string | undefined {
|
||||||
|
if (!path) return undefined;
|
||||||
|
if (path.startsWith('blob:') || path.startsWith('http:') || path.startsWith('https:')) return path;
|
||||||
|
return isTauriRuntime() ? convertFileSrc(path) : path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { AppSettings, SpeechEvent } from '../types';
|
||||||
|
|
||||||
|
export class SpeechClient {
|
||||||
|
private ws?: WebSocket;
|
||||||
|
private mockTimer = 0;
|
||||||
|
private mockSamples = 0;
|
||||||
|
private mockIndex = 0;
|
||||||
|
private onEvent: (event: SpeechEvent) => void;
|
||||||
|
private settings: AppSettings;
|
||||||
|
|
||||||
|
constructor(settings: AppSettings, onEvent: (event: SpeechEvent) => void) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.onEvent = onEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.settings.speechMode === 'mock') {
|
||||||
|
this.onEvent({ type: 'ready', engine: 'mock-whisper', diarization: 'mock-diart' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(this.settings.speechEngineUrl);
|
||||||
|
this.ws = ws;
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'config',
|
||||||
|
model: this.settings.whisperModel,
|
||||||
|
compute: this.settings.computeDevice,
|
||||||
|
language: this.settings.language,
|
||||||
|
diarization: this.settings.diarization,
|
||||||
|
sample_rate: 16000,
|
||||||
|
}));
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
ws.onerror = () => reject(new Error('Could not connect to the local speech engine. Start speech-engine/server.py or switch Settings → Speech engine mode to Mock.'));
|
||||||
|
ws.onmessage = (message) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(String(message.data)) as SpeechEvent;
|
||||||
|
this.onEvent(event);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed diagnostics.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => this.onEvent({ type: 'health', message: 'Speech engine disconnected' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPcm(pcm: Int16Array): void {
|
||||||
|
if (this.settings.speechMode === 'mock') {
|
||||||
|
this.mockSamples += pcm.length;
|
||||||
|
if (this.mockSamples > 16000 * 2.6) {
|
||||||
|
this.mockSamples = 0;
|
||||||
|
this.emitMockLine();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.mockTimer) window.clearTimeout(this.mockTimer);
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify({ type: 'stop' }));
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMockLine(): void {
|
||||||
|
const lines = [
|
||||||
|
['speaker_1', 'We should finalize the upload flow before Friday.'],
|
||||||
|
['speaker_2', 'I can take the local storage and video playback work.'],
|
||||||
|
['speaker_1', 'Great. The backend can remain in processing state for the demo.'],
|
||||||
|
['speaker_3', 'Can we also keep the live overlay visible while another app is open?'],
|
||||||
|
];
|
||||||
|
const [speakerId, text] = lines[this.mockIndex % lines.length];
|
||||||
|
this.mockIndex += 1;
|
||||||
|
const now = this.mockIndex * 2.6;
|
||||||
|
this.onEvent({
|
||||||
|
type: 'final',
|
||||||
|
segment: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
speakerId,
|
||||||
|
start: Math.max(0, now - 2.4),
|
||||||
|
end: now,
|
||||||
|
text,
|
||||||
|
final: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function isTauriRuntime(): boolean {
|
||||||
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
}
|
||||||
8
Wireframes/Desktop/MeetVault-Desktop-Demo/src/main.tsx
Normal file
8
Wireframes/Desktop/MeetVault-Desktop-Demo/src/main.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode><App /></React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { HardDrive, MonitorUp, Mic2, Plus } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
|
function fmt(seconds: number) {
|
||||||
|
if (!seconds) return '—';
|
||||||
|
return `${Math.round(seconds / 60)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const meetings = useAppStore((s) => s.meetings);
|
||||||
|
const settings = useAppStore((s) => s.settings);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page dashboard-page">
|
||||||
|
<div className="page-heading-row">
|
||||||
|
<div>
|
||||||
|
<h1>Good morning</h1>
|
||||||
|
<p>Record, transcribe, identify speakers, and review meetings locally.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn primary" onClick={() => navigate('/live?start=1')}><Plus size={18} /> Start new meeting</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="dashboard-cards">
|
||||||
|
<article className="card dashboard-card">
|
||||||
|
<div className="card-title"><Mic2 size={18} /> Quick start</div>
|
||||||
|
<p>Microphone: {settings.microphoneDeviceId === 'default' ? 'Default system microphone' : settings.microphoneDeviceId}</p>
|
||||||
|
<p>Local STT: Whisper {settings.whisperModel}</p>
|
||||||
|
<button className="btn primary small" onClick={() => navigate('/live?start=1')}>Start recording</button>
|
||||||
|
</article>
|
||||||
|
<article className="card dashboard-card">
|
||||||
|
<div className="card-title"><MonitorUp size={18} /> Live overlay</div>
|
||||||
|
<p>Overlay: {settings.overlayEnabled ? 'Enabled' : 'Disabled'}</p>
|
||||||
|
<p>Speaker labels: On · renameable live</p>
|
||||||
|
<button className="btn secondary small" onClick={() => navigate('/live')}>Preview / configure</button>
|
||||||
|
</article>
|
||||||
|
<article className="card dashboard-card">
|
||||||
|
<div className="card-title"><HardDrive size={18} /> Local storage</div>
|
||||||
|
<p>Files are stored locally for this demo.</p>
|
||||||
|
<p>No login, subscription, PostgreSQL, or SeaweedFS is used yet.</p>
|
||||||
|
<span className="badge badge-ready">Local first</span>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<h2>Recent meetings</h2>
|
||||||
|
<div className="table-card">
|
||||||
|
<div className="table-row table-head"><span>Title</span><span>Date</span><span>Duration</span><span>Status</span><span>Actions</span></div>
|
||||||
|
{meetings.slice(0, 6).map((meeting) => (
|
||||||
|
<div className="table-row" key={meeting.id}>
|
||||||
|
<strong>{meeting.title}</strong>
|
||||||
|
<span>{new Date(meeting.startedAt).toLocaleDateString()}</span>
|
||||||
|
<span>{fmt(meeting.durationSeconds)}</span>
|
||||||
|
<span><StatusBadge status={meeting.status} /></span>
|
||||||
|
<span><button className="btn secondary tiny" onClick={() => navigate(`/meetings/${meeting.id}`)}>Open</button></span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { Mic, MonitorUp, Pause, Play, Square } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import type { SpeechEvent } from '../types';
|
||||||
|
import { startCapture, type CaptureController } from '../lib/capture';
|
||||||
|
import { broadcastLiveState, openOverlayWindow } from '../lib/liveBus';
|
||||||
|
import { saveMeetingLocally } from '../lib/localFiles';
|
||||||
|
import { SpeechClient } from '../lib/speechClient';
|
||||||
|
import { isTauriRuntime } from '../lib/tauri';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
|
function secondsFmt(total: number) {
|
||||||
|
const m = Math.floor(total / 60).toString().padStart(2, '0');
|
||||||
|
const s = Math.floor(total % 60).toString().padStart(2, '0');
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveMeeting() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const meetings = useAppStore((s) => s.meetings);
|
||||||
|
const activeMeetingId = useAppStore((s) => s.activeMeetingId);
|
||||||
|
const createMeeting = useAppStore((s) => s.createMeeting);
|
||||||
|
const appendSegment = useAppStore((s) => s.appendSegment);
|
||||||
|
const renameSpeaker = useAppStore((s) => s.renameSpeaker);
|
||||||
|
const finishMeeting = useAppStore((s) => s.finishMeeting);
|
||||||
|
const settings = useAppStore((s) => s.settings);
|
||||||
|
|
||||||
|
const [meetingId, setMeetingId] = useState<string | undefined>(activeMeetingId);
|
||||||
|
const [engineState, setEngineState] = useState('Idle');
|
||||||
|
const [partial, setPartial] = useState('');
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [micLevel, setMicLevel] = useState(0);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const captureRef = useRef<CaptureController>();
|
||||||
|
const speechRef = useRef<SpeechClient>();
|
||||||
|
const timerRef = useRef<number>();
|
||||||
|
const hasAutoStarted = useRef(false);
|
||||||
|
|
||||||
|
const meeting = useMemo(() => meetings.find((m) => m.id === meetingId), [meetings, meetingId]);
|
||||||
|
|
||||||
|
async function pushLiveState(id = meetingId) {
|
||||||
|
const current = useAppStore.getState().meetings.find((m) => m.id === id);
|
||||||
|
if (!current) return;
|
||||||
|
await broadcastLiveState({ meetingId: current.id, title: current.title, elapsedSeconds: elapsed, transcript: current.transcript, speakerNames: current.speakerNames, recording: running });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { if (meetingId) void pushLiveState(); }, [meeting?.transcript.length, meeting?.speakerNames, elapsed, running]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
listen<{ meetingId?: string; speakerId: string; name: string }>('meetvault:rename-speaker', (event) => {
|
||||||
|
if (!event.payload.meetingId || event.payload.meetingId === meetingId) renameSpeaker(meetingId!, event.payload.speakerId, event.payload.name);
|
||||||
|
}).then((fn) => { unlisten = fn; });
|
||||||
|
return () => unlisten?.();
|
||||||
|
}, [meetingId, renameSpeaker]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.get('start') === '1' && !hasAutoStarted.current) {
|
||||||
|
hasAutoStarted.current = true;
|
||||||
|
void startLive();
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function startLive() {
|
||||||
|
try {
|
||||||
|
setError(undefined);
|
||||||
|
let id = meetingId;
|
||||||
|
if (!id) {
|
||||||
|
id = createMeeting('Weekly Product Sync');
|
||||||
|
setMeetingId(id);
|
||||||
|
}
|
||||||
|
const client = new SpeechClient(settings, (event: SpeechEvent) => {
|
||||||
|
if (event.type === 'final') {
|
||||||
|
appendSegment(id!, event.segment);
|
||||||
|
setPartial('');
|
||||||
|
} else if (event.type === 'partial') {
|
||||||
|
setPartial(event.text);
|
||||||
|
} else if (event.type === 'ready') {
|
||||||
|
setEngineState('Whisper + diart ready');
|
||||||
|
} else if (event.type === 'health') {
|
||||||
|
setEngineState(event.message || 'Healthy');
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
setError(event.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
speechRef.current = client;
|
||||||
|
const capture = await startCapture({
|
||||||
|
captureSystemAudio: settings.captureSystemAudio,
|
||||||
|
microphoneDeviceId: settings.microphoneDeviceId,
|
||||||
|
onPcm: (pcm) => client.sendPcm(pcm),
|
||||||
|
onLevel: setMicLevel,
|
||||||
|
});
|
||||||
|
captureRef.current = capture;
|
||||||
|
setRunning(true);
|
||||||
|
setEngineState('Healthy');
|
||||||
|
const started = Date.now();
|
||||||
|
timerRef.current = window.setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000);
|
||||||
|
if (settings.overlayEnabled) await openOverlayWindow();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setEngineState('Unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePause() {
|
||||||
|
if (!captureRef.current) return;
|
||||||
|
if (paused) captureRef.current.resume(); else captureRef.current.pause();
|
||||||
|
setPaused(!paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
if (!meetingId || !captureRef.current) return;
|
||||||
|
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||||
|
speechRef.current?.stop();
|
||||||
|
const media = await captureRef.current.stop();
|
||||||
|
const current = useAppStore.getState().meetings.find((m) => m.id === meetingId)!;
|
||||||
|
const saved = await saveMeetingLocally(current, media);
|
||||||
|
finishMeeting(meetingId, saved.mediaPath, saved.mediaMime);
|
||||||
|
setRunning(false);
|
||||||
|
await pushLiveState(meetingId);
|
||||||
|
navigate(`/meetings/${meetingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rename(speakerId: string) {
|
||||||
|
if (!meetingId) return;
|
||||||
|
const next = window.prompt('Speaker name', meeting?.speakerNames[speakerId] || speakerId);
|
||||||
|
if (next) renameSpeaker(meetingId, speakerId, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakers = Array.from(new Set(meeting?.transcript.map((s) => s.speakerId) ?? []));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page live-page">
|
||||||
|
<div className="page-heading-row">
|
||||||
|
<div><h1>{meeting?.title || 'Live Meeting'}</h1><p>Real-time local transcription and speaker diarization.</p></div>
|
||||||
|
<div className="rec-badge">● REC {secondsFmt(elapsed)}</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{!running && <div className="start-live-panel card"><h2>Ready to start</h2><p>Live transcription uses the local speech engine. Switch Settings → Speech engine mode to Mock if you only want to test the UI.</p><button className="btn primary" onClick={startLive}><Mic size={18} /> Start live meeting</button></div>}
|
||||||
|
|
||||||
|
<div className="live-grid">
|
||||||
|
<section className="card transcript-panel">
|
||||||
|
<h2>Live transcription</h2>
|
||||||
|
<div className="transcript-live-scroll">
|
||||||
|
{meeting?.transcript.map((segment) => (
|
||||||
|
<div className="live-line" key={segment.id}>
|
||||||
|
<button className="speaker-pill" onClick={() => rename(segment.speakerId)}>{meeting.speakerNames[segment.speakerId] || segment.speakerId}</button>
|
||||||
|
<span>{segment.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{partial && <div className="partial-line">Listening… {partial}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="level-row"><span>Mic</span><div className="level-track"><i style={{ width: `${Math.min(100, micLevel * 420)}%` }} /></div><span>{engineState}</span></div>
|
||||||
|
<div className="live-actions"><button className="btn secondary" disabled={!running} onClick={togglePause}>{paused ? <Play size={16} /> : <Pause size={16} />} {paused ? 'Resume' : 'Pause'}</button><button className="btn primary" onClick={openOverlayWindow}><MonitorUp size={16} /> Show overlay</button><button className="btn danger" disabled={!running} onClick={finish}><Square size={16} /> Finish meeting</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="card live-side">
|
||||||
|
<h2>Speakers</h2>
|
||||||
|
<div className="speaker-editor-list">
|
||||||
|
{speakers.length === 0 && <span className="muted">Speaker labels will appear after diarization starts.</span>}
|
||||||
|
{speakers.map((speakerId) => <div className="speaker-editor" key={speakerId}><input value={meeting?.speakerNames[speakerId] || speakerId} onChange={(e) => renameSpeaker(meetingId!, speakerId, e.target.value)} /><button className="btn secondary tiny" onClick={() => rename(speakerId)}>Rename</button></div>)}
|
||||||
|
</div>
|
||||||
|
<h3>Audio sources</h3><div className="source-chips"><span>✓ Microphone</span><span>{settings.captureSystemAudio ? '✓' : '○'} System audio</span></div>
|
||||||
|
<h3>Local transcription</h3><div className="engine-summary">Whisper {settings.whisperModel}<br />Compute: {settings.computeDevice}<br />Diarization: {settings.diarization ? 'diart enabled' : 'disabled'}</div><span className="badge badge-ready">{engineState}</span>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ArrowLeft, Copy, RefreshCw, BellRing } from 'lucide-react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ProcessingCard } from '../components/ProcessingCard';
|
||||||
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
import { VideoPlayer } from '../components/VideoPlayer';
|
||||||
|
import { requestDemoBackendJob } from '../lib/backend';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
|
function ts(value: number) {
|
||||||
|
const m = Math.floor(value / 60).toString().padStart(2, '0');
|
||||||
|
const s = Math.floor(value % 60).toString().padStart(2, '0');
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeetingDetails() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const meeting = useAppStore((s) => s.meetings.find((m) => m.id === id));
|
||||||
|
const renameSpeaker = useAppStore((s) => s.renameSpeaker);
|
||||||
|
const setProcessing = useAppStore((s) => s.setProcessing);
|
||||||
|
if (!meeting) return <div className="page"><h1>Meeting not found</h1></div>;
|
||||||
|
|
||||||
|
async function backendAction() {
|
||||||
|
await requestDemoBackendJob();
|
||||||
|
setProcessing(meeting!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTranscript() {
|
||||||
|
const text = meeting!.transcript.map((s) => `${meeting!.speakerNames[s.speakerId] || s.speakerId}: ${s.text}`).join('\n');
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rename(speakerId: string) {
|
||||||
|
const next = window.prompt('Speaker name', meeting!.speakerNames[speakerId] || speakerId);
|
||||||
|
if (next) renameSpeaker(meeting!.id, speakerId, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page details-page">
|
||||||
|
<button className="btn ghost" onClick={() => navigate('/meetings')}><ArrowLeft size={17} /> Back to meetings</button>
|
||||||
|
<div className="page-heading-row">
|
||||||
|
<div><h1>{meeting.title}</h1><p>{new Date(meeting.startedAt).toLocaleString()} · {Math.round(meeting.durationSeconds / 60)} min · {Object.keys(meeting.speakerNames).length || 'Detected'} speakers</p></div>
|
||||||
|
<StatusBadge status={meeting.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="details-top-grid">
|
||||||
|
<div className="card video-card"><VideoPlayer path={meeting.mediaPath} mime={meeting.mediaMime} /></div>
|
||||||
|
<div className="card info-card">
|
||||||
|
<h3>Meeting information</h3>
|
||||||
|
<dl><dt>Storage</dt><dd>Local</dd><dt>Status</dt><dd>{meeting.status}</dd><dt>Started</dt><dd>{new Date(meeting.startedAt).toLocaleString()}</dd><dt>Ended</dt><dd>{meeting.endedAt ? new Date(meeting.endedAt).toLocaleString() : '—'}</dd></dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs-bar">Transcript <span>Summary</span> <span>Action items</span> <span>Files</span></div>
|
||||||
|
|
||||||
|
<div className="details-bottom-grid">
|
||||||
|
<section>
|
||||||
|
<div className="section-title-row"><h2>Transcript</h2><button className="btn secondary tiny" onClick={copyTranscript}><Copy size={15} /> Copy</button></div>
|
||||||
|
<div className="transcript-list card">
|
||||||
|
{meeting.transcript.length === 0 && <div className="empty-state">No transcript stored for this demo meeting.</div>}
|
||||||
|
{meeting.transcript.map((segment) => (
|
||||||
|
<div className="transcript-line" key={segment.id}>
|
||||||
|
<button className="speaker-pill" onClick={() => rename(segment.speakerId)}>{meeting.speakerNames[segment.speakerId] || segment.speakerId} {ts(segment.start)}</button>
|
||||||
|
<p>{segment.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Backend demo</h2>
|
||||||
|
<ProcessingCard />
|
||||||
|
<div className="card backend-actions">
|
||||||
|
<p>Summary, tasks and reminders are intentionally not implemented in this demo backend.</p>
|
||||||
|
<div><button className="btn secondary" onClick={backendAction}><RefreshCw size={16} /> Regenerate summary</button><button className="btn primary" onClick={backendAction}><BellRing size={16} /> Create reminder</button></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
|
export function Meetings() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const meetings = useAppStore((s) => s.meetings);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const filtered = useMemo(() => meetings.filter((m) => m.title.toLowerCase().includes(query.toLowerCase())), [meetings, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-heading-row"><div><h1>Meetings</h1><p>Local recordings and transcripts.</p></div></div>
|
||||||
|
<div className="search-box"><Search size={18} /><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search meetings" /></div>
|
||||||
|
<div className="meeting-grid">
|
||||||
|
{filtered.map((meeting) => (
|
||||||
|
<button className="meeting-card" key={meeting.id} onClick={() => navigate(`/meetings/${meeting.id}`)}>
|
||||||
|
<div><strong>{meeting.title}</strong><span>{new Date(meeting.startedAt).toLocaleString()}</span></div>
|
||||||
|
<StatusBadge status={meeting.status} />
|
||||||
|
<span>{meeting.durationSeconds ? `${Math.round(meeting.durationSeconds / 60)} min` : 'In progress'}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { listen, emit } from '@tauri-apps/api/event';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { LiveState } from '../types';
|
||||||
|
import { getCachedLiveState } from '../lib/liveBus';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
import { isTauriRuntime } from '../lib/tauri';
|
||||||
|
|
||||||
|
export function Overlay() {
|
||||||
|
const settings = useAppStore((s) => s.settings);
|
||||||
|
const [state, setState] = useState<LiveState | undefined>(() => getCachedLiveState());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
listen<LiveState>('meetvault:live-state', (event) => setState(event.payload)).then((fn) => { unlisten = fn; });
|
||||||
|
return () => unlisten?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visible = useMemo(() => state?.transcript.slice(-settings.overlayLines) ?? [], [state, settings.overlayLines]);
|
||||||
|
|
||||||
|
async function rename(speakerId: string) {
|
||||||
|
const current = state?.speakerNames[speakerId] || speakerId;
|
||||||
|
const next = window.prompt('Rename speaker', current);
|
||||||
|
if (!next) return;
|
||||||
|
if (isTauriRuntime()) await emit('meetvault:rename-speaker', { meetingId: state?.meetingId, speakerId, name: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay-root" style={{ opacity: settings.overlayOpacity / 100, fontSize: settings.overlayFontSize }}>
|
||||||
|
<div className="overlay-header"><span>MeetVault live captions</span>{isTauriRuntime() && <button onClick={() => getCurrentWindow().hide()}>Hide</button>}</div>
|
||||||
|
{visible.length === 0 ? <div className="overlay-empty">Waiting for speech…</div> : visible.map((segment) => (
|
||||||
|
<div className="overlay-line" key={segment.id}>
|
||||||
|
<button onClick={() => rename(segment.speakerId)}>{state?.speakerNames[segment.speakerId] || segment.speakerId}</button>
|
||||||
|
<span>{segment.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const settings = useAppStore((s) => s.settings);
|
||||||
|
const setSettings = useAppStore((s) => s.setSettings);
|
||||||
|
const [draft, setDraft] = useState(settings);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
useEffect(() => setDraft(settings), [settings]);
|
||||||
|
|
||||||
|
const set = <K extends keyof typeof draft>(key: K, value: (typeof draft)[K]) => setDraft((d) => ({ ...d, [key]: value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page settings-page">
|
||||||
|
<div className="page-heading-row"><div><h1>Settings</h1><p>Configure MeetVault desktop behavior and local transcription.</p></div></div>
|
||||||
|
|
||||||
|
<section className="settings-section"><h2>General</h2><div className="settings-card card three-col">
|
||||||
|
<label className="toggle-row"><input type="checkbox" checked={draft.launchAtStartup} onChange={(e) => set('launchAtStartup', e.target.checked)} /> Launch MeetVault at startup</label>
|
||||||
|
<label><span>Global hotkey</span><input value={draft.globalHotkey} onChange={(e) => set('globalHotkey', e.target.value)} /></label>
|
||||||
|
<label><span>Theme</span><select value={draft.theme} onChange={(e) => set('theme', e.target.value as typeof draft.theme)}><option value="system">System default</option><option value="light">Light</option><option value="dark">Dark</option></select></label>
|
||||||
|
<label className="toggle-row"><input type="checkbox" checked={draft.keepInTray} onChange={(e) => set('keepInTray', e.target.checked)} /> Keep running in system tray</label>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<section className="settings-section"><h2>Audio</h2><div className="settings-card card three-col">
|
||||||
|
<label><span>Microphone</span><input value={draft.microphoneDeviceId} onChange={(e) => set('microphoneDeviceId', e.target.value)} /></label>
|
||||||
|
<label className="toggle-row"><input type="checkbox" checked={draft.captureSystemAudio} onChange={(e) => set('captureSystemAudio', e.target.checked)} /> Capture system audio / screen</label>
|
||||||
|
<label className="toggle-row"><input type="checkbox" checked={draft.monitorMicLevel} onChange={(e) => set('monitorMicLevel', e.target.checked)} /> Monitor microphone level</label>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<section className="settings-section"><h2>Transcription</h2><div className="settings-card card four-col">
|
||||||
|
<label><span>Model</span><select value={draft.whisperModel} onChange={(e) => set('whisperModel', e.target.value as typeof draft.whisperModel)}><option>base</option><option>small</option><option>medium</option></select></label>
|
||||||
|
<label><span>Compute</span><select value={draft.computeDevice} onChange={(e) => set('computeDevice', e.target.value as typeof draft.computeDevice)}><option value="auto">Auto</option><option value="cpu">CPU</option><option value="cuda">CUDA</option></select></label>
|
||||||
|
<label><span>Language</span><input value={draft.language} onChange={(e) => set('language', e.target.value)} /></label>
|
||||||
|
<label className="toggle-row"><input type="checkbox" checked={draft.diarization} onChange={(e) => set('diarization', e.target.checked)} /> Speaker diarization</label>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<section className="settings-section"><h2>Overlay</h2><div className="settings-card card four-col">
|
||||||
|
<label className="toggle-row"><input type="checkbox" checked={draft.overlayEnabled} onChange={(e) => set('overlayEnabled', e.target.checked)} /> Enable overlay</label>
|
||||||
|
<label><span>Opacity</span><input type="range" min="40" max="100" value={draft.overlayOpacity} onChange={(e) => set('overlayOpacity', Number(e.target.value))} /></label>
|
||||||
|
<label><span>Font size</span><input type="number" min="14" max="36" value={draft.overlayFontSize} onChange={(e) => set('overlayFontSize', Number(e.target.value))} /></label>
|
||||||
|
<label><span>Visible lines</span><input type="number" min="1" max="8" value={draft.overlayLines} onChange={(e) => set('overlayLines', Number(e.target.value))} /></label>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<section className="settings-section"><h2>Local speech engine</h2><div className="settings-card card three-col">
|
||||||
|
<label><span>Mode</span><select value={draft.speechMode} onChange={(e) => set('speechMode', e.target.value as typeof draft.speechMode)}><option value="real">Real Whisper + diart</option><option value="mock">Mock demo</option></select></label>
|
||||||
|
<label className="wide"><span>WebSocket URL</span><input value={draft.speechEngineUrl} onChange={(e) => set('speechEngineUrl', e.target.value)} /></label>
|
||||||
|
<div className="settings-help">Real mode requires <code>speech-engine/server.py</code>. Mock mode lets the UI run without ML dependencies.</div>
|
||||||
|
</div></section>
|
||||||
|
|
||||||
|
<div className="save-bar"><span>{saved ? 'Saved.' : 'Local settings only.'}</span><button className="btn primary" onClick={() => { setSettings(draft); setSaved(true); window.setTimeout(() => setSaved(false), 1200); }}><Save size={17} /> Save changes</button></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { AppSettings, Meeting, TranscriptSegment } from '../types';
|
||||||
|
|
||||||
|
const defaultSettings: AppSettings = {
|
||||||
|
launchAtStartup: false,
|
||||||
|
keepInTray: true,
|
||||||
|
globalHotkey: 'Ctrl + Shift + M',
|
||||||
|
theme: 'system',
|
||||||
|
microphoneDeviceId: 'default',
|
||||||
|
captureSystemAudio: false,
|
||||||
|
monitorMicLevel: true,
|
||||||
|
whisperModel: 'small',
|
||||||
|
computeDevice: 'auto',
|
||||||
|
language: 'auto',
|
||||||
|
diarization: true,
|
||||||
|
overlayEnabled: true,
|
||||||
|
overlayOpacity: 92,
|
||||||
|
overlayFontSize: 20,
|
||||||
|
overlayLines: 4,
|
||||||
|
overlayClickThrough: false,
|
||||||
|
speechEngineUrl: 'ws://127.0.0.1:8765/ws/live',
|
||||||
|
speechMode: 'real',
|
||||||
|
};
|
||||||
|
|
||||||
|
const demoMeetings: Meeting[] = [
|
||||||
|
{
|
||||||
|
id: 'demo-weekly-product-sync',
|
||||||
|
title: 'Weekly Product Sync',
|
||||||
|
startedAt: '2026-09-27T09:00:00.000Z',
|
||||||
|
endedAt: '2026-09-27T09:48:05.000Z',
|
||||||
|
durationSeconds: 2885,
|
||||||
|
status: 'processing',
|
||||||
|
transcript: [
|
||||||
|
{ id: 'd1', speakerId: 'speaker_1', start: 1121, end: 1126, text: 'We should finalize the API contract before tomorrow.', final: true },
|
||||||
|
{ id: 'd2', speakerId: 'speaker_2', start: 1129, end: 1135, text: 'I will update the database migration tonight.', final: true },
|
||||||
|
{ id: 'd3', speakerId: 'speaker_1', start: 1142, end: 1148, text: "Perfect. I'll prepare the release notes after that.", final: true },
|
||||||
|
],
|
||||||
|
speakerNames: { speaker_1: 'Alice', speaker_2: 'Bob' },
|
||||||
|
localOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-backend-review',
|
||||||
|
title: 'Backend Architecture Review',
|
||||||
|
startedAt: '2026-09-26T08:30:00.000Z',
|
||||||
|
endedAt: '2026-09-26T09:31:00.000Z',
|
||||||
|
durationSeconds: 3660,
|
||||||
|
status: 'ready',
|
||||||
|
transcript: [],
|
||||||
|
speakerNames: {},
|
||||||
|
localOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
settings: AppSettings;
|
||||||
|
meetings: Meeting[];
|
||||||
|
activeMeetingId?: string;
|
||||||
|
processingMessage?: string;
|
||||||
|
setSettings: (patch: Partial<AppSettings>) => void;
|
||||||
|
createMeeting: (title?: string) => string;
|
||||||
|
appendSegment: (meetingId: string, segment: TranscriptSegment) => void;
|
||||||
|
renameSpeaker: (meetingId: string, speakerId: string, name: string) => void;
|
||||||
|
finishMeeting: (meetingId: string, mediaPath?: string, mediaMime?: string) => void;
|
||||||
|
setMeetingMedia: (meetingId: string, mediaPath: string, mediaMime: string) => void;
|
||||||
|
setProcessing: (meetingId: string) => void;
|
||||||
|
clearProcessingMessage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
settings: defaultSettings,
|
||||||
|
meetings: demoMeetings,
|
||||||
|
activeMeetingId: undefined,
|
||||||
|
processingMessage: undefined,
|
||||||
|
setSettings: (patch) => set((state) => ({ settings: { ...state.settings, ...patch } })),
|
||||||
|
createMeeting: (title = 'Untitled Meeting') => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const meeting: Meeting = {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
durationSeconds: 0,
|
||||||
|
status: 'recording',
|
||||||
|
transcript: [],
|
||||||
|
speakerNames: {},
|
||||||
|
localOnly: true,
|
||||||
|
};
|
||||||
|
set((state) => ({ meetings: [meeting, ...state.meetings], activeMeetingId: id }));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
appendSegment: (meetingId, segment) => {
|
||||||
|
set((state) => ({
|
||||||
|
meetings: state.meetings.map((m) => {
|
||||||
|
if (m.id !== meetingId) return m;
|
||||||
|
const exists = m.transcript.some((s) => s.id === segment.id);
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
transcript: exists ? m.transcript.map((s) => (s.id === segment.id ? segment : s)) : [...m.transcript, segment],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
renameSpeaker: (meetingId, speakerId, name) => {
|
||||||
|
set((state) => ({
|
||||||
|
meetings: state.meetings.map((m) => m.id === meetingId
|
||||||
|
? { ...m, speakerNames: { ...m.speakerNames, [speakerId]: name.trim() || speakerId } }
|
||||||
|
: m),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
finishMeeting: (meetingId, mediaPath, mediaMime) => {
|
||||||
|
const meeting = get().meetings.find((m) => m.id === meetingId);
|
||||||
|
if (!meeting) return;
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
const durationSeconds = Math.max(1, Math.round((Date.parse(endedAt) - Date.parse(meeting.startedAt)) / 1000));
|
||||||
|
set((state) => ({
|
||||||
|
meetings: state.meetings.map((m) => m.id === meetingId ? {
|
||||||
|
...m,
|
||||||
|
endedAt,
|
||||||
|
durationSeconds,
|
||||||
|
status: 'processing',
|
||||||
|
mediaPath: mediaPath ?? m.mediaPath,
|
||||||
|
mediaMime: mediaMime ?? m.mediaMime,
|
||||||
|
} : m),
|
||||||
|
activeMeetingId: undefined,
|
||||||
|
processingMessage: 'Processing (The job). Please wait a moment',
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setMeetingMedia: (meetingId, mediaPath, mediaMime) => set((state) => ({
|
||||||
|
meetings: state.meetings.map((m) => m.id === meetingId ? { ...m, mediaPath, mediaMime } : m),
|
||||||
|
})),
|
||||||
|
setProcessing: (meetingId) => set((state) => ({
|
||||||
|
meetings: state.meetings.map((m) => m.id === meetingId ? { ...m, status: 'processing' } : m),
|
||||||
|
processingMessage: 'Processing (The job). Please wait a moment',
|
||||||
|
})),
|
||||||
|
clearProcessingMessage: () => set({ processingMessage: undefined }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'meetvault-demo-store-v1',
|
||||||
|
partialize: (state) => ({ settings: state.settings, meetings: state.meetings }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
138
Wireframes/Desktop/MeetVault-Desktop-Demo/src/styles.css
Normal file
138
Wireframes/Desktop/MeetVault-Desktop-Demo/src/styles.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #13233f;
|
||||||
|
background: #f5f7fb;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { margin: 0; min-width: 100%; min-height: 100%; }
|
||||||
|
body { min-height: 100vh; background: #f5f7fb; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
code { background: #eef2f8; padding: 2px 6px; border-radius: 6px; }
|
||||||
|
|
||||||
|
.app-shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||||
|
.sidebar { background: #20252d; color: white; padding: 28px 22px; display: flex; flex-direction: column; gap: 36px; }
|
||||||
|
.brand { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; }
|
||||||
|
.sidebar nav { display: grid; gap: 10px; }
|
||||||
|
.sidebar nav a { color: #d8dee8; text-decoration: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 10px; font-weight: 650; }
|
||||||
|
.sidebar nav a.active { background: #2d66ec; color: white; }
|
||||||
|
.sidebar-note { margin-top: auto; background: #f4f6fa; color: #27354d; padding: 14px; border-radius: 12px; display: grid; gap: 4px; font-size: 12px; }
|
||||||
|
.main-area { min-width: 0; }
|
||||||
|
.page { padding: 42px 48px 56px; max-width: 1500px; margin: 0 auto; }
|
||||||
|
.page h1 { font-size: 28px; margin: 0 0 8px; letter-spacing: -0.02em; }
|
||||||
|
.page h2 { font-size: 18px; margin: 0; }
|
||||||
|
.page h3 { font-size: 15px; margin: 24px 0 10px; }
|
||||||
|
.page p { color: #5b6780; }
|
||||||
|
.page-heading-row { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 36px; }
|
||||||
|
.page-heading-row p { margin: 0; }
|
||||||
|
|
||||||
|
.btn { border: 0; border-radius: 9px; padding: 11px 15px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; }
|
||||||
|
.btn.primary { background: #2d66ec; color: white; }
|
||||||
|
.btn.secondary { background: #edf2ff; color: #2453c3; border: 1px solid #cad7ff; }
|
||||||
|
.btn.danger { background: #fff0ef; color: #c3382d; border: 1px solid #ffd0cb; }
|
||||||
|
.btn.ghost { background: transparent; color: #2f4b7c; padding-left: 0; }
|
||||||
|
.btn.small { padding: 9px 13px; }
|
||||||
|
.btn.tiny { padding: 7px 11px; font-size: 12px; }
|
||||||
|
.btn:disabled { opacity: .45; cursor: default; }
|
||||||
|
.card { background: white; border: 1px solid #dfe5ef; border-radius: 18px; box-shadow: 0 1px 0 rgba(20, 40, 80, .02); }
|
||||||
|
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 82px; padding: 6px 10px; border-radius: 7px; font-size: 12px; font-weight: 750; }
|
||||||
|
.badge-ready { background: #eafff5; color: #137b58; border: 1px solid #bdebd9; }
|
||||||
|
.badge-processing { background: #eef3ff; color: #3b5fc5; border: 1px solid #ccd8ff; }
|
||||||
|
.badge-recording { background: #fff1ef; color: #c43b30; border: 1px solid #ffd1cb; }
|
||||||
|
|
||||||
|
.dashboard-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
|
||||||
|
.dashboard-card { padding: 24px; min-height: 190px; }
|
||||||
|
.dashboard-card p { margin: 8px 0; font-size: 14px; }
|
||||||
|
.card-title { display: flex; align-items: center; gap: 9px; font-weight: 800; margin-bottom: 22px; }
|
||||||
|
.section-block { margin-top: 46px; }
|
||||||
|
.section-block h2 { margin-bottom: 18px; }
|
||||||
|
.table-card { background: white; border: 1px solid #dfe5ef; border-radius: 18px; padding: 10px 20px; }
|
||||||
|
.table-row { display: grid; grid-template-columns: 2.3fr 1fr .8fr .8fr .7fr; align-items: center; min-height: 64px; gap: 20px; border-bottom: 1px solid #eef1f6; }
|
||||||
|
.table-row:last-child { border-bottom: 0; }
|
||||||
|
.table-head { min-height: 46px; color: #6b7891; font-size: 12px; }
|
||||||
|
|
||||||
|
.search-box { display: flex; gap: 10px; align-items: center; background: white; border: 1px solid #dfe5ef; border-radius: 12px; padding: 0 14px; max-width: 540px; margin-bottom: 24px; }
|
||||||
|
.search-box input { border: 0; outline: 0; width: 100%; padding: 13px 0; background: transparent; }
|
||||||
|
.meeting-grid { display: grid; gap: 12px; }
|
||||||
|
.meeting-card { border: 1px solid #dfe5ef; border-radius: 15px; background: white; padding: 18px 20px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 22px; text-align: left; color: inherit; }
|
||||||
|
.meeting-card div { display: grid; gap: 4px; }
|
||||||
|
.meeting-card div span { color: #78849a; font-size: 12px; }
|
||||||
|
|
||||||
|
.live-grid { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(310px, .85fr); gap: 28px; }
|
||||||
|
.transcript-panel { min-height: 620px; padding: 28px; display: flex; flex-direction: column; }
|
||||||
|
.transcript-live-scroll { flex: 1; overflow: auto; padding: 18px 0; display: grid; align-content: start; gap: 18px; }
|
||||||
|
.live-line { display: grid; grid-template-columns: 118px 1fr; gap: 16px; align-items: start; }
|
||||||
|
.speaker-pill { background: #eefbf5; border: 1px solid #b8ebd6; color: #147056; border-radius: 7px; padding: 7px 10px; font-weight: 750; font-size: 12px; }
|
||||||
|
.live-line:nth-child(even) .speaker-pill, .transcript-line:nth-child(even) .speaker-pill { background: #eef2ff; border-color: #ccd7ff; color: #3556b2; }
|
||||||
|
.partial-line { background: #f2f4f8; border: 1px solid #e1e5ec; border-radius: 10px; padding: 14px; text-align: center; color: #66738a; }
|
||||||
|
.level-row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; color: #6e7a91; font-size: 12px; }
|
||||||
|
.level-track { height: 7px; border-radius: 999px; background: #edf0f5; overflow: hidden; }
|
||||||
|
.level-track i { display: block; height: 100%; background: #2d66ec; }
|
||||||
|
.live-actions { display: flex; align-items: center; gap: 10px; margin-top: 22px; }
|
||||||
|
.live-actions .danger { margin-left: auto; }
|
||||||
|
.live-side { padding: 28px; min-height: 620px; }
|
||||||
|
.speaker-editor-list { display: grid; gap: 12px; }
|
||||||
|
.speaker-editor { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
|
||||||
|
.speaker-editor input, .settings-card input, .settings-card select { width: 100%; border: 1px solid #cad4e4; border-radius: 9px; background: white; padding: 10px 12px; color: #30415f; }
|
||||||
|
.source-chips { display: flex; gap: 10px; }
|
||||||
|
.source-chips span { background: #f2f4f8; padding: 10px 13px; border-radius: 9px; font-size: 12px; }
|
||||||
|
.engine-summary { line-height: 1.6; color: #58657b; margin-bottom: 14px; }
|
||||||
|
.rec-badge { background: #fff0ef; color: #c4392e; border: 1px solid #ffccc6; border-radius: 8px; padding: 10px 18px; }
|
||||||
|
.start-live-panel { padding: 22px; margin-bottom: 24px; }
|
||||||
|
.error-banner { background: #fff0ef; color: #9e2f27; border: 1px solid #ffd0ca; border-radius: 10px; padding: 12px 14px; margin-bottom: 20px; }
|
||||||
|
.muted { color: #8792a7; font-size: 13px; }
|
||||||
|
|
||||||
|
.details-top-grid { display: grid; grid-template-columns: 1.35fr .85fr; gap: 26px; }
|
||||||
|
.video-card { min-height: 330px; overflow: hidden; display: grid; place-items: center; }
|
||||||
|
.video-player { width: 100%; height: 100%; min-height: 330px; background: #10141d; object-fit: contain; }
|
||||||
|
.audio-wrap { width: 100%; padding: 40px; }
|
||||||
|
.audio-wrap audio { width: 100%; }
|
||||||
|
.video-empty { min-height: 330px; display: grid; place-items: center; align-content: center; gap: 10px; color: #7b879c; text-align: center; padding: 24px; }
|
||||||
|
.video-empty span { max-width: 470px; font-size: 13px; }
|
||||||
|
.info-card { padding: 24px; }
|
||||||
|
dl { display: grid; grid-template-columns: 110px 1fr; gap: 12px 16px; font-size: 13px; }
|
||||||
|
dt { color: #8390a6; } dd { margin: 0; }
|
||||||
|
.tabs-bar { margin: 30px 0 20px; background: #eaf0ff; border: 1px solid #cbd8ff; color: #2d55bd; border-radius: 10px; padding: 12px 18px; font-weight: 800; display: flex; gap: 32px; }
|
||||||
|
.tabs-bar span { font-weight: 600; color: #6680bd; }
|
||||||
|
.details-bottom-grid { display: grid; grid-template-columns: 1.2fr .8fr; gap: 28px; }
|
||||||
|
.section-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
.transcript-list { padding: 20px; min-height: 280px; }
|
||||||
|
.transcript-line { display: grid; grid-template-columns: 140px 1fr; gap: 18px; align-items: start; padding: 12px 0; border-bottom: 1px solid #edf0f5; }
|
||||||
|
.transcript-line p { margin: 4px 0 0; color: #23334e; }
|
||||||
|
.empty-state { color: #8490a5; padding: 30px; text-align: center; }
|
||||||
|
.processing-card { display: flex; gap: 14px; align-items: center; background: #fff9e8; border: 1px solid #f3dea0; color: #765614; border-radius: 14px; padding: 18px; margin: 12px 0; }
|
||||||
|
.processing-card p { margin: 4px 0 0; color: #876a27; }
|
||||||
|
.spin { animation: spin 1.2s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.backend-actions { padding: 20px; }
|
||||||
|
.backend-actions div { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.settings-section { margin-top: 28px; }
|
||||||
|
.settings-section h2 { margin-bottom: 12px; }
|
||||||
|
.settings-card { padding: 22px; display: grid; gap: 18px; }
|
||||||
|
.settings-card.three-col { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.settings-card.four-col { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.settings-card label { display: grid; gap: 7px; color: #53617a; font-size: 12px; }
|
||||||
|
.settings-card .toggle-row { display: flex; align-items: center; gap: 10px; background: #f2f4f8; padding: 11px 13px; border-radius: 9px; align-self: end; min-height: 42px; }
|
||||||
|
.settings-card .toggle-row input { width: auto; }
|
||||||
|
.settings-card .wide { grid-column: span 2; }
|
||||||
|
.settings-help { color: #6e7b90; font-size: 12px; align-self: end; }
|
||||||
|
.save-bar { position: sticky; bottom: 0; margin-top: 30px; background: rgba(245,247,251,.92); backdrop-filter: blur(8px); display: flex; justify-content: flex-end; align-items: center; gap: 16px; padding: 12px 0; }
|
||||||
|
.save-bar span { color: #6f7b90; font-size: 12px; }
|
||||||
|
|
||||||
|
.overlay-root { min-height: 100vh; background: rgba(21, 28, 42, .96); color: white; padding: 18px 26px; border-radius: 20px; overflow: hidden; }
|
||||||
|
.overlay-header { display: flex; justify-content: space-between; color: #a9b4c7; font-size: 11px; margin-bottom: 8px; }
|
||||||
|
.overlay-header button { border: 0; background: rgba(255,255,255,.1); color: white; border-radius: 7px; padding: 5px 9px; }
|
||||||
|
.overlay-line { display: grid; grid-template-columns: 110px 1fr; gap: 16px; align-items: baseline; padding: 5px 0; }
|
||||||
|
.overlay-line button { color: #85afff; font-weight: 800; background: none; border: 0; text-align: left; padding: 0; font-size: .72em; }
|
||||||
|
.overlay-line:nth-child(even) button { color: #79e7b7; }
|
||||||
|
.overlay-empty { color: #d7dde8; padding: 28px 0; text-align: center; }
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.app-shell { grid-template-columns: 190px 1fr; }
|
||||||
|
.page { padding: 28px; }
|
||||||
|
.dashboard-cards { grid-template-columns: 1fr; }
|
||||||
|
.live-grid, .details-top-grid, .details-bottom-grid { grid-template-columns: 1fr; }
|
||||||
|
.settings-card.three-col, .settings-card.four-col { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
82
Wireframes/Desktop/MeetVault-Desktop-Demo/src/types.ts
Normal file
82
Wireframes/Desktop/MeetVault-Desktop-Demo/src/types.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export type MeetingStatus = 'recording' | 'processing' | 'ready';
|
||||||
|
|
||||||
|
export interface TranscriptSegment {
|
||||||
|
id: string;
|
||||||
|
speakerId: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
final: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meeting {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
status: MeetingStatus;
|
||||||
|
mediaPath?: string;
|
||||||
|
mediaMime?: string;
|
||||||
|
transcript: TranscriptSegment[];
|
||||||
|
speakerNames: Record<string, string>;
|
||||||
|
localOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveState {
|
||||||
|
meetingId: string;
|
||||||
|
title: string;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
transcript: TranscriptSegment[];
|
||||||
|
speakerNames: Record<string, string>;
|
||||||
|
recording: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
launchAtStartup: boolean;
|
||||||
|
keepInTray: boolean;
|
||||||
|
globalHotkey: string;
|
||||||
|
theme: 'system' | 'light' | 'dark';
|
||||||
|
microphoneDeviceId: string;
|
||||||
|
captureSystemAudio: boolean;
|
||||||
|
monitorMicLevel: boolean;
|
||||||
|
whisperModel: 'base' | 'small' | 'medium';
|
||||||
|
computeDevice: 'auto' | 'cpu' | 'cuda';
|
||||||
|
language: string;
|
||||||
|
diarization: boolean;
|
||||||
|
overlayEnabled: boolean;
|
||||||
|
overlayOpacity: number;
|
||||||
|
overlayFontSize: number;
|
||||||
|
overlayLines: number;
|
||||||
|
overlayClickThrough: boolean;
|
||||||
|
speechEngineUrl: string;
|
||||||
|
speechMode: 'real' | 'mock';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechFinalEvent {
|
||||||
|
type: 'final';
|
||||||
|
segment: TranscriptSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechPartialEvent {
|
||||||
|
type: 'partial';
|
||||||
|
text: string;
|
||||||
|
speaker_id?: string;
|
||||||
|
start?: number;
|
||||||
|
end?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechHealthEvent {
|
||||||
|
type: 'ready' | 'health';
|
||||||
|
engine?: string;
|
||||||
|
diarization?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechErrorEvent {
|
||||||
|
type: 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeechEvent = SpeechFinalEvent | SpeechPartialEvent | SpeechHealthEvent | SpeechErrorEvent;
|
||||||
20
Wireframes/Desktop/MeetVault-Desktop-Demo/tsconfig.app.json
Normal file
20
Wireframes/Desktop/MeetVault-Desktop-Demo/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
Wireframes/Desktop/MeetVault-Desktop-Demo/tsconfig.json
Normal file
7
Wireframes/Desktop/MeetVault-Desktop-Demo/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
10
Wireframes/Desktop/MeetVault-Desktop-Demo/tsconfig.node.json
Normal file
10
Wireframes/Desktop/MeetVault-Desktop-Demo/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
12
Wireframes/Desktop/MeetVault-Desktop-Demo/vite.config.ts
Normal file
12
Wireframes/Desktop/MeetVault-Desktop-Demo/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
strictPort: true,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 1420,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user