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>
This commit is contained in:
2026-02-26 15:16:06 -08:00
parent c450ef3c0c
commit 503cc6c0cf
95 changed files with 9607 additions and 0 deletions

9
.gitignore vendored
View File

@@ -10,6 +10,15 @@ dist/
build/ build/
out/ out/
*.egg-info/ *.egg-info/
.svelte-kit/
target/
# Rust
src-tauri/target/
# Python cache
.pytest_cache/
.ruff_cache/
# IDE # IDE
.vscode/ .vscode/

149
docs/TESTING.md Normal file
View 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

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View 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
View 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
View File

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

View 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

View 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

View File

@@ -0,0 +1,3 @@
"""Voice to Notes — Python sidecar for transcription, diarization, and AI services."""
__version__ = "0.1.0"

View File

@@ -0,0 +1 @@
"""Hardware detection and model selection."""

View 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

View 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

View File

@@ -0,0 +1 @@
"""IPC protocol layer for JSON-line communication with the Rust backend."""

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

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

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

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

View File

@@ -0,0 +1 @@
"""AI provider adapters — local (llama-server), LiteLLM, OpenAI, Anthropic."""

View File

@@ -0,0 +1,5 @@
"""Anthropic provider — direct Anthropic SDK integration."""
from __future__ import annotations
# TODO: Implement Anthropic provider

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

View File

@@ -0,0 +1,5 @@
"""LiteLLM provider — multi-provider gateway."""
from __future__ import annotations
# TODO: Implement LiteLLM provider

View 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

View File

@@ -0,0 +1,5 @@
"""OpenAI provider — direct OpenAI SDK integration."""
from __future__ import annotations
# TODO: Implement OpenAI provider

View File

@@ -0,0 +1 @@
"""Service layer — transcription, diarization, AI, and export."""

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1 @@
"""Utility modules."""

46
scripts/validate.sh Executable file
View 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
View 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

File diff suppressed because it is too large Load Diff

24
src-tauri/Cargo.toml Normal file
View 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
View File

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,2 @@
// AI provider commands — chat, summarize via Python sidecar
// TODO: Implement when AI provider service is built

View File

@@ -0,0 +1,2 @@
// Export commands — trigger caption/text export via Python sidecar
// TODO: Implement when export service is built

View File

@@ -0,0 +1,6 @@
pub mod ai;
pub mod export;
pub mod project;
pub mod settings;
pub mod system;
pub mod transcribe;

View 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![])
}

View File

@@ -0,0 +1,2 @@
// Settings commands — app preferences, model selection, AI provider config
// TODO: Implement when settings UI is built

View File

@@ -0,0 +1,2 @@
// System commands — hardware detection, llama-server lifecycle
// TODO: Implement hardware detection and llama-server management

View File

@@ -0,0 +1,2 @@
// Transcription commands — start/stop/monitor transcription via Python sidecar
// TODO: Implement when sidecar IPC is connected

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

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

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

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

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

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

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

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

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

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

View 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
View 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[]>([]);

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

View 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[]>([]);

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

1
static/svelte.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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/**"],
},
},
}));