Phase 1 foundation: Tauri shell, Python sidecar, SQLite database
Tauri v2 + Svelte + TypeScript frontend:
- App shell with workspace layout (waveform, transcript, speakers, AI chat)
- Placeholder components for all major UI areas
- Typed stores (project, transcript, playback, AI)
- TypeScript interfaces matching the database schema
- Tauri bridge service with typed invoke wrappers
- svelte-check passes with 0 errors
Rust backend:
- Tauri v2 app entry point with command registration
- SQLite database layer (rusqlite with bundled SQLite)
- Full schema: projects, media_files, speakers, segments, words,
ai_outputs, annotations (with indexes)
- Model structs with serde serialization
- CRUD queries for projects, speakers, segments, words
- Segment text editing preserves original text
- Schema versioning for future migrations
- 6 tests passing
- Command stubs for project, transcribe, export, AI, settings, system
- App state management
Python sidecar:
- JSON-line IPC protocol (stdin/stdout)
- Message types: IPCMessage, progress, error, ready
- Handler registry with routing and error handling
- Ping/pong handler for connectivity testing
- Service stubs: transcribe, diarize, pipeline, AI, export
- Provider stubs: local (llama-server), OpenAI, Anthropic, LiteLLM
- Hardware detection stubs
- 14 tests passing, ruff clean
Also adds:
- Testing strategy document (docs/TESTING.md)
- Validation script (scripts/validate.sh)
- Updated .gitignore for Svelte, Rust, Python artifacts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9
.gitignore
vendored
@@ -10,6 +10,15 @@ dist/
|
||||
build/
|
||||
out/
|
||||
*.egg-info/
|
||||
.svelte-kit/
|
||||
target/
|
||||
|
||||
# Rust
|
||||
src-tauri/target/
|
||||
|
||||
# Python cache
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
149
docs/TESTING.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Voice to Notes — Testing Strategy
|
||||
|
||||
## Overview
|
||||
|
||||
Each layer has its own test approach. Agents should run tests after every significant change before considering work complete.
|
||||
|
||||
---
|
||||
|
||||
## 1. Rust Backend Tests
|
||||
|
||||
**Tool:** `cargo test` (built-in)
|
||||
**Location:** `src-tauri/src/` (inline `#[cfg(test)]` modules)
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo test
|
||||
```
|
||||
|
||||
**What to test:**
|
||||
- SQLite schema creation and migrations
|
||||
- Database CRUD operations (projects, media files, segments, words, speakers)
|
||||
- IPC message serialization/deserialization (serde)
|
||||
- llama-server port allocation logic
|
||||
- File path handling across platforms
|
||||
|
||||
**Lint/format check:**
|
||||
```bash
|
||||
cd src-tauri && cargo fmt --check && cargo clippy -- -D warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Python Sidecar Tests
|
||||
|
||||
**Tool:** `pytest`
|
||||
**Location:** `python/tests/`
|
||||
|
||||
```bash
|
||||
cd python && python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
**What to test:**
|
||||
- IPC protocol: JSON-line encode/decode, message routing
|
||||
- Transcription service (mock faster-whisper for unit tests)
|
||||
- Diarization service (mock pyannote for unit tests)
|
||||
- AI provider adapters (mock HTTP responses)
|
||||
- Export formatters (SRT, WebVTT, text output correctness)
|
||||
- Hardware detection logic
|
||||
|
||||
**Lint check:**
|
||||
```bash
|
||||
cd python && ruff check . && ruff format --check .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Tests
|
||||
|
||||
**Tool:** `vitest` (Vite-native, works with Svelte)
|
||||
**Location:** `src/lib/**/*.test.ts`
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
**What to test:**
|
||||
- Svelte store logic (transcript, playback, project state)
|
||||
- Tauri bridge service (mock tauri::invoke)
|
||||
- Audio sync calculations (timestamp → word mapping)
|
||||
- Export option formatting
|
||||
|
||||
**Lint/format check:**
|
||||
```bash
|
||||
npm run check # svelte-check (TypeScript)
|
||||
npm run lint # eslint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration Tests
|
||||
|
||||
### IPC Round-trip Test
|
||||
Verify Rust ↔ Python communication works end-to-end:
|
||||
```bash
|
||||
# From project root
|
||||
cd src-tauri && cargo test --test ipc_integration
|
||||
```
|
||||
This spawns the real Python sidecar, sends a ping message, and verifies the response.
|
||||
|
||||
### Tauri App Launch Test
|
||||
Verify the app starts and the frontend loads:
|
||||
```bash
|
||||
cd src-tauri && cargo test --test app_launch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Quick Validation Script
|
||||
|
||||
Agents should run this after any significant change:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/validate.sh — Run all checks
|
||||
set -e
|
||||
|
||||
echo "=== Rust checks ==="
|
||||
cd src-tauri
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
cd ..
|
||||
|
||||
echo "=== Python checks ==="
|
||||
cd python
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
python -m pytest tests/ -v
|
||||
cd ..
|
||||
|
||||
echo "=== Frontend checks ==="
|
||||
npm run check
|
||||
npm run lint
|
||||
npm run test -- --run
|
||||
|
||||
echo "=== All checks passed ==="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Manual Testing (Final User Test)
|
||||
|
||||
These require a human to verify since they involve audio playback and visual UI:
|
||||
- Import a real audio file and run transcription
|
||||
- Verify waveform displays correctly
|
||||
- Click words → audio seeks to correct position
|
||||
- Rename speakers → changes propagate
|
||||
- Export caption files → open in VLC/subtitle editor
|
||||
- AI chat → get responses about transcript content
|
||||
|
||||
---
|
||||
|
||||
## 7. Test Fixtures
|
||||
|
||||
Store small test fixtures in `tests/fixtures/`:
|
||||
- `short_clip.wav` — 5-second audio clip with 2 speakers (for integration tests)
|
||||
- `sample_transcript.json` — Pre-built transcript data for UI/export tests
|
||||
- `sample_ipc_messages.jsonl` — Example IPC message sequences
|
||||
|
||||
Agents should create mock/fixture data rather than requiring real audio files for unit tests.
|
||||
2309
package-lock.json
generated
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "voice-to-notes",
|
||||
"version": "0.1.0",
|
||||
"description": "Desktop app for transcribing audio/video with speaker identification",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
33
python/pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "voice-to-notes"
|
||||
version = "0.1.0"
|
||||
description = "Python sidecar for Voice to Notes — transcription, diarization, and AI services"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.8.0",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I", "B", "UP", "RUF"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
0
python/tests/__init__.py
Normal file
43
python/tests/test_handlers.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Tests for message handler routing."""
|
||||
|
||||
from voice_to_notes.ipc.handlers import HandlerRegistry, ping_handler
|
||||
from voice_to_notes.ipc.messages import IPCMessage
|
||||
|
||||
|
||||
def test_ping_handler():
|
||||
msg = IPCMessage(id="req-1", type="ping", payload={"hello": "world"})
|
||||
response = ping_handler(msg)
|
||||
assert response.type == "pong"
|
||||
assert response.id == "req-1"
|
||||
assert response.payload["echo"] == {"hello": "world"}
|
||||
|
||||
|
||||
def test_registry_routes_to_handler():
|
||||
registry = HandlerRegistry()
|
||||
registry.register("ping", ping_handler)
|
||||
msg = IPCMessage(id="req-1", type="ping", payload={})
|
||||
response = registry.handle(msg)
|
||||
assert response is not None
|
||||
assert response.type == "pong"
|
||||
|
||||
|
||||
def test_registry_unknown_type():
|
||||
registry = HandlerRegistry()
|
||||
msg = IPCMessage(id="req-1", type="nonexistent", payload={})
|
||||
response = registry.handle(msg)
|
||||
assert response is not None
|
||||
assert response.type == "error"
|
||||
assert response.payload["code"] == "unknown_type"
|
||||
|
||||
|
||||
def test_registry_handler_exception():
|
||||
def bad_handler(msg: IPCMessage) -> IPCMessage:
|
||||
raise ValueError("something broke")
|
||||
|
||||
registry = HandlerRegistry()
|
||||
registry.register("bad", bad_handler)
|
||||
msg = IPCMessage(id="req-1", type="bad", payload={})
|
||||
response = registry.handle(msg)
|
||||
assert response is not None
|
||||
assert response.type == "error"
|
||||
assert response.payload["code"] == "handler_error"
|
||||
50
python/tests/test_messages.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for IPC message types."""
|
||||
|
||||
from voice_to_notes.ipc.messages import (
|
||||
IPCMessage,
|
||||
error_message,
|
||||
progress_message,
|
||||
ready_message,
|
||||
)
|
||||
|
||||
|
||||
def test_ipc_message_to_dict():
|
||||
msg = IPCMessage(id="req-1", type="ping", payload={"key": "value"})
|
||||
d = msg.to_dict()
|
||||
assert d == {"id": "req-1", "type": "ping", "payload": {"key": "value"}}
|
||||
|
||||
|
||||
def test_ipc_message_from_dict():
|
||||
data = {"id": "req-1", "type": "ping", "payload": {"key": "value"}}
|
||||
msg = IPCMessage.from_dict(data)
|
||||
assert msg.id == "req-1"
|
||||
assert msg.type == "ping"
|
||||
assert msg.payload == {"key": "value"}
|
||||
|
||||
|
||||
def test_ipc_message_from_dict_missing_fields():
|
||||
msg = IPCMessage.from_dict({})
|
||||
assert msg.id == ""
|
||||
assert msg.type == ""
|
||||
assert msg.payload == {}
|
||||
|
||||
|
||||
def test_progress_message():
|
||||
msg = progress_message("req-1", 50, "transcribing", "Processing...")
|
||||
assert msg.type == "progress"
|
||||
assert msg.payload["percent"] == 50
|
||||
assert msg.payload["stage"] == "transcribing"
|
||||
|
||||
|
||||
def test_error_message():
|
||||
msg = error_message("req-1", "not_found", "File not found")
|
||||
assert msg.type == "error"
|
||||
assert msg.payload["code"] == "not_found"
|
||||
assert msg.payload["message"] == "File not found"
|
||||
|
||||
|
||||
def test_ready_message():
|
||||
msg = ready_message()
|
||||
assert msg.type == "ready"
|
||||
assert msg.id == "system"
|
||||
assert "version" in msg.payload
|
||||
38
python/tests/test_protocol.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Tests for IPC protocol JSON-line encoding/decoding."""
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
from voice_to_notes.ipc.messages import IPCMessage
|
||||
from voice_to_notes.ipc.protocol import read_message, write_message
|
||||
|
||||
|
||||
def test_write_message(capsys):
|
||||
msg = IPCMessage(id="req-1", type="pong", payload={"ok": True})
|
||||
write_message(msg)
|
||||
captured = capsys.readouterr()
|
||||
parsed = json.loads(captured.out.strip())
|
||||
assert parsed["id"] == "req-1"
|
||||
assert parsed["type"] == "pong"
|
||||
assert parsed["payload"]["ok"] is True
|
||||
|
||||
|
||||
def test_read_message(monkeypatch):
|
||||
line = json.dumps({"id": "req-1", "type": "ping", "payload": {}}) + "\n"
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO(line))
|
||||
msg = read_message()
|
||||
assert msg is not None
|
||||
assert msg.id == "req-1"
|
||||
assert msg.type == "ping"
|
||||
|
||||
|
||||
def test_read_message_eof(monkeypatch):
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO(""))
|
||||
msg = read_message()
|
||||
assert msg is None
|
||||
|
||||
|
||||
def test_read_message_invalid_json(monkeypatch):
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO("not json\n"))
|
||||
msg = read_message()
|
||||
assert msg is None
|
||||
3
python/voice_to_notes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Voice to Notes — Python sidecar for transcription, diarization, and AI services."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
python/voice_to_notes/hardware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Hardware detection and model selection."""
|
||||
9
python/voice_to_notes/hardware/detect.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""GPU/CPU detection and VRAM estimation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# TODO: Implement hardware detection
|
||||
# - Check torch.cuda.is_available()
|
||||
# - Detect VRAM size
|
||||
# - Detect CPU cores and available RAM
|
||||
# - Return recommended model configuration
|
||||
7
python/voice_to_notes/hardware/models.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Model selection logic based on available hardware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# TODO: Implement model selection
|
||||
# - Map hardware capabilities to recommended models
|
||||
# - Support user overrides from settings
|
||||
1
python/voice_to_notes/ipc/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""IPC protocol layer for JSON-line communication with the Rust backend."""
|
||||
39
python/voice_to_notes/ipc/handlers.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Message handler registry and routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
from voice_to_notes.ipc.messages import IPCMessage, error_message
|
||||
|
||||
# Handler function type: takes a message, returns a response message
|
||||
HandlerFunc = Callable[[IPCMessage], IPCMessage | None]
|
||||
|
||||
|
||||
class HandlerRegistry:
|
||||
"""Registry mapping message types to handler functions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[str, HandlerFunc] = {}
|
||||
|
||||
def register(self, message_type: str, handler: HandlerFunc) -> None:
|
||||
"""Register a handler for a message type."""
|
||||
self._handlers[message_type] = handler
|
||||
|
||||
def handle(self, msg: IPCMessage) -> IPCMessage | None:
|
||||
"""Route a message to its handler. Returns a response or error."""
|
||||
handler = self._handlers.get(msg.type)
|
||||
if handler is None:
|
||||
print(f"[sidecar] Unknown message type: {msg.type}", file=sys.stderr, flush=True)
|
||||
return error_message(msg.id, "unknown_type", f"Unknown message type: {msg.type}")
|
||||
try:
|
||||
return handler(msg)
|
||||
except Exception as e:
|
||||
print(f"[sidecar] Handler error for {msg.type}: {e}", file=sys.stderr, flush=True)
|
||||
return error_message(msg.id, "handler_error", str(e))
|
||||
|
||||
|
||||
def ping_handler(msg: IPCMessage) -> IPCMessage:
|
||||
"""Simple ping handler for testing connectivity."""
|
||||
return IPCMessage(id=msg.id, type="pong", payload={"echo": msg.payload})
|
||||
46
python/voice_to_notes/ipc/messages.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""IPC message type definitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class IPCMessage:
|
||||
"""A message exchanged between Rust and Python via JSON-line protocol."""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"id": self.id, "type": self.type, "payload": self.payload}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> IPCMessage:
|
||||
return cls(
|
||||
id=data.get("id", ""),
|
||||
type=data.get("type", ""),
|
||||
payload=data.get("payload", {}),
|
||||
)
|
||||
|
||||
|
||||
def progress_message(request_id: str, percent: int, stage: str, message: str) -> IPCMessage:
|
||||
return IPCMessage(
|
||||
id=request_id,
|
||||
type="progress",
|
||||
payload={"percent": percent, "stage": stage, "message": message},
|
||||
)
|
||||
|
||||
|
||||
def error_message(request_id: str, code: str, message: str) -> IPCMessage:
|
||||
return IPCMessage(
|
||||
id=request_id,
|
||||
type="error",
|
||||
payload={"code": code, "message": message},
|
||||
)
|
||||
|
||||
|
||||
def ready_message() -> IPCMessage:
|
||||
return IPCMessage(id="system", type="ready", payload={"version": "0.1.0"})
|
||||
47
python/voice_to_notes/ipc/protocol.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""JSON-line protocol reader/writer over stdin/stdout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from voice_to_notes.ipc.messages import IPCMessage
|
||||
|
||||
|
||||
def read_message() -> IPCMessage | None:
|
||||
"""Read a single JSON-line message from stdin. Returns None on EOF."""
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None # EOF
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
data = json.loads(line)
|
||||
return IPCMessage.from_dict(data)
|
||||
except json.JSONDecodeError as e:
|
||||
_log(f"Invalid JSON: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log(f"Read error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def write_message(msg: IPCMessage) -> None:
|
||||
"""Write a JSON-line message to stdout."""
|
||||
line = json.dumps(msg.to_dict(), separators=(",", ":"))
|
||||
sys.stdout.write(line + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def write_dict(data: dict[str, Any]) -> None:
|
||||
"""Write a raw dict as a JSON-line message to stdout."""
|
||||
line = json.dumps(data, separators=(",", ":"))
|
||||
sys.stdout.write(line + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
"""Log to stderr (stdout is reserved for IPC)."""
|
||||
print(f"[sidecar] {message}", file=sys.stderr, flush=True)
|
||||
52
python/voice_to_notes/main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Main entry point for the Voice to Notes Python sidecar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from voice_to_notes.ipc.handlers import HandlerRegistry, ping_handler
|
||||
from voice_to_notes.ipc.messages import ready_message
|
||||
from voice_to_notes.ipc.protocol import read_message, write_message
|
||||
|
||||
|
||||
def create_registry() -> HandlerRegistry:
|
||||
"""Set up the message handler registry."""
|
||||
registry = HandlerRegistry()
|
||||
registry.register("ping", ping_handler)
|
||||
# TODO: Register transcribe, diarize, pipeline, ai, export handlers
|
||||
return registry
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main loop: read messages from stdin, dispatch to handlers, write responses to stdout."""
|
||||
|
||||
# Handle clean shutdown
|
||||
def shutdown(signum: int, frame: object) -> None:
|
||||
print("[sidecar] Shutting down...", file=sys.stderr, flush=True)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
|
||||
registry = create_registry()
|
||||
|
||||
# Signal to Rust that we're ready
|
||||
write_message(ready_message())
|
||||
print("[sidecar] Ready and waiting for messages", file=sys.stderr, flush=True)
|
||||
|
||||
# Message loop
|
||||
while True:
|
||||
msg = read_message()
|
||||
if msg is None:
|
||||
# EOF — parent closed stdin, time to exit
|
||||
print("[sidecar] EOF on stdin, exiting", file=sys.stderr, flush=True)
|
||||
break
|
||||
|
||||
response = registry.handle(msg)
|
||||
if response is not None:
|
||||
write_message(response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
python/voice_to_notes/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""AI provider adapters — local (llama-server), LiteLLM, OpenAI, Anthropic."""
|
||||
5
python/voice_to_notes/providers/anthropic_provider.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Anthropic provider — direct Anthropic SDK integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# TODO: Implement Anthropic provider
|
||||
23
python/voice_to_notes/providers/base.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Abstract base class for AI providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AIProvider(ABC):
|
||||
"""Base interface for all AI providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def chat(self, messages: list[dict[str, Any]], config: dict[str, Any]) -> str:
|
||||
"""Send a chat completion request and return the response."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def stream(
|
||||
self, messages: list[dict[str, Any]], config: dict[str, Any]
|
||||
) -> AsyncIterator[str]:
|
||||
"""Send a streaming chat request, yielding tokens as they arrive."""
|
||||
...
|
||||
5
python/voice_to_notes/providers/litellm_provider.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""LiteLLM provider — multi-provider gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# TODO: Implement LiteLLM provider
|
||||
9
python/voice_to_notes/providers/local_provider.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Local AI provider — bundled llama-server (OpenAI-compatible API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# TODO: Implement local provider
|
||||
# - Connect to llama-server on localhost:{port}
|
||||
# - Use openai SDK with custom base_url
|
||||
# - Support chat and streaming
|
||||
5
python/voice_to_notes/providers/openai_provider.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""OpenAI provider — direct OpenAI SDK integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# TODO: Implement OpenAI provider
|
||||
1
python/voice_to_notes/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service layer — transcription, diarization, AI, and export."""
|
||||
13
python/voice_to_notes/services/ai_provider.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""AI provider service — routes requests to configured provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AIProviderService:
|
||||
"""Manages AI provider selection and routes chat/summarize requests."""
|
||||
|
||||
# TODO: Implement provider routing
|
||||
# - Select provider based on config (local, openai, anthropic, litellm)
|
||||
# - Forward chat messages
|
||||
# - Handle streaming responses
|
||||
pass
|
||||
13
python/voice_to_notes/services/diarize.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Diarization service — pyannote.audio speaker identification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DiarizeService:
|
||||
"""Handles speaker diarization via pyannote.audio."""
|
||||
|
||||
# TODO: Implement pyannote.audio integration
|
||||
# - Load community-1 model
|
||||
# - Run diarization on audio
|
||||
# - Return speaker segments with timestamps
|
||||
pass
|
||||
14
python/voice_to_notes/services/export.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Export service — caption and text document generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ExportService:
|
||||
"""Handles export to SRT, WebVTT, ASS, plain text, and Markdown."""
|
||||
|
||||
# TODO: Implement pysubs2 integration
|
||||
# - SRT with [Speaker]: prefix
|
||||
# - WebVTT with <v Speaker> voice tags
|
||||
# - ASS with named styles per speaker
|
||||
# - Plain text and Markdown with speaker labels
|
||||
pass
|
||||
14
python/voice_to_notes/services/pipeline.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Combined transcription + diarization pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class PipelineService:
|
||||
"""Runs the full WhisperX-style pipeline: transcribe -> align -> diarize -> merge."""
|
||||
|
||||
# TODO: Implement combined pipeline
|
||||
# 1. faster-whisper transcription
|
||||
# 2. wav2vec2 word-level alignment
|
||||
# 3. pyannote diarization
|
||||
# 4. Merge words with speaker segments
|
||||
pass
|
||||
13
python/voice_to_notes/services/transcribe.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Transcription service — faster-whisper + wav2vec2 pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TranscribeService:
|
||||
"""Handles audio transcription via faster-whisper."""
|
||||
|
||||
# TODO: Implement faster-whisper integration
|
||||
# - Load model based on hardware detection
|
||||
# - Transcribe audio with word-level timestamps
|
||||
# - Report progress via IPC
|
||||
pass
|
||||
1
python/voice_to_notes/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility modules."""
|
||||
46
scripts/validate.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# Run all project checks — agents run this after significant changes
|
||||
set -e
|
||||
|
||||
ERRORS=0
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "=== Rust checks ==="
|
||||
if [ -f "$ROOT_DIR/src-tauri/Cargo.toml" ]; then
|
||||
cd "$ROOT_DIR/src-tauri"
|
||||
cargo fmt --check || { echo "FAIL: cargo fmt"; ERRORS=$((ERRORS+1)); }
|
||||
cargo clippy -- -D warnings || { echo "FAIL: cargo clippy"; ERRORS=$((ERRORS+1)); }
|
||||
cargo test || { echo "FAIL: cargo test"; ERRORS=$((ERRORS+1)); }
|
||||
else
|
||||
echo "SKIP: src-tauri not set up yet"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Python checks ==="
|
||||
if [ -f "$ROOT_DIR/python/pyproject.toml" ]; then
|
||||
cd "$ROOT_DIR/python"
|
||||
ruff check . || { echo "FAIL: ruff check"; ERRORS=$((ERRORS+1)); }
|
||||
ruff format --check . || { echo "FAIL: ruff format"; ERRORS=$((ERRORS+1)); }
|
||||
python -m pytest tests/ -v || { echo "FAIL: pytest"; ERRORS=$((ERRORS+1)); }
|
||||
else
|
||||
echo "SKIP: python not set up yet"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Frontend checks ==="
|
||||
if [ -f "$ROOT_DIR/package.json" ]; then
|
||||
cd "$ROOT_DIR"
|
||||
npm run check 2>/dev/null || { echo "FAIL: svelte-check"; ERRORS=$((ERRORS+1)); }
|
||||
npm run lint 2>/dev/null || { echo "FAIL: eslint"; ERRORS=$((ERRORS+1)); }
|
||||
npm run test -- --run 2>/dev/null || { echo "FAIL: vitest"; ERRORS=$((ERRORS+1)); }
|
||||
else
|
||||
echo "SKIP: frontend not set up yet"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ $ERRORS -eq 0 ]; then
|
||||
echo "=== All checks passed ==="
|
||||
else
|
||||
echo "=== $ERRORS check(s) failed ==="
|
||||
exit 1
|
||||
fi
|
||||
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5385
src-tauri/Cargo.lock
generated
Normal file
24
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "voice-to-notes"
|
||||
version = "0.1.0"
|
||||
description = "Voice to Notes — desktop transcription with speaker identification"
|
||||
authors = ["Voice to Notes Contributors"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "voice_to_notes_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
thiserror = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
2
src-tauri/src/commands/ai.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// AI provider commands — chat, summarize via Python sidecar
|
||||
// TODO: Implement when AI provider service is built
|
||||
2
src-tauri/src/commands/export.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Export commands — trigger caption/text export via Python sidecar
|
||||
// TODO: Implement when export service is built
|
||||
6
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod ai;
|
||||
pub mod export;
|
||||
pub mod project;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod transcribe;
|
||||
27
src-tauri/src/commands/project.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::db::models::Project;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_project(name: String) -> Result<Project, String> {
|
||||
// TODO: Use actual database connection from app state
|
||||
Ok(Project {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
settings: None,
|
||||
status: "active".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_project(id: String) -> Result<Option<Project>, String> {
|
||||
// TODO: Use actual database connection from app state
|
||||
let _ = id;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_projects() -> Result<Vec<Project>, String> {
|
||||
// TODO: Use actual database connection from app state
|
||||
Ok(vec![])
|
||||
}
|
||||
2
src-tauri/src/commands/settings.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Settings commands — app preferences, model selection, AI provider config
|
||||
// TODO: Implement when settings UI is built
|
||||
2
src-tauri/src/commands/system.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// System commands — hardware detection, llama-server lifecycle
|
||||
// TODO: Implement hardware detection and llama-server management
|
||||
2
src-tauri/src/commands/transcribe.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Transcription commands — start/stop/monitor transcription via Python sidecar
|
||||
// TODO: Implement when sidecar IPC is connected
|
||||
22
src-tauri/src/db/errors.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
|
||||
#[error("Record not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid data: {0}")]
|
||||
InvalidData(String),
|
||||
}
|
||||
|
||||
impl serde::Serialize for DatabaseError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
49
src-tauri/src/db/mod.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
pub mod schema;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use errors::DatabaseError;
|
||||
|
||||
/// Open a SQLite database at the given path, creating tables if needed.
|
||||
pub fn open_database(path: &Path) -> Result<Connection, DatabaseError> {
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
// Enable WAL mode for concurrent reads
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
// Set busy timeout to 5 seconds
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
// Enable foreign key constraints
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
schema::create_tables(&conn)?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_open_in_memory() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
schema::create_tables(&conn).unwrap();
|
||||
|
||||
// Verify tables exist by querying sqlite_master
|
||||
let count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
// 8 tables: schema_version, projects, media_files, speakers, segments, words, ai_outputs, annotations
|
||||
assert_eq!(count, 8);
|
||||
}
|
||||
}
|
||||
84
src-tauri/src/db/models.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub settings: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaFile {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub file_path: String,
|
||||
pub file_hash: Option<String>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub sample_rate: Option<i32>,
|
||||
pub channels: Option<i32>,
|
||||
pub format: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Speaker {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub label: String,
|
||||
pub display_name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Segment {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub media_file_id: String,
|
||||
pub speaker_id: Option<String>,
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
pub text: String,
|
||||
pub original_text: Option<String>,
|
||||
pub confidence: Option<f64>,
|
||||
pub is_edited: bool,
|
||||
pub edited_at: Option<String>,
|
||||
pub segment_index: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Word {
|
||||
pub id: String,
|
||||
pub segment_id: String,
|
||||
pub word: String,
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
pub confidence: Option<f64>,
|
||||
pub word_index: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiOutput {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub output_type: String,
|
||||
pub prompt: Option<String>,
|
||||
pub content: String,
|
||||
pub provider: Option<String>,
|
||||
pub created_at: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub start_ms: i64,
|
||||
pub end_ms: Option<i64>,
|
||||
pub text: String,
|
||||
pub annotation_type: String,
|
||||
}
|
||||
303
src-tauri/src/db/queries.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::errors::DatabaseError;
|
||||
use super::models::*;
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn create_project(conn: &Connection, name: &str) -> Result<Project, DatabaseError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, name, created_at, updated_at, status) VALUES (?1, ?2, ?3, ?4, 'active')",
|
||||
params![id, name, now, now],
|
||||
)?;
|
||||
get_project(conn, &id)?.ok_or_else(|| DatabaseError::NotFound("project".into()))
|
||||
}
|
||||
|
||||
pub fn get_project(conn: &Connection, id: &str) -> Result<Option<Project>, DatabaseError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, created_at, updated_at, settings, status FROM projects WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![id], |row| {
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
settings: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_projects(conn: &Connection) -> Result<Vec<Project>, DatabaseError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, created_at, updated_at, settings, status FROM projects WHERE status = 'active' ORDER BY updated_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(Project {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
created_at: row.get(2)?,
|
||||
updated_at: row.get(3)?,
|
||||
settings: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn update_project(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
settings: Option<&str>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if let Some(name) = name {
|
||||
conn.execute(
|
||||
"UPDATE projects SET name = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![name, now, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(settings) = settings {
|
||||
conn.execute(
|
||||
"UPDATE projects SET settings = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![settings, now, id],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_project(conn: &Connection, id: &str) -> Result<(), DatabaseError> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"UPDATE projects SET status = 'deleted', updated_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Speakers ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn create_speaker(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
label: &str,
|
||||
color: Option<&str>,
|
||||
) -> Result<Speaker, DatabaseError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO speakers (id, project_id, label, color) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, project_id, label, color],
|
||||
)?;
|
||||
Ok(Speaker {
|
||||
id,
|
||||
project_id: project_id.to_string(),
|
||||
label: label.to_string(),
|
||||
display_name: None,
|
||||
color: color.map(String::from),
|
||||
metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_speakers_for_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<Speaker>, DatabaseError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, label, display_name, color, metadata FROM speakers WHERE project_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
Ok(Speaker {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
label: row.get(2)?,
|
||||
display_name: row.get(3)?,
|
||||
color: row.get(4)?,
|
||||
metadata: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn rename_speaker(
|
||||
conn: &Connection,
|
||||
speaker_id: &str,
|
||||
display_name: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
conn.execute(
|
||||
"UPDATE speakers SET display_name = ?1 WHERE id = ?2",
|
||||
params![display_name, speaker_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Segments ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn get_segments_for_media(
|
||||
conn: &Connection,
|
||||
media_file_id: &str,
|
||||
) -> Result<Vec<Segment>, DatabaseError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, media_file_id, speaker_id, start_ms, end_ms, text, original_text, confidence, is_edited, edited_at, segment_index FROM segments WHERE media_file_id = ?1 ORDER BY segment_index",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![media_file_id], |row| {
|
||||
Ok(Segment {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
media_file_id: row.get(2)?,
|
||||
speaker_id: row.get(3)?,
|
||||
start_ms: row.get(4)?,
|
||||
end_ms: row.get(5)?,
|
||||
text: row.get(6)?,
|
||||
original_text: row.get(7)?,
|
||||
confidence: row.get(8)?,
|
||||
is_edited: row.get(9)?,
|
||||
edited_at: row.get(10)?,
|
||||
segment_index: row.get(11)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn update_segment_text(
|
||||
conn: &Connection,
|
||||
segment_id: &str,
|
||||
new_text: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
// Preserve original text on first edit
|
||||
conn.execute(
|
||||
"UPDATE segments SET original_text = COALESCE(original_text, text), text = ?1, is_edited = 1, edited_at = ?2 WHERE id = ?3",
|
||||
params![new_text, now, segment_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reassign_speaker(
|
||||
conn: &Connection,
|
||||
segment_id: &str,
|
||||
new_speaker_id: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
conn.execute(
|
||||
"UPDATE segments SET speaker_id = ?1 WHERE id = ?2",
|
||||
params![new_speaker_id, segment_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Words ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn get_words_for_segment(
|
||||
conn: &Connection,
|
||||
segment_id: &str,
|
||||
) -> Result<Vec<Word>, DatabaseError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, segment_id, word, start_ms, end_ms, confidence, word_index FROM words WHERE segment_id = ?1 ORDER BY word_index",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![segment_id], |row| {
|
||||
Ok(Word {
|
||||
id: row.get(0)?,
|
||||
segment_id: row.get(1)?,
|
||||
word: row.get(2)?,
|
||||
start_ms: row.get(3)?,
|
||||
end_ms: row.get(4)?,
|
||||
confidence: row.get(5)?,
|
||||
word_index: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::schema;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
schema::create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_crud() {
|
||||
let conn = setup_db();
|
||||
|
||||
// Create
|
||||
let project = create_project(&conn, "Test Project").unwrap();
|
||||
assert_eq!(project.name, "Test Project");
|
||||
assert_eq!(project.status, "active");
|
||||
|
||||
// Read
|
||||
let fetched = get_project(&conn, &project.id).unwrap().unwrap();
|
||||
assert_eq!(fetched.name, "Test Project");
|
||||
|
||||
// List
|
||||
let projects = list_projects(&conn).unwrap();
|
||||
assert_eq!(projects.len(), 1);
|
||||
|
||||
// Update
|
||||
update_project(&conn, &project.id, Some("Renamed"), None).unwrap();
|
||||
let updated = get_project(&conn, &project.id).unwrap().unwrap();
|
||||
assert_eq!(updated.name, "Renamed");
|
||||
|
||||
// Delete (soft)
|
||||
delete_project(&conn, &project.id).unwrap();
|
||||
let active = list_projects(&conn).unwrap();
|
||||
assert_eq!(active.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speaker_operations() {
|
||||
let conn = setup_db();
|
||||
let project = create_project(&conn, "Test").unwrap();
|
||||
|
||||
let speaker = create_speaker(&conn, &project.id, "Speaker 1", Some("#ff0000")).unwrap();
|
||||
assert_eq!(speaker.label, "Speaker 1");
|
||||
|
||||
rename_speaker(&conn, &speaker.id, "Alice").unwrap();
|
||||
let speakers = get_speakers_for_project(&conn, &project.id).unwrap();
|
||||
assert_eq!(speakers[0].display_name.as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_segment_text_editing() {
|
||||
let conn = setup_db();
|
||||
let project = create_project(&conn, "Test").unwrap();
|
||||
|
||||
// Insert a media file first
|
||||
let media_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO media_files (id, project_id, file_path, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![media_id, project.id, "test.wav", now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert a segment
|
||||
let seg_id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO segments (id, project_id, media_file_id, start_ms, end_ms, text, segment_index) VALUES (?1, ?2, ?3, 0, 1000, 'hello world', 0)",
|
||||
params![seg_id, project.id, media_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Edit the text
|
||||
update_segment_text(&conn, &seg_id, "hello everyone").unwrap();
|
||||
|
||||
let segments = get_segments_for_media(&conn, &media_id).unwrap();
|
||||
assert_eq!(segments[0].text, "hello everyone");
|
||||
assert_eq!(segments[0].original_text.as_deref(), Some("hello world"));
|
||||
assert!(segments[0].is_edited);
|
||||
}
|
||||
}
|
||||
136
src-tauri/src/db/schema.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::errors::DatabaseError;
|
||||
|
||||
const CURRENT_SCHEMA_VERSION: i32 = 1;
|
||||
|
||||
pub fn create_tables(conn: &Connection) -> Result<(), DatabaseError> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
settings TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
file_path TEXT NOT NULL,
|
||||
file_hash TEXT,
|
||||
duration_ms INTEGER,
|
||||
sample_rate INTEGER,
|
||||
channels INTEGER,
|
||||
format TEXT,
|
||||
file_size INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speakers (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
label TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
color TEXT,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS segments (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
media_file_id TEXT NOT NULL REFERENCES media_files(id),
|
||||
speaker_id TEXT REFERENCES speakers(id),
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
original_text TEXT,
|
||||
confidence REAL,
|
||||
is_edited INTEGER NOT NULL DEFAULT 0,
|
||||
edited_at TEXT,
|
||||
segment_index INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS words (
|
||||
id TEXT PRIMARY KEY,
|
||||
segment_id TEXT NOT NULL REFERENCES segments(id),
|
||||
word TEXT NOT NULL,
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
confidence REAL,
|
||||
word_index INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_outputs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
output_type TEXT NOT NULL,
|
||||
prompt TEXT,
|
||||
content TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'bookmark'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_segments_project ON segments(project_id, segment_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_segments_time ON segments(media_file_id, start_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_words_segment ON words(segment_id, word_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_words_time ON words(start_ms, end_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_outputs_project ON ai_outputs(project_id, output_type);
|
||||
",
|
||||
)?;
|
||||
|
||||
// Initialize schema version if empty
|
||||
let count: i32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM schema_version",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if count == 0 {
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?1)",
|
||||
[CURRENT_SCHEMA_VERSION],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_tables_idempotent() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
// Running again should be fine (IF NOT EXISTS)
|
||||
create_tables(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_version() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
let version: i32 = conn
|
||||
.query_row("SELECT version FROM schema_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, CURRENT_SCHEMA_VERSION);
|
||||
}
|
||||
}
|
||||
18
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
pub mod commands;
|
||||
pub mod db;
|
||||
pub mod state;
|
||||
|
||||
use commands::project::{create_project, get_project, list_projects};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
create_project,
|
||||
get_project,
|
||||
list_projects,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
voice_to_notes_lib::run()
|
||||
}
|
||||
19
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Shared application state managed by Tauri.
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Option<Connection>>,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
db: Mutex::new(None),
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voice to Notes",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.voicetonotes.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Voice to Notes",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Voice to Notes</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
18
src/lib/components/AIChatPanel.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="ai-chat-panel">
|
||||
<h3>AI Chat</h3>
|
||||
<p class="placeholder">Ask questions about the transcript, generate summaries</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ai-chat-panel {
|
||||
padding: 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h3 { margin: 0 0 0.5rem; }
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
18
src/lib/components/ExportPanel.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="export-panel">
|
||||
<h3>Export</h3>
|
||||
<p class="placeholder">SRT, WebVTT, ASS, plain text, Markdown export options</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.export-panel {
|
||||
padding: 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h3 { margin: 0 0 0.5rem; }
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
58
src/lib/components/ProgressOverlay.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
visible?: boolean;
|
||||
percent?: number;
|
||||
stage?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
let { visible = false, percent = 0, stage = '', message = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="overlay">
|
||||
<div class="progress-card">
|
||||
<h3>{stage}</h3>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" style="width: {percent}%"></div>
|
||||
</div>
|
||||
<p>{percent}% — {message}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.progress-card {
|
||||
background: #16213e;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
min-width: 400px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h3 { margin: 0 0 1rem; text-transform: capitalize; }
|
||||
.bar-track {
|
||||
height: 8px;
|
||||
background: #0f3460;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: #e94560;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
16
src/lib/components/ProjectList.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="project-list">
|
||||
<h3>Projects</h3>
|
||||
<p class="placeholder">Create, open, and manage projects</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.project-list {
|
||||
padding: 1rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h3 { margin: 0 0 0.5rem; }
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
18
src/lib/components/SettingsPanel.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="settings-panel">
|
||||
<h3>Settings</h3>
|
||||
<p class="placeholder">Model selection, AI provider config, preferences</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-panel {
|
||||
padding: 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h3 { margin: 0 0 0.5rem; }
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
18
src/lib/components/SpeakerManager.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="speaker-manager">
|
||||
<h3>Speakers</h3>
|
||||
<p class="placeholder">Speaker list with rename/color controls</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.speaker-manager {
|
||||
padding: 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h3 { margin: 0 0 0.5rem; }
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
18
src/lib/components/TranscriptEditor.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="transcript-editor">
|
||||
<p>Transcript Editor</p>
|
||||
<p class="placeholder">TipTap rich text editor will be integrated here</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.transcript-editor {
|
||||
padding: 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
flex: 1;
|
||||
}
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
17
src/lib/components/WaveformPlayer.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="waveform-player">
|
||||
<p>Waveform Player</p>
|
||||
<p class="placeholder">wavesurfer.js will be integrated here</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.waveform-player {
|
||||
padding: 1rem;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.placeholder {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
14
src/lib/services/tauri-bridge.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { Project } from '$lib/types/project';
|
||||
|
||||
export async function createProject(name: string): Promise<Project> {
|
||||
return invoke('create_project', { name });
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project | null> {
|
||||
return invoke('get_project', { id });
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
return invoke('list_projects');
|
||||
}
|
||||
17
src/lib/stores/ai.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface AIConfig {
|
||||
default_provider: string;
|
||||
providers: Record<string, { model: string; [key: string]: unknown }>;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const aiConfig = writable<AIConfig>({
|
||||
default_provider: 'local',
|
||||
providers: {},
|
||||
});
|
||||
export const chatHistory = writable<ChatMessage[]>([]);
|
||||
5
src/lib/stores/playback.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isPlaying = writable(false);
|
||||
export const currentTimeMs = writable(0);
|
||||
export const durationMs = writable(0);
|
||||
5
src/lib/stores/project.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Project } from '$lib/types/project';
|
||||
|
||||
export const currentProject = writable<Project | null>(null);
|
||||
export const projects = writable<Project[]>([]);
|
||||
5
src/lib/stores/transcript.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Segment, Speaker } from '$lib/types/transcript';
|
||||
|
||||
export const segments = writable<Segment[]>([]);
|
||||
export const speakers = writable<Speaker[]>([]);
|
||||
16
src/lib/types/ipc.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface IPCMessage {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProgressPayload {
|
||||
percent: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErrorPayload {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
21
src/lib/types/project.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
settings: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MediaFile {
|
||||
id: string;
|
||||
project_id: string;
|
||||
file_path: string;
|
||||
file_hash: string | null;
|
||||
duration_ms: number | null;
|
||||
sample_rate: number | null;
|
||||
channels: number | null;
|
||||
format: string | null;
|
||||
file_size: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
33
src/lib/types/transcript.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Word {
|
||||
id: string;
|
||||
segment_id: string;
|
||||
word: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
confidence: number | null;
|
||||
word_index: number;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
id: string;
|
||||
project_id: string;
|
||||
media_file_id: string;
|
||||
speaker_id: string | null;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
text: string;
|
||||
original_text: string | null;
|
||||
confidence: number | null;
|
||||
is_edited: boolean;
|
||||
edited_at: string | null;
|
||||
segment_index: number;
|
||||
words: Word[];
|
||||
}
|
||||
|
||||
export interface Speaker {
|
||||
id: string;
|
||||
project_id: string;
|
||||
label: string;
|
||||
display_name: string | null;
|
||||
color: string | null;
|
||||
}
|
||||
5
src/routes/+layout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
38
src/routes/+page.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import WaveformPlayer from '$lib/components/WaveformPlayer.svelte';
|
||||
import TranscriptEditor from '$lib/components/TranscriptEditor.svelte';
|
||||
import SpeakerManager from '$lib/components/SpeakerManager.svelte';
|
||||
import AIChatPanel from '$lib/components/AIChatPanel.svelte';
|
||||
</script>
|
||||
|
||||
<div class="workspace">
|
||||
<div class="main-content">
|
||||
<WaveformPlayer />
|
||||
<TranscriptEditor />
|
||||
</div>
|
||||
<div class="sidebar-right">
|
||||
<SpeakerManager />
|
||||
<AIChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workspace {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: calc(100vh - 3rem);
|
||||
}
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.sidebar-right {
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
BIN
static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
1
static/svelte.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
6
static/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
static/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
32
vite.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [sveltekit()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||