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