Update agent

This commit is contained in:
2026-09-30 00:50:44 +07:00
parent d77a09cff4
commit 9eff88215c
48 changed files with 6374 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
node_modules/
dist/
src-tauri/target/
speech-engine/.venv/
speech-engine/__pycache__/
.env
*.pyc
.DS_Store

View 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`.

View 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
```

View File

@@ -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&amp;nbsp;&lt;div&gt;audio: On Local STT: Whisper small&lt;/div&gt;" 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&amp;nbsp;&lt;div&gt;Position: Bottom center&amp;nbsp;&lt;/div&gt;&lt;div&gt;Speaker labels: On&lt;/div&gt;" 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&amp;nbsp;&lt;div&gt;Storage used: 12.8 GB Uploads: healthy&lt;/div&gt;" 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="&lt;font style=&quot;font-size: 11px;&quot;&gt;Click the speaker&#39;s name to rename&lt;/font&gt;" 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&#39;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&#39;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&#39;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 &amp; 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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=

View File

@@ -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.

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -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"
]
}

View File

@@ -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");
}

View File

@@ -0,0 +1,3 @@
fn main() {
meetvault_demo_lib::run();
}

View File

@@ -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"
}
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,3 @@
export function StatusBadge({ status }: { status: string }) {
return <span className={`badge badge-${status}`}>{status[0].toUpperCase() + status.slice(1)}</span>;
}

View File

@@ -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} />;
}

View 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',
};
}

View 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();
}),
};
}

View 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));
});
}

View File

@@ -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;
}

View File

@@ -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,
},
});
}
}

View File

@@ -0,0 +1,3 @@
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}

View 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>,
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }),
},
),
);

View 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; }
}

View 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;

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}

View 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,
},
});