first round
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.venv
|
||||
.rehearsal-data
|
||||
src-tauri/target
|
||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Rehearsal Studio
|
||||
|
||||
Desktop-first script rehearsal application for synchronizing screenplay text with one or more audio files, then practicing a selected role against that synchronized timeline.
|
||||
|
||||
## Current implementation
|
||||
|
||||
- `src/`: React + TypeScript UI with import, reader, sync editor, provider routing, and practice views
|
||||
- `src-tauri/`: Rust host for project management, media preparation, JSON artifacts, and SQLite-backed practice history
|
||||
- `providers/python/`: provider runtime for script parsing, draft transcription, fuzzy alignment, and transcript-based answer evaluation
|
||||
|
||||
The provider layer is modular by design:
|
||||
- `local-whisper-draft` is the default local provider
|
||||
- `remote-placeholder` is reserved for future API adapters
|
||||
|
||||
## Local development
|
||||
|
||||
1. Install JavaScript dependencies:
|
||||
`npm install`
|
||||
2. Install Python provider dependencies if you want local document parsing and optional ASR:
|
||||
`python -m venv .venv && .venv/bin/pip install -r providers/python/requirements.txt`
|
||||
3. Run the browser preview:
|
||||
`npm run dev`
|
||||
4. Run the desktop shell:
|
||||
`npm run tauri dev`
|
||||
|
||||
## Notes
|
||||
|
||||
- If `faster-whisper` is not installed or no local model is available, the provider runtime falls back to generating a draft transcript from script lines and total audio duration.
|
||||
- The app automatically prefers `./.venv/bin/python` or `./.venv/Scripts/python.exe` for the provider runtime. `REHEARSAL_PYTHON` still overrides that if you want a different interpreter.
|
||||
- Media preparation requires `ffmpeg` and `ffprobe` on the system path.
|
||||
- The UI accepts absolute file paths for the first implementation; native file-picker integration can be added on top of the existing commands.
|
||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>Rehearsal Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2106
package-lock.json
generated
Normal file
2106
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "rehearsal-studio",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
1
providers/python/app/__init__.py
Normal file
1
providers/python/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Provider runtime package for Rehearsal Studio."""
|
||||
BIN
providers/python/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
providers/python/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
providers/python/app/__pycache__/aligner.cpython-314.pyc
Normal file
BIN
providers/python/app/__pycache__/aligner.cpython-314.pyc
Normal file
Binary file not shown.
BIN
providers/python/app/__pycache__/evaluator.cpython-314.pyc
Normal file
BIN
providers/python/app/__pycache__/evaluator.cpython-314.pyc
Normal file
Binary file not shown.
BIN
providers/python/app/__pycache__/script_parser.cpython-314.pyc
Normal file
BIN
providers/python/app/__pycache__/script_parser.cpython-314.pyc
Normal file
Binary file not shown.
BIN
providers/python/app/__pycache__/text_utils.cpython-314.pyc
Normal file
BIN
providers/python/app/__pycache__/text_utils.cpython-314.pyc
Normal file
Binary file not shown.
BIN
providers/python/app/__pycache__/transcriber.cpython-314.pyc
Normal file
BIN
providers/python/app/__pycache__/transcriber.cpython-314.pyc
Normal file
Binary file not shown.
83
providers/python/app/aligner.py
Normal file
83
providers/python/app/aligner.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from rapidfuzz import fuzz # type: ignore
|
||||
except ModuleNotFoundError: # pragma: no cover - optional dependency
|
||||
fuzz = None
|
||||
|
||||
from .text_utils import normalize_text
|
||||
|
||||
|
||||
def _score(left: str, right: str) -> float:
|
||||
if not left or not right:
|
||||
return 0.0
|
||||
if fuzz is not None:
|
||||
return float(fuzz.token_set_ratio(left, right)) / 100.0
|
||||
return SequenceMatcher(None, left, right).ratio()
|
||||
|
||||
|
||||
def align_script(payload: dict[str, Any]) -> dict:
|
||||
lines = payload.get("lines", [])
|
||||
transcript_segments = payload.get("transcriptSegments", [])
|
||||
warnings: list[str] = []
|
||||
unresolved: list[str] = []
|
||||
cues: list[dict[str, Any]] = []
|
||||
cursor = 0
|
||||
|
||||
for line in lines:
|
||||
normalized_line = normalize_text(line["displayText"])
|
||||
best_index = None
|
||||
best_score = 0.0
|
||||
|
||||
for index in range(cursor, min(len(transcript_segments), cursor + 6)):
|
||||
segment = transcript_segments[index]
|
||||
score = _score(normalized_line, normalize_text(segment["text"]))
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_index = index
|
||||
|
||||
if best_index is None or best_score < 0.18:
|
||||
unresolved.append(line["id"])
|
||||
cues.append(
|
||||
{
|
||||
"lineId": line["id"],
|
||||
"startMs": cues[-1]["endMs"] if cues else 0,
|
||||
"endMs": cues[-1]["endMs"] if cues else 0,
|
||||
"status": "unmatched",
|
||||
"confidence": round(best_score, 3),
|
||||
"transcriptSpanIds": [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
segment = transcript_segments[best_index]
|
||||
cursor = best_index + 1
|
||||
cues.append(
|
||||
{
|
||||
"lineId": line["id"],
|
||||
"startMs": int(segment["startMs"]),
|
||||
"endMs": int(segment["endMs"]),
|
||||
"status": "auto",
|
||||
"confidence": round(best_score, 3),
|
||||
"transcriptSpanIds": [segment["id"]],
|
||||
}
|
||||
)
|
||||
|
||||
if unresolved:
|
||||
warnings.append(f"{len(unresolved)} lines remained unresolved and should be corrected in the sync editor.")
|
||||
|
||||
return {
|
||||
"providerRun": {
|
||||
"id": "provider-run-align",
|
||||
"capability": "align_script",
|
||||
"providerId": "local-whisper-draft",
|
||||
"providerVersion": "draft-1",
|
||||
"createdAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
"unresolvedLineIds": unresolved,
|
||||
"cues": cues,
|
||||
"warnings": warnings,
|
||||
}
|
||||
44
providers/python/app/evaluator.py
Normal file
44
providers/python/app/evaluator.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any
|
||||
|
||||
from .text_utils import tokenize
|
||||
|
||||
|
||||
def evaluate_attempt(payload: dict[str, Any]) -> dict:
|
||||
expected_text = payload["expectedText"]
|
||||
recognized_text = (payload.get("recognizedTextOverride") or "").strip()
|
||||
warnings: list[str] = []
|
||||
|
||||
if not recognized_text:
|
||||
warnings.append("No ASR transcript was provided. Use recognizedTextOverride or add a local transcription model.")
|
||||
recognized_text = ""
|
||||
|
||||
expected_tokens = tokenize(expected_text)
|
||||
received_tokens = tokenize(recognized_text)
|
||||
expected_set = set(expected_tokens)
|
||||
received_set = set(received_tokens)
|
||||
missing_words = [token for token in expected_tokens if token not in received_set]
|
||||
extra_words = [token for token in received_tokens if token not in expected_set]
|
||||
matches = sum(1 for token in expected_tokens if token in received_set)
|
||||
score = matches / len(expected_tokens) if expected_tokens else 0.0
|
||||
passed = score >= 0.88
|
||||
|
||||
return {
|
||||
"id": f"evaluation-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}",
|
||||
"providerRun": {
|
||||
"id": "provider-run-evaluate",
|
||||
"capability": "evaluate_attempt",
|
||||
"providerId": "local-whisper-draft",
|
||||
"providerVersion": "draft-1",
|
||||
"createdAt": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"expectedText": expected_text,
|
||||
"recognizedText": recognized_text,
|
||||
"missingWords": missing_words,
|
||||
"extraWords": extra_words,
|
||||
"score": round(score, 3),
|
||||
"passed": passed,
|
||||
"warnings": warnings,
|
||||
}
|
||||
123
providers/python/app/script_parser.py
Normal file
123
providers/python/app/script_parser.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .text_utils import normalize_text
|
||||
|
||||
|
||||
def _read_script_text(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".txt":
|
||||
return path.read_text(encoding="utf-8")
|
||||
if suffix == ".docx":
|
||||
try:
|
||||
from docx import Document # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError("DOCX parsing requires python-docx.") from exc
|
||||
document = Document(path)
|
||||
return "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
if suffix == ".pdf":
|
||||
try:
|
||||
import fitz # type: ignore
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError("PDF parsing requires PyMuPDF.") from exc
|
||||
with fitz.open(path) as document:
|
||||
return "\n".join(page.get_text("text") for page in document)
|
||||
raise RuntimeError(f"Unsupported script format: {path.suffix}")
|
||||
|
||||
|
||||
def _is_scene_heading(line: str) -> bool:
|
||||
if not line:
|
||||
return False
|
||||
upper = line.upper()
|
||||
return (
|
||||
line == upper
|
||||
and len(line) <= 80
|
||||
and any(line.startswith(prefix) for prefix in ("INT", "EXT", "SCENE", "JELENET"))
|
||||
)
|
||||
|
||||
|
||||
def _is_role_heading(line: str) -> bool:
|
||||
if not line:
|
||||
return False
|
||||
if len(line) > 40:
|
||||
return False
|
||||
if line.endswith((".", "!", "?", ":")):
|
||||
return False
|
||||
return line == line.upper()
|
||||
|
||||
|
||||
def parse_script(script_path: str) -> dict:
|
||||
path = Path(script_path)
|
||||
text = _read_script_text(path)
|
||||
raw_lines = [line.rstrip() for line in text.splitlines()]
|
||||
|
||||
current_scene = {"id": "scene-1", "heading": "Imported Scene", "ordinal": 1}
|
||||
scenes = [current_scene]
|
||||
roles_by_name: dict[str, dict] = {}
|
||||
script_lines = []
|
||||
warnings: list[str] = []
|
||||
current_role_name: str | None = None
|
||||
ordinal = 1
|
||||
|
||||
for raw_line in raw_lines:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if _is_scene_heading(line):
|
||||
current_scene = {
|
||||
"id": f"scene-{len(scenes) + 1}",
|
||||
"heading": line,
|
||||
"ordinal": len(scenes) + 1,
|
||||
}
|
||||
scenes.append(current_scene)
|
||||
current_role_name = None
|
||||
continue
|
||||
|
||||
if _is_role_heading(line):
|
||||
current_role_name = line.title()
|
||||
if current_role_name not in roles_by_name:
|
||||
roles_by_name[current_role_name] = {
|
||||
"id": f"role-{len(roles_by_name) + 1}",
|
||||
"name": current_role_name,
|
||||
"lineCount": 0,
|
||||
}
|
||||
continue
|
||||
|
||||
if current_role_name is None:
|
||||
current_role_name = "Narrator"
|
||||
if current_role_name not in roles_by_name:
|
||||
warnings.append("Some lines had no explicit speaker and were assigned to Narrator.")
|
||||
roles_by_name[current_role_name] = {
|
||||
"id": "role-1",
|
||||
"name": current_role_name,
|
||||
"lineCount": 0,
|
||||
}
|
||||
|
||||
role = roles_by_name[current_role_name]
|
||||
role["lineCount"] += 1
|
||||
script_lines.append(
|
||||
{
|
||||
"id": f"line-{ordinal}",
|
||||
"sceneId": current_scene["id"],
|
||||
"roleId": role["id"],
|
||||
"roleName": role["name"],
|
||||
"displayText": line,
|
||||
"normalizedText": normalize_text(line),
|
||||
"ordinal": ordinal,
|
||||
}
|
||||
)
|
||||
ordinal += 1
|
||||
|
||||
if not script_lines:
|
||||
raise RuntimeError("No dialogue lines could be extracted from the script.")
|
||||
|
||||
return {
|
||||
"sourcePath": script_path,
|
||||
"parserWarnings": warnings,
|
||||
"roles": list(roles_by_name.values()),
|
||||
"scenes": scenes,
|
||||
"lines": script_lines,
|
||||
"warnings": warnings,
|
||||
}
|
||||
16
providers/python/app/text_utils.py
Normal file
16
providers/python/app/text_utils.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
lowered = text.lower().strip()
|
||||
lowered = unicodedata.normalize("NFKC", lowered)
|
||||
lowered = re.sub(r"[^\w\sáéíóöőúüűÁÉÍÓÖŐÚÜŰ-]", " ", lowered, flags=re.UNICODE)
|
||||
lowered = re.sub(r"\s+", " ", lowered)
|
||||
return lowered.strip()
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
return [token for token in normalize_text(text).split(" ") if token]
|
||||
108
providers/python/app/transcriber.py
Normal file
108
providers/python/app/transcriber.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptDraft:
|
||||
segments: list[dict[str, Any]]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def _run_faster_whisper(audio_path: str, language: str, model_name: str) -> TranscriptDraft | None:
|
||||
try:
|
||||
from faster_whisper import WhisperModel # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
return None
|
||||
|
||||
try:
|
||||
model = WhisperModel(model_name, device="auto", compute_type="int8")
|
||||
segments, _ = model.transcribe(audio_path, language=language.split("-")[0], vad_filter=True)
|
||||
payload = []
|
||||
for index, segment in enumerate(segments, start=1):
|
||||
payload.append(
|
||||
{
|
||||
"id": f"segment-{index}",
|
||||
"startMs": int(segment.start * 1000),
|
||||
"endMs": int(segment.end * 1000),
|
||||
"text": segment.text.strip(),
|
||||
"confidence": 0.82,
|
||||
}
|
||||
)
|
||||
return TranscriptDraft(
|
||||
segments=payload,
|
||||
warnings=["Transcription used faster-whisper with local inference."],
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - runtime fallback
|
||||
return TranscriptDraft(
|
||||
segments=[],
|
||||
warnings=[f"faster-whisper failed and draft mode was used instead: {exc}"],
|
||||
)
|
||||
|
||||
|
||||
def _build_draft_segments(script_lines: list[dict[str, Any]], total_duration_ms: int) -> TranscriptDraft:
|
||||
if not script_lines:
|
||||
return TranscriptDraft(segments=[], warnings=["No script lines were available for draft transcript generation."])
|
||||
|
||||
span = max(2200, total_duration_ms // max(len(script_lines), 1))
|
||||
segments = []
|
||||
for index, line in enumerate(script_lines, start=1):
|
||||
start_ms = (index - 1) * span
|
||||
segments.append(
|
||||
{
|
||||
"id": f"segment-{index}",
|
||||
"startMs": start_ms,
|
||||
"endMs": min(total_duration_ms, start_ms + int(span * 0.88)),
|
||||
"text": line["displayText"],
|
||||
"confidence": 0.44,
|
||||
}
|
||||
)
|
||||
return TranscriptDraft(
|
||||
segments=segments,
|
||||
warnings=["No local ASR model is configured. The transcript was drafted from script lines and timeline duration."],
|
||||
)
|
||||
|
||||
|
||||
def transcribe_audio(payload: dict[str, Any]) -> dict:
|
||||
audio_path = payload["mergedAudioPath"]
|
||||
language = payload.get("language", "en")
|
||||
total_duration_ms = int(payload.get("totalDurationMs", 0))
|
||||
script_lines = payload.get("scriptLines", [])
|
||||
model_name = payload.get("options", {}).get("model", "small")
|
||||
|
||||
if not Path(audio_path).exists():
|
||||
raise RuntimeError(f"Merged audio not found: {audio_path}")
|
||||
|
||||
draft = _run_faster_whisper(audio_path, language, model_name)
|
||||
if draft and draft.segments:
|
||||
return {
|
||||
"id": "transcript-latest",
|
||||
"providerRun": {
|
||||
"id": "provider-run-transcribe",
|
||||
"capability": "transcribe_audio",
|
||||
"providerId": "local-whisper-draft",
|
||||
"providerVersion": "draft-1",
|
||||
"createdAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
"segments": draft.segments,
|
||||
"warnings": draft.warnings,
|
||||
}
|
||||
|
||||
fallback = _build_draft_segments(script_lines, total_duration_ms)
|
||||
warnings = list(fallback.warnings)
|
||||
if draft and draft.warnings:
|
||||
warnings = draft.warnings + warnings
|
||||
return {
|
||||
"id": "transcript-latest",
|
||||
"providerRun": {
|
||||
"id": "provider-run-transcribe",
|
||||
"capability": "transcribe_audio",
|
||||
"providerId": "local-whisper-draft",
|
||||
"providerVersion": "draft-1",
|
||||
"createdAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
|
||||
},
|
||||
"segments": fallback.segments,
|
||||
"warnings": warnings,
|
||||
}
|
||||
4
providers/python/requirements.txt
Normal file
4
providers/python/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
faster-whisper>=1.1.0
|
||||
RapidFuzz>=3.10.1
|
||||
python-docx>=1.1.2
|
||||
PyMuPDF>=1.24.13
|
||||
57
providers/python/runtime.py
Normal file
57
providers/python/runtime.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from app.aligner import align_script
|
||||
from app.evaluator import evaluate_attempt
|
||||
from app.script_parser import parse_script
|
||||
from app.transcriber import transcribe_audio
|
||||
|
||||
|
||||
def main() -> int:
|
||||
request = json.load(sys.stdin)
|
||||
capability = request.get("capability")
|
||||
provider_id = request.get("providerId", request.get("provider_id", "local-whisper-draft"))
|
||||
|
||||
if capability == "parse_script":
|
||||
payload = parse_script(request["payload"]["scriptPath"])
|
||||
response = {
|
||||
"providerId": provider_id,
|
||||
"providerVersion": "draft-1",
|
||||
"warnings": payload.pop("warnings", []),
|
||||
"payload": payload,
|
||||
}
|
||||
elif capability == "transcribe_audio":
|
||||
payload = transcribe_audio(request["payload"])
|
||||
response = {
|
||||
"providerId": provider_id,
|
||||
"providerVersion": "draft-1",
|
||||
"warnings": payload.pop("warnings", []),
|
||||
"payload": payload,
|
||||
}
|
||||
elif capability == "align_script":
|
||||
payload = align_script(request["payload"])
|
||||
response = {
|
||||
"providerId": provider_id,
|
||||
"providerVersion": "draft-1",
|
||||
"warnings": payload.pop("warnings", []),
|
||||
"payload": payload,
|
||||
}
|
||||
elif capability == "evaluate_attempt":
|
||||
payload = evaluate_attempt(request["payload"])
|
||||
response = {
|
||||
"providerId": provider_id,
|
||||
"providerVersion": "draft-1",
|
||||
"warnings": payload.pop("warnings", []),
|
||||
"payload": payload,
|
||||
}
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported capability: {capability}")
|
||||
|
||||
json.dump(response, sys.stdout, ensure_ascii=False)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
15
sample-data/demo-script.txt
Normal file
15
sample-data/demo-script.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
SCENE 1
|
||||
|
||||
ANNA
|
||||
You always arrive before the others.
|
||||
|
||||
JANI
|
||||
Only when the line I fear is waiting for me.
|
||||
|
||||
ANNA
|
||||
Then let us repeat it until fear turns into rhythm.
|
||||
|
||||
SCENE 2
|
||||
|
||||
NARRATOR
|
||||
He listens, breathes, and steps into the text.
|
||||
4718
src-tauri/Cargo.lock
generated
Normal file
4718
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
src-tauri/Cargo.toml
Normal file
19
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "rehearsal-studio-shell"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.39", features = ["clock", "serde"] }
|
||||
hound = "3.5.1"
|
||||
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
||||
serde = { version = "1.0.215", features = ["derive"] }
|
||||
serde_json = "1.0.133"
|
||||
tauri = { version = "2.1.1", features = [] }
|
||||
tempfile = "3.14.0"
|
||||
thiserror = "2.0.6"
|
||||
uuid = { version = "1.11.0", features = ["serde", "v4"] }
|
||||
5
src-tauri/build.rs
Normal file
5
src-tauri/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||
println!("cargo:rerun-if-changed=../providers/python");
|
||||
tauri_build::build()
|
||||
}
|
||||
7
src-tauri/capabilities/default.json
Normal file
7
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the main window.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capability for the main window.","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||
2292
src-tauri/gen/schemas/desktop-schema.json
Normal file
2292
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2292
src-tauri/gen/schemas/linux-schema.json
Normal file
2292
src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 B |
280
src-tauri/src/app_state.rs
Normal file
280
src-tauri/src/app_state.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
use std::{
|
||||
fs,
|
||||
fs::File,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::AppDatabase,
|
||||
domain::{
|
||||
AlignmentDocument, DashboardState, PracticeSession, ProjectManifest, ProjectSnapshot,
|
||||
RoutingPolicy, ScriptDocument, TranscriptArtifact, WaveformDocument,
|
||||
},
|
||||
errors::{AppError, AppResult},
|
||||
providers::{default_profiles, LocalProviderRuntime},
|
||||
};
|
||||
|
||||
pub struct AppState {
|
||||
workspace_root: PathBuf,
|
||||
data_root: PathBuf,
|
||||
db: Mutex<AppDatabase>,
|
||||
providers: Vec<crate::domain::ProviderProfile>,
|
||||
provider_runtime: LocalProviderRuntime,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(workspace_root: PathBuf) -> AppResult<Self> {
|
||||
let data_root = workspace_root.join(".rehearsal-data");
|
||||
fs::create_dir_all(data_root.join("projects"))?;
|
||||
let db = AppDatabase::open(&data_root.join("rehearsal.db"))?;
|
||||
Ok(Self {
|
||||
workspace_root,
|
||||
data_root,
|
||||
db: Mutex::new(db),
|
||||
providers: default_profiles(),
|
||||
provider_runtime: LocalProviderRuntime::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provider_runtime(&self) -> &LocalProviderRuntime {
|
||||
&self.provider_runtime
|
||||
}
|
||||
|
||||
pub fn providers(&self) -> Vec<crate::domain::ProviderProfile> {
|
||||
self.providers.clone()
|
||||
}
|
||||
|
||||
pub fn workspace_root_string(&self) -> String {
|
||||
self.workspace_root.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
pub fn next_project_id(&self) -> String {
|
||||
format!("project-{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
pub fn now_iso(&self) -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
pub fn project_dir(&self, project_id: &str) -> PathBuf {
|
||||
self.data_root.join("projects").join(project_id)
|
||||
}
|
||||
|
||||
pub fn project_artifacts_dir(&self, project_id: &str) -> PathBuf {
|
||||
self.project_dir(project_id).join("artifacts")
|
||||
}
|
||||
|
||||
pub fn save_manifest(&self, manifest: &ProjectManifest) -> AppResult<()> {
|
||||
let project_dir = self.project_dir(&manifest.id);
|
||||
fs::create_dir_all(project_dir.join("artifacts"))?;
|
||||
self.write_json(project_dir.join("project.json"), manifest)?;
|
||||
self.db()
|
||||
.lock()
|
||||
.map_err(|_| AppError::from("database mutex poisoned"))?
|
||||
.upsert_project(manifest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_manifest(&self, project_id: &str) -> AppResult<ProjectManifest> {
|
||||
self.read_json(self.project_dir(project_id).join("project.json"))
|
||||
}
|
||||
|
||||
pub fn save_script(&self, project_id: &str, script: &ScriptDocument) -> AppResult<()> {
|
||||
self.write_json(self.project_dir(project_id).join("script.json"), script)
|
||||
}
|
||||
|
||||
pub fn load_script(&self, project_id: &str) -> AppResult<Option<ScriptDocument>> {
|
||||
self.read_json_optional(self.project_dir(project_id).join("script.json"))
|
||||
}
|
||||
|
||||
pub fn save_alignment(&self, project_id: &str, alignment: &AlignmentDocument) -> AppResult<()> {
|
||||
self.write_json(
|
||||
self.project_dir(project_id).join("alignment.json"),
|
||||
alignment,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load_alignment(&self, project_id: &str) -> AppResult<Option<AlignmentDocument>> {
|
||||
self.read_json_optional(self.project_dir(project_id).join("alignment.json"))
|
||||
}
|
||||
|
||||
pub fn save_transcript(
|
||||
&self,
|
||||
project_id: &str,
|
||||
transcript: &TranscriptArtifact,
|
||||
) -> AppResult<()> {
|
||||
self.write_json(
|
||||
self.project_artifacts_dir(project_id)
|
||||
.join("transcript-latest.json"),
|
||||
transcript,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load_transcript(&self, project_id: &str) -> AppResult<Option<TranscriptArtifact>> {
|
||||
self.read_json_optional(
|
||||
self.project_artifacts_dir(project_id)
|
||||
.join("transcript-latest.json"),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn save_waveform(&self, project_id: &str, waveform: &WaveformDocument) -> AppResult<()> {
|
||||
self.write_json(
|
||||
self.project_artifacts_dir(project_id).join("waveform.json"),
|
||||
waveform,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load_waveform(&self, project_id: &str) -> AppResult<Option<WaveformDocument>> {
|
||||
self.read_json_optional(self.project_artifacts_dir(project_id).join("waveform.json"))
|
||||
}
|
||||
|
||||
pub fn write_evaluation(
|
||||
&self,
|
||||
project_id: &str,
|
||||
evaluation: &crate::domain::EvaluationArtifact,
|
||||
) -> AppResult<()> {
|
||||
let path = self
|
||||
.project_artifacts_dir(project_id)
|
||||
.join(format!("evaluation-{}.json", evaluation.id));
|
||||
self.write_json(path, evaluation)
|
||||
}
|
||||
|
||||
pub fn load_sessions(&self, project_id: &str) -> AppResult<Vec<PracticeSession>> {
|
||||
self.db()
|
||||
.lock()
|
||||
.map_err(|_| AppError::from("database mutex poisoned"))?
|
||||
.load_sessions(project_id)
|
||||
}
|
||||
|
||||
pub fn insert_session(&self, project_id: &str, session: &PracticeSession) -> AppResult<()> {
|
||||
self.db()
|
||||
.lock()
|
||||
.map_err(|_| AppError::from("database mutex poisoned"))?
|
||||
.insert_session(project_id, session)
|
||||
}
|
||||
|
||||
pub fn insert_attempt(
|
||||
&self,
|
||||
session_id: &str,
|
||||
attempt: &crate::domain::PracticeAttempt,
|
||||
) -> AppResult<()> {
|
||||
self.db()
|
||||
.lock()
|
||||
.map_err(|_| AppError::from("database mutex poisoned"))?
|
||||
.insert_attempt(session_id, attempt)
|
||||
}
|
||||
|
||||
pub fn build_snapshot(&self, project_id: &str) -> AppResult<ProjectSnapshot> {
|
||||
let project = self.load_manifest(project_id)?;
|
||||
Ok(ProjectSnapshot {
|
||||
project,
|
||||
script: self.load_script(project_id)?,
|
||||
alignment: self.load_alignment(project_id)?,
|
||||
transcript: self.load_transcript(project_id)?,
|
||||
waveform: self.load_waveform(project_id)?,
|
||||
practice_sessions: self.load_sessions(project_id)?,
|
||||
providers: self.providers(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dashboard_state(&self) -> AppResult<DashboardState> {
|
||||
let summaries = self
|
||||
.db()
|
||||
.lock()
|
||||
.map_err(|_| AppError::from("database mutex poisoned"))?
|
||||
.list_projects()?;
|
||||
let scripts = summaries
|
||||
.iter()
|
||||
.map(|summary| self.load_script(&summary.id))
|
||||
.collect::<AppResult<Vec<_>>>()?;
|
||||
let projects = self
|
||||
.db()
|
||||
.lock()
|
||||
.map_err(|_| AppError::from("database mutex poisoned"))?
|
||||
.with_script_stats(summaries, &scripts);
|
||||
Ok(DashboardState {
|
||||
workspace_root: self.workspace_root_string(),
|
||||
projects,
|
||||
providers: self.providers(),
|
||||
})
|
||||
}
|
||||
|
||||
fn db(&self) -> &Mutex<AppDatabase> {
|
||||
&self.db
|
||||
}
|
||||
|
||||
fn write_json<T: serde::Serialize, P: AsRef<Path>>(&self, path: P, value: &T) -> AppResult<()> {
|
||||
if let Some(parent) = path.as_ref().parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = File::create(path)?;
|
||||
serde_json::to_writer_pretty(file, value)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned, P: AsRef<Path>>(&self, path: P) -> AppResult<T> {
|
||||
let file = File::open(path)?;
|
||||
Ok(serde_json::from_reader(file)?)
|
||||
}
|
||||
|
||||
fn read_json_optional<T: serde::de::DeserializeOwned, P: AsRef<Path>>(
|
||||
&self,
|
||||
path: P,
|
||||
) -> AppResult<Option<T>> {
|
||||
if path.as_ref().exists() {
|
||||
Ok(Some(self.read_json(path)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_default_alignment(&self, script: &ScriptDocument) -> AlignmentDocument {
|
||||
AlignmentDocument {
|
||||
provider_run: None,
|
||||
unresolved_line_ids: script.lines.iter().map(|line| line.id.clone()).collect(),
|
||||
cues: script
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| crate::domain::AlignmentCue {
|
||||
line_id: line.id.clone(),
|
||||
start_ms: 0,
|
||||
end_ms: 0,
|
||||
status: "unmatched".to_string(),
|
||||
confidence: 0.0,
|
||||
transcript_span_ids: Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_summary_status(
|
||||
&self,
|
||||
project_id: &str,
|
||||
status: &str,
|
||||
merged_audio_path: Option<String>,
|
||||
waveform_path: Option<String>,
|
||||
total_duration_ms: Option<u64>,
|
||||
routing_policy: Option<RoutingPolicy>,
|
||||
) -> AppResult<ProjectManifest> {
|
||||
let mut manifest = self.load_manifest(project_id)?;
|
||||
manifest.status = status.to_string();
|
||||
if let Some(path) = merged_audio_path {
|
||||
manifest.merged_audio_path = Some(path);
|
||||
}
|
||||
if let Some(path) = waveform_path {
|
||||
manifest.waveform_path = Some(path);
|
||||
}
|
||||
if let Some(duration_ms) = total_duration_ms {
|
||||
manifest.total_duration_ms = duration_ms;
|
||||
}
|
||||
if let Some(policy) = routing_policy {
|
||||
manifest.routing_policy = policy;
|
||||
}
|
||||
self.save_manifest(&manifest)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
}
|
||||
357
src-tauri/src/commands.rs
Normal file
357
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,357 @@
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
domain::{
|
||||
AlignPayload, AlignmentEdit, AudioAsset, EvaluatePayload, ImportProjectInput,
|
||||
PracticeAttempt, PracticeSession, ProjectManifest, ProjectSnapshot, RoutingPolicy,
|
||||
ScriptDocument, StartPracticeSessionInput, SubmitPracticeAttemptInput,
|
||||
SubmitPracticeAttemptResult, TranscribePayload,
|
||||
},
|
||||
errors::{AppError, AppResult},
|
||||
media,
|
||||
providers::protocol::{
|
||||
build_alignment_request, build_evaluation_request, build_parse_script_request,
|
||||
build_transcription_request, AlignmentResponse, EvaluationResponse, ParseScriptResponse,
|
||||
TranscriptionResponse,
|
||||
},
|
||||
};
|
||||
|
||||
fn map_error<T>(result: AppResult<T>) -> Result<T, String> {
|
||||
result.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn ensure_existing_file(path: &Path) -> AppResult<()> {
|
||||
if path.exists() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Message(format!(
|
||||
"Missing file: {}",
|
||||
path.to_string_lossy()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_dashboard_state(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::domain::DashboardState, String> {
|
||||
map_error(state.dashboard_state())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_providers(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<crate::domain::ProviderProfile>, String> {
|
||||
Ok(state.providers())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_project_state(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectSnapshot, String> {
|
||||
map_error(state.build_snapshot(&project_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_project(
|
||||
state: State<'_, AppState>,
|
||||
input: ImportProjectInput,
|
||||
) -> Result<ProjectSnapshot, String> {
|
||||
map_error(import_project_inner(state.inner(), input))
|
||||
}
|
||||
|
||||
fn import_project_inner(state: &AppState, input: ImportProjectInput) -> AppResult<ProjectSnapshot> {
|
||||
if input.audio_paths.is_empty() {
|
||||
return Err(AppError::from("At least one audio file is required."));
|
||||
}
|
||||
|
||||
let project_id = state.next_project_id();
|
||||
let project_dir = state.project_dir(&project_id);
|
||||
std::fs::create_dir_all(project_dir.join("artifacts"))?;
|
||||
|
||||
let mut total_duration_ms = 0_u64;
|
||||
let mut audio_assets = Vec::new();
|
||||
for (index, path) in input.audio_paths.iter().enumerate() {
|
||||
let source_path = Path::new(path);
|
||||
ensure_existing_file(source_path)?;
|
||||
let duration_ms = media::probe_duration_ms(source_path)?;
|
||||
audio_assets.push(AudioAsset {
|
||||
id: format!("asset-{}", index + 1),
|
||||
label: source_path
|
||||
.file_name()
|
||||
.map(|value| value.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format!("Track {}", index + 1)),
|
||||
original_path: source_path.to_string_lossy().to_string(),
|
||||
duration_ms,
|
||||
start_offset_ms: total_duration_ms,
|
||||
});
|
||||
total_duration_ms += duration_ms;
|
||||
}
|
||||
|
||||
let script_path = Path::new(&input.script_path);
|
||||
ensure_existing_file(script_path)?;
|
||||
|
||||
let manifest = ProjectManifest {
|
||||
id: project_id.clone(),
|
||||
name: input.name,
|
||||
language: input.language,
|
||||
created_at: state.now_iso(),
|
||||
status: "imported".to_string(),
|
||||
script_path: script_path.to_string_lossy().to_string(),
|
||||
merged_audio_path: None,
|
||||
waveform_path: None,
|
||||
total_duration_ms,
|
||||
audio_assets,
|
||||
routing_policy: RoutingPolicy::default(),
|
||||
};
|
||||
state.save_manifest(&manifest)?;
|
||||
|
||||
let request = build_parse_script_request(&project_id, manifest.script_path.clone())?;
|
||||
let response: ParseScriptResponse = state.provider_runtime().execute(&request)?;
|
||||
let script = ScriptDocument {
|
||||
source_path: response.payload.source_path,
|
||||
parser_warnings: response.payload.parser_warnings,
|
||||
roles: response.payload.roles,
|
||||
scenes: response.payload.scenes,
|
||||
lines: response.payload.lines,
|
||||
};
|
||||
state.save_script(&project_id, &script)?;
|
||||
state.save_alignment(&project_id, &state.build_default_alignment(&script))?;
|
||||
|
||||
state.build_snapshot(&project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn prepare_media(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectSnapshot, String> {
|
||||
map_error(prepare_media_inner(state.inner(), &project_id))
|
||||
}
|
||||
|
||||
fn prepare_media_inner(state: &AppState, project_id: &str) -> AppResult<ProjectSnapshot> {
|
||||
let manifest = state.load_manifest(project_id)?;
|
||||
let (merged_audio_path, waveform_path, waveform, total_duration_ms) =
|
||||
media::prepare_media(&state.project_dir(project_id), &manifest.audio_assets)?;
|
||||
state.update_summary_status(
|
||||
project_id,
|
||||
"media_ready",
|
||||
Some(merged_audio_path.to_string_lossy().to_string()),
|
||||
Some(waveform_path.to_string_lossy().to_string()),
|
||||
Some(total_duration_ms),
|
||||
None,
|
||||
)?;
|
||||
state.save_waveform(project_id, &waveform)?;
|
||||
state.build_snapshot(project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn run_transcription(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectSnapshot, String> {
|
||||
map_error(run_transcription_inner(state.inner(), &project_id))
|
||||
}
|
||||
|
||||
fn run_transcription_inner(state: &AppState, project_id: &str) -> AppResult<ProjectSnapshot> {
|
||||
let manifest = state.load_manifest(project_id)?;
|
||||
if manifest.merged_audio_path.is_none() {
|
||||
prepare_media_inner(state, project_id)?;
|
||||
}
|
||||
let manifest = state.load_manifest(project_id)?;
|
||||
let script = state
|
||||
.load_script(project_id)?
|
||||
.ok_or_else(|| AppError::from("Script must be imported before transcription."))?;
|
||||
let mut options = HashMap::new();
|
||||
options.insert("model".to_string(), "small".to_string());
|
||||
|
||||
let request = build_transcription_request(
|
||||
project_id,
|
||||
manifest.routing_policy.transcription_provider.clone(),
|
||||
TranscribePayload {
|
||||
merged_audio_path: manifest.merged_audio_path.clone().ok_or_else(|| {
|
||||
AppError::from("Merged audio path missing after media preparation.")
|
||||
})?,
|
||||
language: manifest.language.clone(),
|
||||
total_duration_ms: manifest.total_duration_ms,
|
||||
script_lines: script.lines.clone(),
|
||||
options,
|
||||
},
|
||||
)?;
|
||||
let response: TranscriptionResponse = state.provider_runtime().execute(&request)?;
|
||||
state.save_transcript(project_id, &response.payload)?;
|
||||
state.update_summary_status(project_id, "transcribed", None, None, None, None)?;
|
||||
state.build_snapshot(project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn run_alignment(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectSnapshot, String> {
|
||||
map_error(run_alignment_inner(state.inner(), &project_id))
|
||||
}
|
||||
|
||||
fn run_alignment_inner(state: &AppState, project_id: &str) -> AppResult<ProjectSnapshot> {
|
||||
let manifest = state.load_manifest(project_id)?;
|
||||
let script = state
|
||||
.load_script(project_id)?
|
||||
.ok_or_else(|| AppError::from("Missing script draft."))?;
|
||||
let transcript = state
|
||||
.load_transcript(project_id)?
|
||||
.ok_or_else(|| AppError::from("Run transcription before alignment."))?;
|
||||
let request = build_alignment_request(
|
||||
project_id,
|
||||
manifest.routing_policy.alignment_provider.clone(),
|
||||
AlignPayload {
|
||||
lines: script.lines.clone(),
|
||||
transcript_segments: transcript.segments,
|
||||
},
|
||||
)?;
|
||||
let response: AlignmentResponse = state.provider_runtime().execute(&request)?;
|
||||
state.save_alignment(project_id, &response.payload)?;
|
||||
state.update_summary_status(project_id, "aligned", None, None, None, None)?;
|
||||
state.build_snapshot(project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_alignment_edits(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
edits: Vec<AlignmentEdit>,
|
||||
) -> Result<ProjectSnapshot, String> {
|
||||
map_error(save_alignment_edits_inner(
|
||||
state.inner(),
|
||||
&project_id,
|
||||
edits,
|
||||
))
|
||||
}
|
||||
|
||||
fn save_alignment_edits_inner(
|
||||
state: &AppState,
|
||||
project_id: &str,
|
||||
edits: Vec<AlignmentEdit>,
|
||||
) -> AppResult<ProjectSnapshot> {
|
||||
let mut alignment = state
|
||||
.load_alignment(project_id)?
|
||||
.ok_or_else(|| AppError::from("No alignment exists yet."))?;
|
||||
for cue in alignment.cues.iter_mut() {
|
||||
if let Some(edit) = edits.iter().find(|entry| entry.line_id == cue.line_id) {
|
||||
cue.start_ms = edit.start_ms;
|
||||
cue.end_ms = edit.end_ms;
|
||||
cue.status = "manual".to_string();
|
||||
}
|
||||
}
|
||||
alignment.unresolved_line_ids = alignment
|
||||
.cues
|
||||
.iter()
|
||||
.filter(|cue| cue.status == "unmatched")
|
||||
.map(|cue| cue.line_id.clone())
|
||||
.collect();
|
||||
state.save_alignment(project_id, &alignment)?;
|
||||
state.update_summary_status(project_id, "aligned", None, None, None, None)?;
|
||||
state.build_snapshot(project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_practice_session(
|
||||
state: State<'_, AppState>,
|
||||
input: StartPracticeSessionInput,
|
||||
) -> Result<PracticeSession, String> {
|
||||
map_error(start_practice_session_inner(state.inner(), input))
|
||||
}
|
||||
|
||||
fn start_practice_session_inner(
|
||||
state: &AppState,
|
||||
input: StartPracticeSessionInput,
|
||||
) -> AppResult<PracticeSession> {
|
||||
let session = PracticeSession {
|
||||
id: format!("session-{}", Uuid::new_v4().simple()),
|
||||
role_id: input.role_id,
|
||||
started_at: state.now_iso(),
|
||||
attempts: Vec::new(),
|
||||
};
|
||||
state.insert_session(&input.project_id, &session)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn submit_practice_attempt(
|
||||
state: State<'_, AppState>,
|
||||
input: SubmitPracticeAttemptInput,
|
||||
) -> Result<SubmitPracticeAttemptResult, String> {
|
||||
map_error(submit_practice_attempt_inner(state.inner(), input))
|
||||
}
|
||||
|
||||
fn submit_practice_attempt_inner(
|
||||
state: &AppState,
|
||||
input: SubmitPracticeAttemptInput,
|
||||
) -> AppResult<SubmitPracticeAttemptResult> {
|
||||
let manifest = state.load_manifest(&input.project_id)?;
|
||||
let script = state
|
||||
.load_script(&input.project_id)?
|
||||
.ok_or_else(|| AppError::from("Missing script for practice evaluation."))?;
|
||||
let line = script
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == input.line_id)
|
||||
.ok_or_else(|| AppError::from("Unknown line selected for practice."))?;
|
||||
|
||||
let request = build_evaluation_request(
|
||||
&input.project_id,
|
||||
manifest.routing_policy.evaluation_provider.clone(),
|
||||
EvaluatePayload {
|
||||
expected_text: line.display_text.clone(),
|
||||
recording_path: input.recording_path.clone(),
|
||||
recognized_text_override: input.recognized_text_override.clone(),
|
||||
language: manifest.language.clone(),
|
||||
},
|
||||
)?;
|
||||
let response: EvaluationResponse = state.provider_runtime().execute(&request)?;
|
||||
state.write_evaluation(&input.project_id, &response.payload)?;
|
||||
|
||||
let attempt = PracticeAttempt {
|
||||
id: format!("attempt-{}", Uuid::new_v4().simple()),
|
||||
line_id: input.line_id,
|
||||
recognized_text: response.payload.recognized_text.clone(),
|
||||
score: response.payload.score,
|
||||
passed: response.payload.passed,
|
||||
created_at: state.now_iso(),
|
||||
};
|
||||
state.insert_attempt(&input.session_id, &attempt)?;
|
||||
let attempts = state
|
||||
.load_sessions(&input.project_id)?
|
||||
.into_iter()
|
||||
.find(|session| session.id == input.session_id)
|
||||
.map(|session| session.attempts)
|
||||
.ok_or_else(|| AppError::from("Practice session disappeared while saving attempt."))?;
|
||||
|
||||
Ok(SubmitPracticeAttemptResult {
|
||||
evaluation: response.payload,
|
||||
attempts,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_routing_policy(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
policy: RoutingPolicy,
|
||||
) -> Result<ProjectSnapshot, String> {
|
||||
map_error(set_routing_policy_inner(state.inner(), &project_id, policy))
|
||||
}
|
||||
|
||||
fn set_routing_policy_inner(
|
||||
state: &AppState,
|
||||
project_id: &str,
|
||||
policy: RoutingPolicy,
|
||||
) -> AppResult<ProjectSnapshot> {
|
||||
let current = state.load_manifest(project_id)?;
|
||||
state.update_summary_status(project_id, ¤t.status, None, None, None, Some(policy))?;
|
||||
state.build_snapshot(project_id)
|
||||
}
|
||||
200
src-tauri/src/db.rs
Normal file
200
src-tauri/src/db.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::{
|
||||
domain::{PracticeAttempt, PracticeSession, ProjectManifest, ProjectSummary, ScriptDocument},
|
||||
errors::AppResult,
|
||||
};
|
||||
|
||||
pub struct AppDatabase {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl AppDatabase {
|
||||
pub fn open(path: &Path) -> AppResult<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
"
|
||||
create table if not exists projects (
|
||||
id text primary key,
|
||||
name text not null,
|
||||
language text not null,
|
||||
status text not null,
|
||||
created_at text not null,
|
||||
total_duration_ms integer not null default 0
|
||||
);
|
||||
create table if not exists practice_sessions (
|
||||
id text primary key,
|
||||
project_id text not null,
|
||||
role_id text not null,
|
||||
started_at text not null
|
||||
);
|
||||
create table if not exists practice_attempts (
|
||||
id text primary key,
|
||||
session_id text not null,
|
||||
line_id text not null,
|
||||
recognized_text text not null,
|
||||
score real not null,
|
||||
passed integer not null,
|
||||
created_at text not null
|
||||
);
|
||||
",
|
||||
)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn upsert_project(&self, manifest: &ProjectManifest) -> AppResult<()> {
|
||||
self.conn.execute(
|
||||
"
|
||||
insert into projects (id, name, language, status, created_at, total_duration_ms)
|
||||
values (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
on conflict(id) do update set
|
||||
name = excluded.name,
|
||||
language = excluded.language,
|
||||
status = excluded.status,
|
||||
total_duration_ms = excluded.total_duration_ms
|
||||
",
|
||||
params![
|
||||
manifest.id,
|
||||
manifest.name,
|
||||
manifest.language,
|
||||
manifest.status,
|
||||
manifest.created_at,
|
||||
manifest.total_duration_ms,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_projects(&self) -> AppResult<Vec<ProjectSummary>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"
|
||||
select id, name, language, status, created_at, total_duration_ms
|
||||
from projects
|
||||
order by created_at desc
|
||||
",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok(ProjectSummary {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
language: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
total_duration_ms: row.get(5)?,
|
||||
role_count: 0,
|
||||
line_count: 0,
|
||||
})
|
||||
})?;
|
||||
let mut summaries = Vec::new();
|
||||
for row in rows {
|
||||
summaries.push(row?);
|
||||
}
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
pub fn with_script_stats(
|
||||
&self,
|
||||
summaries: Vec<ProjectSummary>,
|
||||
scripts: &[Option<ScriptDocument>],
|
||||
) -> Vec<ProjectSummary> {
|
||||
let script_by_id = summaries
|
||||
.iter()
|
||||
.map(|summary| summary.id.clone())
|
||||
.zip(scripts.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
summaries
|
||||
.into_iter()
|
||||
.map(|mut summary| {
|
||||
if let Some((_, Some(script))) = script_by_id
|
||||
.iter()
|
||||
.find(|(project_id, _)| project_id == &summary.id)
|
||||
{
|
||||
summary.role_count = script.roles.len();
|
||||
summary.line_count = script.lines.len();
|
||||
}
|
||||
summary
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn insert_session(&self, project_id: &str, session: &PracticeSession) -> AppResult<()> {
|
||||
self.conn.execute(
|
||||
"
|
||||
insert into practice_sessions (id, project_id, role_id, started_at)
|
||||
values (?1, ?2, ?3, ?4)
|
||||
",
|
||||
params![session.id, project_id, session.role_id, session.started_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_attempt(&self, session_id: &str, attempt: &PracticeAttempt) -> AppResult<()> {
|
||||
self.conn.execute(
|
||||
"
|
||||
insert into practice_attempts (id, session_id, line_id, recognized_text, score, passed, created_at)
|
||||
values (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
",
|
||||
params![
|
||||
attempt.id,
|
||||
session_id,
|
||||
attempt.line_id,
|
||||
attempt.recognized_text,
|
||||
attempt.score,
|
||||
i32::from(attempt.passed),
|
||||
attempt.created_at,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_sessions(&self, project_id: &str) -> AppResult<Vec<PracticeSession>> {
|
||||
let mut session_stmt = self.conn.prepare(
|
||||
"
|
||||
select id, role_id, started_at
|
||||
from practice_sessions
|
||||
where project_id = ?1
|
||||
order by started_at desc
|
||||
",
|
||||
)?;
|
||||
let sessions = session_stmt.query_map(params![project_id], |row| {
|
||||
Ok(PracticeSession {
|
||||
id: row.get(0)?,
|
||||
role_id: row.get(1)?,
|
||||
started_at: row.get(2)?,
|
||||
attempts: Vec::new(),
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut attempt_stmt = self.conn.prepare(
|
||||
"
|
||||
select id, line_id, recognized_text, score, passed, created_at
|
||||
from practice_attempts
|
||||
where session_id = ?1
|
||||
order by created_at desc
|
||||
",
|
||||
)?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for session in sessions {
|
||||
let mut session = session?;
|
||||
let attempts = attempt_stmt.query_map(params![session.id], |row| {
|
||||
Ok(PracticeAttempt {
|
||||
id: row.get(0)?,
|
||||
line_id: row.get(1)?,
|
||||
recognized_text: row.get(2)?,
|
||||
score: row.get(3)?,
|
||||
passed: row.get::<_, i32>(4)? == 1,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
for attempt in attempts {
|
||||
session.attempts.push(attempt?);
|
||||
}
|
||||
result.push(session);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
297
src-tauri/src/domain.rs
Normal file
297
src-tauri/src/domain.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderProfile {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub transport: String,
|
||||
pub capabilities: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RoutingPolicy {
|
||||
pub transcription_provider: String,
|
||||
pub alignment_provider: String,
|
||||
pub evaluation_provider: String,
|
||||
pub offline_only: bool,
|
||||
}
|
||||
|
||||
impl Default for RoutingPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
transcription_provider: "local-whisper-draft".to_string(),
|
||||
alignment_provider: "local-whisper-draft".to_string(),
|
||||
evaluation_provider: "local-whisper-draft".to_string(),
|
||||
offline_only: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AudioAsset {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub original_path: String,
|
||||
pub duration_ms: u64,
|
||||
pub start_offset_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectManifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub language: String,
|
||||
pub created_at: String,
|
||||
pub status: String,
|
||||
pub script_path: String,
|
||||
pub merged_audio_path: Option<String>,
|
||||
pub waveform_path: Option<String>,
|
||||
pub total_duration_ms: u64,
|
||||
pub audio_assets: Vec<AudioAsset>,
|
||||
pub routing_policy: RoutingPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Role {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub line_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Scene {
|
||||
pub id: String,
|
||||
pub heading: String,
|
||||
pub ordinal: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScriptLine {
|
||||
pub id: String,
|
||||
pub scene_id: String,
|
||||
pub role_id: Option<String>,
|
||||
pub role_name: Option<String>,
|
||||
pub display_text: String,
|
||||
pub normalized_text: String,
|
||||
pub ordinal: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScriptDocument {
|
||||
pub source_path: String,
|
||||
pub parser_warnings: Vec<String>,
|
||||
pub roles: Vec<Role>,
|
||||
pub scenes: Vec<Scene>,
|
||||
pub lines: Vec<ScriptLine>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranscriptSegment {
|
||||
pub id: String,
|
||||
pub start_ms: u64,
|
||||
pub end_ms: u64,
|
||||
pub text: String,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderRun {
|
||||
pub id: String,
|
||||
pub capability: String,
|
||||
pub provider_id: String,
|
||||
pub provider_version: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranscriptArtifact {
|
||||
pub id: String,
|
||||
pub provider_run: ProviderRun,
|
||||
pub segments: Vec<TranscriptSegment>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AlignmentCue {
|
||||
pub line_id: String,
|
||||
pub start_ms: u64,
|
||||
pub end_ms: u64,
|
||||
pub status: String,
|
||||
pub confidence: f32,
|
||||
pub transcript_span_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AlignmentDocument {
|
||||
pub provider_run: Option<ProviderRun>,
|
||||
pub unresolved_line_ids: Vec<String>,
|
||||
pub cues: Vec<AlignmentCue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WaveformDocument {
|
||||
pub peak_count: usize,
|
||||
pub peaks: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PracticeAttempt {
|
||||
pub id: String,
|
||||
pub line_id: String,
|
||||
pub recognized_text: String,
|
||||
pub score: f32,
|
||||
pub passed: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PracticeSession {
|
||||
pub id: String,
|
||||
pub role_id: String,
|
||||
pub started_at: String,
|
||||
pub attempts: Vec<PracticeAttempt>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluationArtifact {
|
||||
pub id: String,
|
||||
pub provider_run: ProviderRun,
|
||||
pub expected_text: String,
|
||||
pub recognized_text: String,
|
||||
pub missing_words: Vec<String>,
|
||||
pub extra_words: Vec<String>,
|
||||
pub score: f32,
|
||||
pub passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectSnapshot {
|
||||
pub project: ProjectManifest,
|
||||
pub script: Option<ScriptDocument>,
|
||||
pub alignment: Option<AlignmentDocument>,
|
||||
pub transcript: Option<TranscriptArtifact>,
|
||||
pub waveform: Option<WaveformDocument>,
|
||||
pub practice_sessions: Vec<PracticeSession>,
|
||||
pub providers: Vec<ProviderProfile>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub language: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub total_duration_ms: u64,
|
||||
pub role_count: usize,
|
||||
pub line_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardState {
|
||||
pub workspace_root: String,
|
||||
pub projects: Vec<ProjectSummary>,
|
||||
pub providers: Vec<ProviderProfile>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportProjectInput {
|
||||
pub name: String,
|
||||
pub script_path: String,
|
||||
pub audio_paths: Vec<String>,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AlignmentEdit {
|
||||
pub line_id: String,
|
||||
pub start_ms: u64,
|
||||
pub end_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartPracticeSessionInput {
|
||||
pub project_id: String,
|
||||
pub role_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubmitPracticeAttemptInput {
|
||||
pub project_id: String,
|
||||
pub session_id: String,
|
||||
pub line_id: String,
|
||||
pub recording_path: Option<String>,
|
||||
pub recognized_text_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubmitPracticeAttemptResult {
|
||||
pub evaluation: EvaluationArtifact,
|
||||
pub attempts: Vec<PracticeAttempt>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ParseScriptResult {
|
||||
pub source_path: String,
|
||||
pub parser_warnings: Vec<String>,
|
||||
pub roles: Vec<Role>,
|
||||
pub scenes: Vec<Scene>,
|
||||
pub lines: Vec<ScriptLine>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranscribePayload {
|
||||
pub merged_audio_path: String,
|
||||
pub language: String,
|
||||
pub total_duration_ms: u64,
|
||||
pub script_lines: Vec<ScriptLine>,
|
||||
pub options: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AlignPayload {
|
||||
pub lines: Vec<ScriptLine>,
|
||||
pub transcript_segments: Vec<TranscriptSegment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluatePayload {
|
||||
pub expected_text: String,
|
||||
pub recording_path: Option<String>,
|
||||
pub recognized_text_override: Option<String>,
|
||||
pub language: String,
|
||||
}
|
||||
33
src-tauri/src/errors.rs
Normal file
33
src-tauri/src/errors.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use std::{io, num::ParseFloatError};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("Audio parse error: {0}")]
|
||||
ParseFloat(#[from] ParseFloatError),
|
||||
#[error("WAV error: {0}")]
|
||||
Wav(#[from] hound::Error),
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
impl From<&str> for AppError {
|
||||
fn from(value: &str) -> Self {
|
||||
Self::Message(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for AppError {
|
||||
fn from(value: String) -> Self {
|
||||
Self::Message(value)
|
||||
}
|
||||
}
|
||||
38
src-tauri/src/main.rs
Normal file
38
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod app_state;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod domain;
|
||||
mod errors;
|
||||
mod media;
|
||||
mod providers;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use app_state::AppState;
|
||||
|
||||
fn main() {
|
||||
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."));
|
||||
|
||||
let state = AppState::new(workspace_root).expect("failed to initialize app state");
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_dashboard_state,
|
||||
commands::list_providers,
|
||||
commands::get_project_state,
|
||||
commands::import_project,
|
||||
commands::prepare_media,
|
||||
commands::run_transcription,
|
||||
commands::run_alignment,
|
||||
commands::save_alignment_edits,
|
||||
commands::start_practice_session,
|
||||
commands::submit_practice_attempt,
|
||||
commands::set_routing_policy
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
119
src-tauri/src/media.rs
Normal file
119
src-tauri/src/media.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
domain::{AudioAsset, WaveformDocument},
|
||||
errors::{AppError, AppResult},
|
||||
};
|
||||
|
||||
pub fn probe_duration_ms(path: &Path) -> AppResult<u64> {
|
||||
let output = Command::new("ffprobe")
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-show_entries")
|
||||
.arg("format=duration")
|
||||
.arg("-of")
|
||||
.arg("default=noprint_wrappers=1:nokey=1")
|
||||
.arg(path)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::Message(format!(
|
||||
"ffprobe failed for {}",
|
||||
path.to_string_lossy()
|
||||
)));
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let seconds: f64 = raw.parse()?;
|
||||
Ok((seconds * 1000.0).round() as u64)
|
||||
}
|
||||
|
||||
fn ffmpeg_list_line(path: &Path) -> String {
|
||||
let escaped = path.to_string_lossy().replace('\'', "'\\''");
|
||||
format!("file '{}'\n", escaped)
|
||||
}
|
||||
|
||||
pub fn prepare_media(
|
||||
project_dir: &Path,
|
||||
assets: &[AudioAsset],
|
||||
) -> AppResult<(PathBuf, PathBuf, WaveformDocument, u64)> {
|
||||
let derived_dir = project_dir.join("derived");
|
||||
fs::create_dir_all(&derived_dir)?;
|
||||
let concat_path = derived_dir.join("concat.txt");
|
||||
let merged_audio_path = derived_dir.join("merged.wav");
|
||||
let waveform_path = derived_dir.join("waveform.json");
|
||||
let total_duration_ms = assets
|
||||
.iter()
|
||||
.map(|asset| asset.duration_ms)
|
||||
.fold(0_u64, |acc, value| acc + value);
|
||||
|
||||
let list_content = assets
|
||||
.iter()
|
||||
.map(|asset| ffmpeg_list_line(Path::new(&asset.original_path)))
|
||||
.collect::<String>();
|
||||
fs::write(&concat_path, list_content)?;
|
||||
|
||||
let output = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-f")
|
||||
.arg("concat")
|
||||
.arg("-safe")
|
||||
.arg("0")
|
||||
.arg("-i")
|
||||
.arg(&concat_path)
|
||||
.arg("-ac")
|
||||
.arg("1")
|
||||
.arg("-ar")
|
||||
.arg("16000")
|
||||
.arg("-sample_fmt")
|
||||
.arg("s16")
|
||||
.arg(&merged_audio_path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(AppError::Message(format!(
|
||||
"ffmpeg failed while preparing merged audio: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
|
||||
let waveform = compute_waveform(&merged_audio_path, 320)?;
|
||||
fs::write(&waveform_path, serde_json::to_vec_pretty(&waveform)?)?;
|
||||
|
||||
Ok((
|
||||
merged_audio_path,
|
||||
waveform_path,
|
||||
waveform,
|
||||
total_duration_ms,
|
||||
))
|
||||
}
|
||||
|
||||
fn compute_waveform(path: &Path, peak_target: usize) -> AppResult<WaveformDocument> {
|
||||
let mut reader = hound::WavReader::open(path)?;
|
||||
let total_samples = reader.duration() as usize;
|
||||
let bucket_size = usize::max(1, total_samples / usize::max(1, peak_target));
|
||||
let mut peaks = Vec::new();
|
||||
let mut current_peak = 0.0_f32;
|
||||
let mut seen = 0_usize;
|
||||
|
||||
for sample in reader.samples::<i16>() {
|
||||
let normalized = sample?.abs() as f32 / i16::MAX as f32;
|
||||
current_peak = current_peak.max(normalized);
|
||||
seen += 1;
|
||||
if seen >= bucket_size {
|
||||
peaks.push(current_peak);
|
||||
current_peak = 0.0;
|
||||
seen = 0;
|
||||
}
|
||||
}
|
||||
if seen > 0 {
|
||||
peaks.push(current_peak);
|
||||
}
|
||||
|
||||
Ok(WaveformDocument {
|
||||
peak_count: peaks.len(),
|
||||
peaks,
|
||||
})
|
||||
}
|
||||
114
src-tauri/src/providers/local.rs
Normal file
114
src-tauri/src/providers/local.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use std::{
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
use crate::errors::{AppError, AppResult};
|
||||
|
||||
use super::protocol::ProviderResponse;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LocalProviderRuntime {
|
||||
script_path: PathBuf,
|
||||
project_root: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalProviderRuntime {
|
||||
pub fn new() -> Self {
|
||||
let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let script_path = project_root.join("providers/python/runtime.py");
|
||||
Self {
|
||||
script_path,
|
||||
project_root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute<TRequest, TResponse>(
|
||||
&self,
|
||||
request: &TRequest,
|
||||
) -> AppResult<ProviderResponse<TResponse>>
|
||||
where
|
||||
TRequest: Serialize,
|
||||
TResponse: DeserializeOwned,
|
||||
{
|
||||
let mut last_error = None;
|
||||
let candidates = self.python_candidates();
|
||||
|
||||
for python_binary in &candidates {
|
||||
let mut child = match Command::new(python_binary)
|
||||
.arg(&self.script_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
last_error = Some(format!(
|
||||
"failed to start '{}' ({error})",
|
||||
python_binary.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let payload = serde_json::to_vec(request)?;
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
stdin.write_all(&payload)?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::Message(format!(
|
||||
"Provider runtime failed via '{}': {}",
|
||||
python_binary.display(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
|
||||
let response: ProviderResponse<TResponse> = serde_json::from_slice(&output.stdout)?;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let tried = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(AppError::Message(format!(
|
||||
"Unable to start the provider runtime. Tried: {tried}. {}",
|
||||
last_error.unwrap_or_else(|| "No Python candidates were available.".to_string())
|
||||
)))
|
||||
}
|
||||
|
||||
fn python_candidates(&self) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Ok(override_path) = std::env::var("REHEARSAL_PYTHON") {
|
||||
candidates.push(PathBuf::from(override_path));
|
||||
}
|
||||
|
||||
let venv_candidates = [
|
||||
self.project_root.join(".venv/bin/python"),
|
||||
self.project_root.join(".venv/Scripts/python.exe"),
|
||||
];
|
||||
|
||||
for candidate in venv_candidates {
|
||||
if path_exists(&candidate) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(PathBuf::from("python"));
|
||||
candidates.push(PathBuf::from("python3"));
|
||||
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
fn path_exists(path: &Path) -> bool {
|
||||
path.exists() && path.is_file()
|
||||
}
|
||||
38
src-tauri/src/providers/mod.rs
Normal file
38
src-tauri/src/providers/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod local;
|
||||
pub mod protocol;
|
||||
|
||||
use crate::domain::ProviderProfile;
|
||||
|
||||
pub use local::LocalProviderRuntime;
|
||||
|
||||
pub fn default_profiles() -> Vec<ProviderProfile> {
|
||||
vec![
|
||||
ProviderProfile {
|
||||
id: "local-whisper-draft".to_string(),
|
||||
label: "Local Draft Stack".to_string(),
|
||||
transport: "local".to_string(),
|
||||
capabilities: vec![
|
||||
"parse_script".to_string(),
|
||||
"transcribe_audio".to_string(),
|
||||
"align_script".to_string(),
|
||||
"evaluate_attempt".to_string(),
|
||||
],
|
||||
enabled: true,
|
||||
description:
|
||||
"Local Python runtime using optional faster-whisper plus deterministic fallback logic."
|
||||
.to_string(),
|
||||
},
|
||||
ProviderProfile {
|
||||
id: "remote-placeholder".to_string(),
|
||||
label: "Remote Placeholder".to_string(),
|
||||
transport: "remote".to_string(),
|
||||
capabilities: vec![
|
||||
"transcribe_audio".to_string(),
|
||||
"align_script".to_string(),
|
||||
"evaluate_attempt".to_string(),
|
||||
],
|
||||
enabled: false,
|
||||
description: "Reserved adapter slot for hosted speech or LLM APIs.".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
82
src-tauri/src/providers/protocol.rs
Normal file
82
src-tauri/src/providers/protocol.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::{
|
||||
AlignPayload, AlignmentDocument, EvaluatePayload, EvaluationArtifact, ParseScriptResult,
|
||||
TranscribePayload, TranscriptArtifact,
|
||||
};
|
||||
use crate::errors::AppResult;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderRequest<T> {
|
||||
pub capability: String,
|
||||
pub provider_id: String,
|
||||
pub project_id: String,
|
||||
pub payload: T,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderResponse<T> {
|
||||
pub provider_id: String,
|
||||
pub provider_version: String,
|
||||
pub warnings: Vec<String>,
|
||||
pub payload: T,
|
||||
}
|
||||
|
||||
pub type ParseScriptRequest = ProviderRequest<serde_json::Value>;
|
||||
pub type ParseScriptResponse = ProviderResponse<ParseScriptResult>;
|
||||
pub type TranscriptionResponse = ProviderResponse<TranscriptArtifact>;
|
||||
pub type AlignmentResponse = ProviderResponse<AlignmentDocument>;
|
||||
pub type EvaluationResponse = ProviderResponse<EvaluationArtifact>;
|
||||
|
||||
pub fn build_parse_script_request(
|
||||
project_id: &str,
|
||||
script_path: String,
|
||||
) -> AppResult<ParseScriptRequest> {
|
||||
Ok(ProviderRequest {
|
||||
capability: "parse_script".to_string(),
|
||||
provider_id: "local-whisper-draft".to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
payload: serde_json::json!({ "scriptPath": script_path }),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_transcription_request(
|
||||
project_id: &str,
|
||||
provider_id: String,
|
||||
payload: TranscribePayload,
|
||||
) -> AppResult<ProviderRequest<TranscribePayload>> {
|
||||
Ok(ProviderRequest {
|
||||
capability: "transcribe_audio".to_string(),
|
||||
provider_id,
|
||||
project_id: project_id.to_string(),
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_alignment_request(
|
||||
project_id: &str,
|
||||
provider_id: String,
|
||||
payload: AlignPayload,
|
||||
) -> AppResult<ProviderRequest<AlignPayload>> {
|
||||
Ok(ProviderRequest {
|
||||
capability: "align_script".to_string(),
|
||||
provider_id,
|
||||
project_id: project_id.to_string(),
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_evaluation_request(
|
||||
project_id: &str,
|
||||
provider_id: String,
|
||||
payload: EvaluatePayload,
|
||||
) -> AppResult<ProviderRequest<EvaluatePayload>> {
|
||||
Ok(ProviderRequest {
|
||||
capability: "evaluate_attempt".to_string(),
|
||||
provider_id,
|
||||
project_id: project_id.to_string(),
|
||||
payload,
|
||||
})
|
||||
}
|
||||
32
src-tauri/tauri.conf.json
Normal file
32
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Rehearsal Studio",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.feka.rehearsalstudio",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
"csp": "default-src 'self' asset: http://asset.localhost blob: data:; media-src 'self' asset: http://asset.localhost blob: data:; img-src 'self' asset: http://asset.localhost blob: data:;"
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "Rehearsal Studio",
|
||||
"width": 1560,
|
||||
"height": 980,
|
||||
"minWidth": 1160,
|
||||
"minHeight": 820,
|
||||
"resizable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
}
|
||||
}
|
||||
245
src/App.tsx
Normal file
245
src/App.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "./lib/api";
|
||||
import type {
|
||||
DashboardState,
|
||||
EvaluationArtifact,
|
||||
ImportProjectInput,
|
||||
PracticeSession,
|
||||
ProjectSnapshot,
|
||||
RoutingPolicy,
|
||||
} from "./lib/types";
|
||||
import { ImportProjectForm } from "./components/ImportProjectForm";
|
||||
import { PracticePanel } from "./components/PracticePanel";
|
||||
import { ProjectSidebar } from "./components/ProjectSidebar";
|
||||
import { ProviderPanel } from "./components/ProviderPanel";
|
||||
import { ReaderPanel } from "./components/ReaderPanel";
|
||||
import { SyncPanel } from "./components/SyncPanel";
|
||||
|
||||
export default function App() {
|
||||
const [dashboard, setDashboard] = useState<DashboardState | null>(null);
|
||||
const [project, setProject] = useState<ProjectSnapshot | null>(null);
|
||||
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
|
||||
const [activeSession, setActiveSession] = useState<PracticeSession | null>(null);
|
||||
const [recentEvaluation, setRecentEvaluation] = useState<EvaluationArtifact | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refreshDashboard(selectProjectId?: string) {
|
||||
const nextDashboard = await api.getDashboardState();
|
||||
setDashboard(nextDashboard);
|
||||
const projectId = selectProjectId ?? activeProjectId ?? nextDashboard.projects[0]?.id ?? null;
|
||||
if (projectId) {
|
||||
const nextProject = await api.getProjectState(projectId);
|
||||
setProject(nextProject);
|
||||
setActiveProjectId(projectId);
|
||||
setActiveSession(nextProject.practiceSessions[0] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await refreshDashboard();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const projectRoleName = useMemo(() => {
|
||||
if (!project || !activeSession) {
|
||||
return null;
|
||||
}
|
||||
return project.script?.roles.find((role) => role.id === activeSession.roleId)?.name ?? null;
|
||||
}, [project, activeSession]);
|
||||
|
||||
async function perform<T>(operation: () => Promise<T>, after?: (value: T) => void | Promise<void>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const value = await operation();
|
||||
if (after) {
|
||||
await after(value);
|
||||
}
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(projectId: string) {
|
||||
await perform(async () => api.getProjectState(projectId), (snapshot) => {
|
||||
startTransition(() => {
|
||||
setProject(snapshot);
|
||||
setActiveProjectId(projectId);
|
||||
setActiveSession(snapshot.practiceSessions[0] ?? null);
|
||||
setRecentEvaluation(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleImport(input: ImportProjectInput) {
|
||||
await perform(async () => api.importProject(input), async (snapshot) => {
|
||||
startTransition(() => {
|
||||
setProject(snapshot);
|
||||
setActiveProjectId(snapshot.project.id);
|
||||
setActiveSession(null);
|
||||
setRecentEvaluation(null);
|
||||
});
|
||||
await refreshDashboard(snapshot.project.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleProjectRefresh(nextProject: Promise<ProjectSnapshot>) {
|
||||
await perform(async () => nextProject, async (snapshot) => {
|
||||
setProject(snapshot);
|
||||
setActiveProjectId(snapshot.project.id);
|
||||
setActiveSession(snapshot.practiceSessions[0] ?? activeSession);
|
||||
await refreshDashboard(snapshot.project.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStartSession(roleId: string) {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
await perform(
|
||||
async () => api.startPracticeSession({ projectId: project.project.id, roleId }),
|
||||
(session) => {
|
||||
setActiveSession(session);
|
||||
setProject((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
practiceSessions: [session, ...current.practiceSessions.filter((entry) => entry.id !== session.id)],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmitAttempt(lineId: string, recognizedTextOverride: string) {
|
||||
if (!project || !activeSession) {
|
||||
return;
|
||||
}
|
||||
await perform(
|
||||
async () =>
|
||||
api.submitPracticeAttempt({
|
||||
projectId: project.project.id,
|
||||
sessionId: activeSession.id,
|
||||
lineId,
|
||||
recognizedTextOverride,
|
||||
}),
|
||||
(result) => {
|
||||
setRecentEvaluation(result.evaluation);
|
||||
setProject((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
practiceSessions: current.practiceSessions.map((session) =>
|
||||
session.id === activeSession.id ? { ...session, attempts: result.attempts } : session,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePolicyChange(policy: RoutingPolicy) {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
await handleProjectRefresh(api.setRoutingPolicy(project.project.id, policy));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="app-chrome">
|
||||
<header className="hero-panel">
|
||||
<div>
|
||||
<span className="eyebrow">Rehearsal Studio</span>
|
||||
<h1>Audio-synced script practice with modular local AI</h1>
|
||||
</div>
|
||||
<p>
|
||||
Import a screenplay and one or more audio files, generate a draft sync, correct the cues, then practice a
|
||||
role with provider-routed transcription and evaluation.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="layout-grid">
|
||||
<aside className="left-rail">
|
||||
<ImportProjectForm onImport={handleImport} busy={busy} />
|
||||
{dashboard ? (
|
||||
<ProjectSidebar
|
||||
projects={dashboard.projects}
|
||||
activeProjectId={activeProjectId}
|
||||
onSelect={(projectId) => void loadProject(projectId)}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<section className="content-rail">
|
||||
{error ? <div className="error-banner">{error}</div> : null}
|
||||
{project ? (
|
||||
<>
|
||||
<section className="panel summary-panel">
|
||||
<div>
|
||||
<span className="eyebrow">Current project</span>
|
||||
<h2>{project.project.name}</h2>
|
||||
</div>
|
||||
<div className="summary-grid">
|
||||
<article>
|
||||
<strong>{project.script?.roles.length ?? 0}</strong>
|
||||
<span>roles</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>{project.script?.lines.length ?? 0}</strong>
|
||||
<span>script lines</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>{project.project.audioAssets.length}</strong>
|
||||
<span>audio sources</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>{projectRoleName ?? "none"}</strong>
|
||||
<span>active practice role</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<ProviderPanel project={project} onPolicyChange={handlePolicyChange} />
|
||||
<div className="two-column">
|
||||
<ReaderPanel project={project} />
|
||||
<SyncPanel
|
||||
project={project}
|
||||
busy={busy}
|
||||
onPrepareMedia={() => handleProjectRefresh(api.prepareMedia(project.project.id))}
|
||||
onTranscribe={() => handleProjectRefresh(api.runTranscription(project.project.id))}
|
||||
onAlign={() => handleProjectRefresh(api.runAlignment(project.project.id))}
|
||||
onSaveEdits={(edits) => handleProjectRefresh(api.saveAlignmentEdits(project.project.id, edits))}
|
||||
/>
|
||||
</div>
|
||||
<PracticePanel
|
||||
project={project}
|
||||
activeSession={activeSession}
|
||||
recentEvaluation={recentEvaluation}
|
||||
busy={busy}
|
||||
onStartSession={handleStartSession}
|
||||
onSubmitAttempt={handleSubmitAttempt}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<section className="panel empty-panel">
|
||||
<h2>No project loaded yet</h2>
|
||||
<p>Import a script and audio files to initialize the rehearsal workspace.</p>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
src/components/ImportProjectForm.tsx
Normal file
72
src/components/ImportProjectForm.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import type { ImportProjectInput } from "../lib/types";
|
||||
|
||||
interface ImportProjectFormProps {
|
||||
onImport: (input: ImportProjectInput) => Promise<void>;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const suggestedAudioPaths = [
|
||||
"/home/feka/Documents/szegeny dzsoni/Dzsoni-01.mp3",
|
||||
"/home/feka/Documents/szegeny dzsoni/Dzsoni-02.mp3",
|
||||
].join("\n");
|
||||
|
||||
export function ImportProjectForm({ onImport, busy }: ImportProjectFormProps) {
|
||||
const [name, setName] = useState("Szegeny Dzsoni Session");
|
||||
const [language, setLanguage] = useState("hu-HU");
|
||||
const [scriptPath, setScriptPath] = useState("/path/to/script.docx");
|
||||
const [audioPathText, setAudioPathText] = useState(suggestedAudioPaths);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const audioPaths = audioPathText
|
||||
.split("\n")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
await onImport({
|
||||
name,
|
||||
language,
|
||||
scriptPath,
|
||||
audioPaths,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel panel-import">
|
||||
<div className="panel-header">
|
||||
<span className="eyebrow">Import</span>
|
||||
<h2>New rehearsal project</h2>
|
||||
</div>
|
||||
<form className="stack" onSubmit={handleSubmit}>
|
||||
<label className="field">
|
||||
<span>Project name</span>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Language</span>
|
||||
<input value={language} onChange={(event) => setLanguage(event.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Script path</span>
|
||||
<input
|
||||
value={scriptPath}
|
||||
onChange={(event) => setScriptPath(event.target.value)}
|
||||
placeholder="/absolute/path/to/script.docx"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Audio paths</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={audioPathText}
|
||||
onChange={(event) => setAudioPathText(event.target.value)}
|
||||
placeholder="/absolute/path/to/track-01.mp3"
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" type="submit" disabled={busy}>
|
||||
{busy ? "Importing..." : "Create project"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
119
src/components/PracticePanel.tsx
Normal file
119
src/components/PracticePanel.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { EvaluationArtifact, PracticeSession, ProjectSnapshot } from "../lib/types";
|
||||
|
||||
interface PracticePanelProps {
|
||||
project: ProjectSnapshot;
|
||||
activeSession: PracticeSession | null;
|
||||
recentEvaluation: EvaluationArtifact | null;
|
||||
busy: boolean;
|
||||
onStartSession: (roleId: string) => Promise<void>;
|
||||
onSubmitAttempt: (lineId: string, recognizedTextOverride: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PracticePanel({
|
||||
project,
|
||||
activeSession,
|
||||
recentEvaluation,
|
||||
busy,
|
||||
onStartSession,
|
||||
onSubmitAttempt,
|
||||
}: PracticePanelProps) {
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(project.script?.roles[0]?.id ?? "");
|
||||
const [selectedLineId, setSelectedLineId] = useState("");
|
||||
const [recognizedText, setRecognizedText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const nextRoleId = project.script?.roles[0]?.id ?? "";
|
||||
setSelectedRoleId(nextRoleId);
|
||||
setSelectedLineId("");
|
||||
setRecognizedText("");
|
||||
}, [project.project.id, project.script]);
|
||||
|
||||
const visibleLines = useMemo(
|
||||
() => project.script?.lines.filter((line) => line.roleId === selectedRoleId) ?? [],
|
||||
[project.script, selectedRoleId],
|
||||
);
|
||||
|
||||
const selectedRole = project.script?.roles.find((role) => role.id === selectedRoleId);
|
||||
|
||||
return (
|
||||
<section className="panel practice-panel">
|
||||
<div className="panel-header">
|
||||
<span className="eyebrow">Practice</span>
|
||||
<h2>Role rehearsal</h2>
|
||||
</div>
|
||||
<div className="practice-grid">
|
||||
<label className="field">
|
||||
<span>Role</span>
|
||||
<select
|
||||
value={selectedRoleId}
|
||||
onChange={(event) => {
|
||||
setSelectedRoleId(event.target.value);
|
||||
setSelectedLineId("");
|
||||
}}
|
||||
>
|
||||
{project.script?.roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => selectedRoleId && void onStartSession(selectedRoleId)}
|
||||
disabled={busy || !selectedRoleId}
|
||||
>
|
||||
{activeSession && activeSession.roleId === selectedRoleId ? "Session active" : "Start session"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="practice-copy">
|
||||
<p>
|
||||
Lead-in playback will use the synced cues for the two previous lines. While local ASR is not configured,
|
||||
you can paste the recognized text to exercise the evaluation pipeline.
|
||||
</p>
|
||||
{selectedRole ? <strong>Selected role: {selectedRole.name}</strong> : null}
|
||||
</div>
|
||||
<div className="practice-line-list">
|
||||
{visibleLines.map((line) => (
|
||||
<button
|
||||
key={line.id}
|
||||
type="button"
|
||||
className={`practice-line ${selectedLineId === line.id ? "active" : ""}`}
|
||||
onClick={() => setSelectedLineId(line.id)}
|
||||
>
|
||||
{line.displayText}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Recognized text override</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={recognizedText}
|
||||
onChange={(event) => setRecognizedText(event.target.value)}
|
||||
placeholder="Paste the recognized line here while the ASR provider is in draft mode."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
disabled={busy || !activeSession || !selectedLineId}
|
||||
onClick={() => void onSubmitAttempt(selectedLineId, recognizedText)}
|
||||
>
|
||||
Evaluate attempt
|
||||
</button>
|
||||
{recentEvaluation ? (
|
||||
<div className={`evaluation-card ${recentEvaluation.passed ? "pass" : "fail"}`}>
|
||||
<strong>{recentEvaluation.passed ? "Close enough" : "Needs another pass"}</strong>
|
||||
<p>Expected: {recentEvaluation.expectedText}</p>
|
||||
<p>Recognized: {recentEvaluation.recognizedText}</p>
|
||||
<p>Score: {(recentEvaluation.score * 100).toFixed(0)}%</p>
|
||||
<p>Missing: {recentEvaluation.missingWords.join(", ") || "none"}</p>
|
||||
<p>Extra: {recentEvaluation.extraWords.join(", ") || "none"}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
46
src/components/ProjectSidebar.tsx
Normal file
46
src/components/ProjectSidebar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ProjectSummary } from "../lib/types";
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
projects: ProjectSummary[];
|
||||
activeProjectId: string | null;
|
||||
onSelect: (projectId: string) => void;
|
||||
}
|
||||
|
||||
function formatDuration(totalMs: number) {
|
||||
const totalSeconds = Math.floor(totalMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function ProjectSidebar({ projects, activeProjectId, onSelect }: ProjectSidebarProps) {
|
||||
return (
|
||||
<section className="panel sidebar-panel">
|
||||
<div className="panel-header">
|
||||
<span className="eyebrow">Projects</span>
|
||||
<h2>Workspace</h2>
|
||||
</div>
|
||||
<div className="project-list">
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
className={`project-card ${project.id === activeProjectId ? "active" : ""}`}
|
||||
onClick={() => onSelect(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="project-card-topline">
|
||||
<strong>{project.name}</strong>
|
||||
<span>{project.language}</span>
|
||||
</div>
|
||||
<div className="project-card-meta">
|
||||
<span>{project.roleCount} roles</span>
|
||||
<span>{project.lineCount} lines</span>
|
||||
<span>{formatDuration(project.totalDurationMs)}</span>
|
||||
</div>
|
||||
<span className="status-pill">{project.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
82
src/components/ProviderPanel.tsx
Normal file
82
src/components/ProviderPanel.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ProjectSnapshot, RoutingPolicy } from "../lib/types";
|
||||
|
||||
interface ProviderPanelProps {
|
||||
project: ProjectSnapshot;
|
||||
onPolicyChange: (policy: RoutingPolicy) => Promise<void>;
|
||||
}
|
||||
|
||||
type ProviderRoutingKey = "transcriptionProvider" | "alignmentProvider" | "evaluationProvider";
|
||||
|
||||
export function ProviderPanel({ project, onPolicyChange }: ProviderPanelProps) {
|
||||
const { routingPolicy } = project.project;
|
||||
|
||||
function buildSelect(capability: ProviderRoutingKey, label: string) {
|
||||
const providerValue = routingPolicy[capability];
|
||||
return (
|
||||
<label className="field compact-field">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
value={providerValue}
|
||||
onChange={(event) =>
|
||||
void onPolicyChange({
|
||||
...routingPolicy,
|
||||
[capability]: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{project.providers
|
||||
.filter((provider) => provider.capabilities.includes(
|
||||
capability === "transcriptionProvider"
|
||||
? "transcribe_audio"
|
||||
: capability === "alignmentProvider"
|
||||
? "align_script"
|
||||
: "evaluate_attempt",
|
||||
))
|
||||
.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<span className="eyebrow">Providers</span>
|
||||
<h2>Routing policy</h2>
|
||||
</div>
|
||||
<div className="provider-grid">
|
||||
{buildSelect("transcriptionProvider", "Transcription")}
|
||||
{buildSelect("alignmentProvider", "Alignment")}
|
||||
{buildSelect("evaluationProvider", "Evaluation")}
|
||||
<label className="toggle-field">
|
||||
<span>Offline only</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={routingPolicy.offlineOnly}
|
||||
onChange={(event) =>
|
||||
void onPolicyChange({
|
||||
...routingPolicy,
|
||||
offlineOnly: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="provider-list">
|
||||
{project.providers.map((provider) => (
|
||||
<article key={provider.id} className={`provider-card ${provider.enabled ? "" : "disabled"}`}>
|
||||
<div>
|
||||
<strong>{provider.label}</strong>
|
||||
<span>{provider.transport}</span>
|
||||
</div>
|
||||
<p>{provider.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
85
src/components/ReaderPanel.tsx
Normal file
85
src/components/ReaderPanel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import type { ProjectSnapshot } from "../lib/types";
|
||||
|
||||
interface ReaderPanelProps {
|
||||
project: ProjectSnapshot;
|
||||
}
|
||||
|
||||
function formatMs(value: number) {
|
||||
return `${(value / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function buildAudioSource(path: string | null) {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
|
||||
return convertFileSrc(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ReaderPanel({ project }: ReaderPanelProps) {
|
||||
const audioSource = buildAudioSource(project.project.mergedAudioPath);
|
||||
const peaks = project.waveform?.peaks ?? [];
|
||||
|
||||
return (
|
||||
<section className="panel reader-panel">
|
||||
<div className="panel-header">
|
||||
<span className="eyebrow">Reader</span>
|
||||
<h2>Script with playback</h2>
|
||||
</div>
|
||||
<div className="reader-topbar">
|
||||
<div>
|
||||
<strong>{project.project.name}</strong>
|
||||
<p>{project.project.audioAssets.length} source files on one rehearsal timeline</p>
|
||||
</div>
|
||||
<div className="source-badges">
|
||||
{project.project.audioAssets.map((asset) => (
|
||||
<span key={asset.id} className="source-pill">
|
||||
{asset.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="waveform-shell">
|
||||
{peaks.length > 0 ? (
|
||||
<svg viewBox={`0 0 ${peaks.length} 1`} preserveAspectRatio="none" className="waveform">
|
||||
{peaks.map((peak, index) => (
|
||||
<line
|
||||
key={`${index}-${peak}`}
|
||||
x1={index}
|
||||
x2={index}
|
||||
y1={0.5 - peak / 2}
|
||||
y2={0.5 + peak / 2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="waveform-empty">Prepare media to generate a waveform preview.</div>
|
||||
)}
|
||||
</div>
|
||||
{audioSource ? (
|
||||
<audio controls preload="metadata" src={audioSource} className="audio-player" />
|
||||
) : (
|
||||
<div className="waveform-empty">
|
||||
Local playback is available inside Tauri after media preparation. Browser preview omits the file URL bridge.
|
||||
</div>
|
||||
)}
|
||||
<div className="script-table">
|
||||
{project.script?.lines.map((line) => {
|
||||
const cue = project.alignment?.cues.find((entry) => entry.lineId === line.id);
|
||||
return (
|
||||
<article key={line.id} className="script-line">
|
||||
<div className="script-line-meta">
|
||||
<span>{line.roleName ?? "Stage"}</span>
|
||||
<small>{cue ? `${formatMs(cue.startMs)} - ${formatMs(cue.endMs)}` : "unmatched"}</small>
|
||||
</div>
|
||||
<p>{line.displayText}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
129
src/components/SyncPanel.tsx
Normal file
129
src/components/SyncPanel.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AlignmentEdit, ProjectSnapshot } from "../lib/types";
|
||||
|
||||
interface SyncPanelProps {
|
||||
project: ProjectSnapshot;
|
||||
busy: boolean;
|
||||
onPrepareMedia: () => Promise<void>;
|
||||
onTranscribe: () => Promise<void>;
|
||||
onAlign: () => Promise<void>;
|
||||
onSaveEdits: (edits: AlignmentEdit[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function formatSeconds(value: number) {
|
||||
return (value / 1000).toFixed(1);
|
||||
}
|
||||
|
||||
export function SyncPanel({
|
||||
project,
|
||||
busy,
|
||||
onPrepareMedia,
|
||||
onTranscribe,
|
||||
onAlign,
|
||||
onSaveEdits,
|
||||
}: SyncPanelProps) {
|
||||
const draftRows = useMemo(
|
||||
() =>
|
||||
project.script?.lines.map((line) => {
|
||||
const cue = project.alignment?.cues.find((entry) => entry.lineId === line.id);
|
||||
return {
|
||||
lineId: line.id,
|
||||
roleName: line.roleName ?? "Stage",
|
||||
text: line.displayText,
|
||||
startMs: cue?.startMs ?? 0,
|
||||
endMs: cue?.endMs ?? 0,
|
||||
status: cue?.status ?? "unmatched",
|
||||
confidence: cue?.confidence ?? 0,
|
||||
};
|
||||
}) ?? [],
|
||||
[project],
|
||||
);
|
||||
|
||||
const [rows, setRows] = useState(draftRows);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(draftRows);
|
||||
}, [draftRows]);
|
||||
|
||||
return (
|
||||
<section className="panel sync-panel">
|
||||
<div className="panel-header">
|
||||
<span className="eyebrow">Sync</span>
|
||||
<h2>Alignment editor</h2>
|
||||
</div>
|
||||
<div className="action-strip">
|
||||
<button type="button" onClick={() => void onPrepareMedia()} disabled={busy}>
|
||||
Prepare media
|
||||
</button>
|
||||
<button type="button" onClick={() => void onTranscribe()} disabled={busy}>
|
||||
Draft transcript
|
||||
</button>
|
||||
<button type="button" onClick={() => void onAlign()} disabled={busy}>
|
||||
Align lines
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onSaveEdits(
|
||||
rows.map((row) => ({
|
||||
lineId: row.lineId,
|
||||
startMs: Math.max(0, Math.round(row.startMs)),
|
||||
endMs: Math.max(Math.round(row.startMs) + 250, Math.round(row.endMs)),
|
||||
})),
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
Save cue edits
|
||||
</button>
|
||||
</div>
|
||||
<div className="sync-grid">
|
||||
{rows.map((row, index) => (
|
||||
<article key={row.lineId} className="sync-row">
|
||||
<div className="sync-row-topline">
|
||||
<strong>{index + 1}. {row.roleName}</strong>
|
||||
<span className={`status-pill status-${row.status}`}>{row.status}</span>
|
||||
<small>{Math.round(row.confidence * 100)}%</small>
|
||||
</div>
|
||||
<p>{row.text}</p>
|
||||
<div className="cue-inputs">
|
||||
<label className="field compact-field">
|
||||
<span>Start</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formatSeconds(row.startMs)}
|
||||
onChange={(event) => {
|
||||
const seconds = Number(event.target.value);
|
||||
setRows((current) =>
|
||||
current.map((entry) =>
|
||||
entry.lineId === row.lineId ? { ...entry, startMs: seconds * 1000 } : entry,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="field compact-field">
|
||||
<span>End</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formatSeconds(row.endMs)}
|
||||
onChange={(event) => {
|
||||
const seconds = Number(event.target.value);
|
||||
setRows((current) =>
|
||||
current.map((entry) =>
|
||||
entry.lineId === row.lineId ? { ...entry, endMs: seconds * 1000 } : entry,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
75
src/lib/api.ts
Normal file
75
src/lib/api.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { mockApi } from "./mock";
|
||||
import type {
|
||||
AlignmentEdit,
|
||||
DashboardState,
|
||||
ImportProjectInput,
|
||||
PracticeSession,
|
||||
ProjectSnapshot,
|
||||
ProviderProfile,
|
||||
RoutingPolicy,
|
||||
StartPracticeSessionInput,
|
||||
SubmitPracticeAttemptInput,
|
||||
SubmitPracticeAttemptResult,
|
||||
} from "./types";
|
||||
|
||||
function isTauriRuntime() {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
async function call<T>(command: string, payload?: Record<string, unknown>): Promise<T> {
|
||||
if (!isTauriRuntime()) {
|
||||
switch (command) {
|
||||
case "get_dashboard_state":
|
||||
return mockApi.getDashboardState() as Promise<T>;
|
||||
case "import_project":
|
||||
return mockApi.importProject(payload?.input as ImportProjectInput) as Promise<T>;
|
||||
case "get_project_state":
|
||||
return mockApi.getProjectState(payload?.projectId as string) as Promise<T>;
|
||||
case "prepare_media":
|
||||
return mockApi.prepareMedia(payload?.projectId as string) as Promise<T>;
|
||||
case "run_transcription":
|
||||
return mockApi.runTranscription(payload?.projectId as string) as Promise<T>;
|
||||
case "run_alignment":
|
||||
return mockApi.runAlignment(payload?.projectId as string) as Promise<T>;
|
||||
case "save_alignment_edits":
|
||||
return mockApi.saveAlignmentEdits(
|
||||
payload?.projectId as string,
|
||||
(payload?.edits as AlignmentEdit[]) ?? [],
|
||||
) as Promise<T>;
|
||||
case "start_practice_session":
|
||||
return mockApi.startPracticeSession(payload?.input as StartPracticeSessionInput) as Promise<T>;
|
||||
case "submit_practice_attempt":
|
||||
return mockApi.submitPracticeAttempt(payload?.input as SubmitPracticeAttemptInput) as Promise<T>;
|
||||
case "list_providers":
|
||||
return mockApi.listProviders() as Promise<T>;
|
||||
case "set_routing_policy":
|
||||
return mockApi.setRoutingPolicy(
|
||||
payload?.projectId as string,
|
||||
payload?.policy as RoutingPolicy,
|
||||
) as Promise<T>;
|
||||
default:
|
||||
throw new Error(`Unknown browser mock command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
return invoke<T>(command, payload);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getDashboardState: () => call<DashboardState>("get_dashboard_state"),
|
||||
importProject: (input: ImportProjectInput) => call<ProjectSnapshot>("import_project", { input }),
|
||||
getProjectState: (projectId: string) => call<ProjectSnapshot>("get_project_state", { projectId }),
|
||||
prepareMedia: (projectId: string) => call<ProjectSnapshot>("prepare_media", { projectId }),
|
||||
runTranscription: (projectId: string) => call<ProjectSnapshot>("run_transcription", { projectId }),
|
||||
runAlignment: (projectId: string) => call<ProjectSnapshot>("run_alignment", { projectId }),
|
||||
saveAlignmentEdits: (projectId: string, edits: AlignmentEdit[]) =>
|
||||
call<ProjectSnapshot>("save_alignment_edits", { projectId, edits }),
|
||||
startPracticeSession: (input: StartPracticeSessionInput) =>
|
||||
call<PracticeSession>("start_practice_session", { input }),
|
||||
submitPracticeAttempt: (input: SubmitPracticeAttemptInput) =>
|
||||
call<SubmitPracticeAttemptResult>("submit_practice_attempt", { input }),
|
||||
listProviders: () => call<ProviderProfile[]>("list_providers"),
|
||||
setRoutingPolicy: (projectId: string, policy: RoutingPolicy) =>
|
||||
call<ProjectSnapshot>("set_routing_policy", { projectId, policy }),
|
||||
};
|
||||
387
src/lib/mock.ts
Normal file
387
src/lib/mock.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import type {
|
||||
AlignmentDocument,
|
||||
DashboardState,
|
||||
EvaluationArtifact,
|
||||
PracticeAttempt,
|
||||
PracticeSession,
|
||||
ProjectSnapshot,
|
||||
ProviderProfile,
|
||||
RoutingPolicy,
|
||||
ScriptDocument,
|
||||
SubmitPracticeAttemptInput,
|
||||
SubmitPracticeAttemptResult,
|
||||
TranscriptArtifact,
|
||||
WaveformDocument,
|
||||
} from "./types";
|
||||
|
||||
import type {
|
||||
ImportProjectInput,
|
||||
ProjectManifest,
|
||||
ProjectSummary,
|
||||
StartPracticeSessionInput,
|
||||
} from "./types";
|
||||
|
||||
const providers: ProviderProfile[] = [
|
||||
{
|
||||
id: "local-whisper-draft",
|
||||
label: "Local Draft Stack",
|
||||
transport: "local",
|
||||
capabilities: ["parse_script", "transcribe_audio", "align_script", "evaluate_attempt"],
|
||||
enabled: true,
|
||||
description: "Offline parser, draft transcript generator, and fuzzy alignment pipeline.",
|
||||
},
|
||||
{
|
||||
id: "remote-placeholder",
|
||||
label: "Remote Placeholder",
|
||||
transport: "remote",
|
||||
capabilities: ["transcribe_audio", "align_script", "evaluate_attempt"],
|
||||
enabled: false,
|
||||
description: "Reserved for future hosted speech or LLM APIs.",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultRoutingPolicy: RoutingPolicy = {
|
||||
transcriptionProvider: "local-whisper-draft",
|
||||
alignmentProvider: "local-whisper-draft",
|
||||
evaluationProvider: "local-whisper-draft",
|
||||
offlineOnly: true,
|
||||
};
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalize(text: string) {
|
||||
return text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function buildScript(scriptPath: string): ScriptDocument {
|
||||
const lines = [
|
||||
{ roleName: "Narrator", displayText: "The rehearsal room settles into silence.", scene: "Scene 1" },
|
||||
{ roleName: "Anna", displayText: "You always arrive before the others.", scene: "Scene 1" },
|
||||
{ roleName: "Jani", displayText: "Only when the line I fear is waiting for me.", scene: "Scene 1" },
|
||||
{ roleName: "Anna", displayText: "Then let us repeat it until fear turns into rhythm.", scene: "Scene 1" },
|
||||
{ roleName: "Jani", displayText: "Play the lead-in again and I will answer cleanly.", scene: "Scene 1" },
|
||||
{ roleName: "Narrator", displayText: "He listens, breathes, and steps into the text.", scene: "Scene 2" },
|
||||
];
|
||||
|
||||
const roles = Array.from(
|
||||
lines.reduce((map, line) => {
|
||||
if (!map.has(line.roleName)) {
|
||||
map.set(line.roleName, {
|
||||
id: `role-${map.size + 1}`,
|
||||
name: line.roleName,
|
||||
lineCount: 0,
|
||||
});
|
||||
}
|
||||
map.get(line.roleName)!.lineCount += 1;
|
||||
return map;
|
||||
}, new Map<string, { id: string; name: string; lineCount: number }>()),
|
||||
).map((entry) => entry[1]);
|
||||
|
||||
const roleByName = new Map(roles.map((role) => [role.name, role]));
|
||||
const scenes = ["Scene 1", "Scene 2"].map((heading, index) => ({
|
||||
id: `scene-${index + 1}`,
|
||||
heading,
|
||||
ordinal: index + 1,
|
||||
}));
|
||||
const sceneByHeading = new Map(scenes.map((scene) => [scene.heading, scene]));
|
||||
|
||||
return {
|
||||
sourcePath: scriptPath,
|
||||
parserWarnings: ["Demo mode is using a generated script draft."],
|
||||
roles,
|
||||
scenes,
|
||||
lines: lines.map((line, index) => ({
|
||||
id: `line-${index + 1}`,
|
||||
sceneId: sceneByHeading.get(line.scene)!.id,
|
||||
roleId: roleByName.get(line.roleName)?.id ?? null,
|
||||
roleName: line.roleName,
|
||||
displayText: line.displayText,
|
||||
normalizedText: normalize(line.displayText),
|
||||
ordinal: index + 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTranscript(script: ScriptDocument, totalDurationMs: number): TranscriptArtifact {
|
||||
const span = Math.max(2500, Math.floor(totalDurationMs / Math.max(script.lines.length, 1)));
|
||||
const segments = script.lines.map((line, index) => {
|
||||
const startMs = index * span;
|
||||
return {
|
||||
id: `segment-${index + 1}`,
|
||||
startMs,
|
||||
endMs: Math.min(startMs + Math.floor(span * 0.88), totalDurationMs),
|
||||
text: line.displayText,
|
||||
confidence: 0.52,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: "transcript-demo",
|
||||
providerRun: {
|
||||
id: "provider-run-demo-transcript",
|
||||
capability: "transcribe_audio",
|
||||
providerId: "local-whisper-draft",
|
||||
providerVersion: "mock-1",
|
||||
createdAt: nowIso(),
|
||||
},
|
||||
segments,
|
||||
warnings: ["Generated transcript because the app is running in browser mock mode."],
|
||||
};
|
||||
}
|
||||
|
||||
function buildAlignment(script: ScriptDocument, transcript: TranscriptArtifact): AlignmentDocument {
|
||||
return {
|
||||
providerRun: {
|
||||
id: "provider-run-demo-align",
|
||||
capability: "align_script",
|
||||
providerId: "local-whisper-draft",
|
||||
providerVersion: "mock-1",
|
||||
createdAt: nowIso(),
|
||||
},
|
||||
unresolvedLineIds: [],
|
||||
cues: script.lines.map((line, index) => {
|
||||
const segment = transcript.segments[index];
|
||||
return {
|
||||
lineId: line.id,
|
||||
startMs: segment?.startMs ?? index * 4000,
|
||||
endMs: segment?.endMs ?? index * 4000 + 3200,
|
||||
status: "auto" as const,
|
||||
confidence: 0.69,
|
||||
transcriptSpanIds: segment ? [segment.id] : [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWaveform(): WaveformDocument {
|
||||
const peaks = Array.from({ length: 240 }, (_, index) => {
|
||||
const x = index / 24;
|
||||
return Math.abs(Math.sin(x) * 0.7 + Math.cos(x * 0.63) * 0.25);
|
||||
});
|
||||
|
||||
return {
|
||||
peakCount: peaks.length,
|
||||
peaks,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary(project: ProjectSnapshot): ProjectSummary {
|
||||
return {
|
||||
id: project.project.id,
|
||||
name: project.project.name,
|
||||
language: project.project.language,
|
||||
status: project.project.status,
|
||||
createdAt: project.project.createdAt,
|
||||
totalDurationMs: project.project.totalDurationMs,
|
||||
roleCount: project.script?.roles.length ?? 0,
|
||||
lineCount: project.script?.lines.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createProject(input: ImportProjectInput): ProjectSnapshot {
|
||||
const projectId = `mock-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const durationPerTrack = 47000;
|
||||
const audioAssets = input.audioPaths.map((path, index) => {
|
||||
const pathParts = path.split(/[\\/]/);
|
||||
return {
|
||||
id: `asset-${index + 1}`,
|
||||
label: pathParts[pathParts.length - 1] ?? `Track ${index + 1}`,
|
||||
originalPath: path,
|
||||
durationMs: durationPerTrack,
|
||||
startOffsetMs: index * durationPerTrack,
|
||||
};
|
||||
});
|
||||
const project: ProjectManifest = {
|
||||
id: projectId,
|
||||
name: input.name,
|
||||
language: input.language,
|
||||
createdAt: nowIso(),
|
||||
status: "draft",
|
||||
scriptPath: input.scriptPath,
|
||||
mergedAudioPath: audioAssets[0]?.originalPath ?? null,
|
||||
waveformPath: null,
|
||||
totalDurationMs: audioAssets.length * durationPerTrack,
|
||||
audioAssets,
|
||||
routingPolicy: defaultRoutingPolicy,
|
||||
};
|
||||
const script = buildScript(input.scriptPath);
|
||||
const transcript = buildTranscript(script, project.totalDurationMs);
|
||||
const alignment = buildAlignment(script, transcript);
|
||||
|
||||
return {
|
||||
project,
|
||||
script,
|
||||
alignment,
|
||||
transcript,
|
||||
waveform: buildWaveform(),
|
||||
practiceSessions: [],
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
let projects: ProjectSnapshot[] = [
|
||||
createProject({
|
||||
name: "Demo Rehearsal Cut",
|
||||
scriptPath: "/sample-data/demo-script.txt",
|
||||
audioPaths: ["/sample-data/demo-take-1.mp3", "/sample-data/demo-take-2.mp3"],
|
||||
language: "hu-HU",
|
||||
}),
|
||||
];
|
||||
|
||||
function findProject(projectId: string) {
|
||||
const project = projects.find((entry) => entry.project.id === projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Unknown mock project: ${projectId}`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
function tokenize(text: string) {
|
||||
return normalize(text).split(" ").filter(Boolean);
|
||||
}
|
||||
|
||||
function evaluateAttempt(project: ProjectSnapshot, input: SubmitPracticeAttemptInput): SubmitPracticeAttemptResult {
|
||||
const line = project.script?.lines.find((entry) => entry.id === input.lineId);
|
||||
const expectedText = line?.displayText ?? "";
|
||||
const recognizedText = input.recognizedTextOverride?.trim() || "No transcript provided.";
|
||||
const expected = tokenize(expectedText);
|
||||
const received = tokenize(recognizedText);
|
||||
const expectedSet = new Set(expected);
|
||||
const receivedSet = new Set(received);
|
||||
const missingWords = expected.filter((token) => !receivedSet.has(token));
|
||||
const extraWords = received.filter((token) => !expectedSet.has(token));
|
||||
const matches = expected.filter((token) => receivedSet.has(token)).length;
|
||||
const score = expected.length ? matches / expected.length : 0;
|
||||
const passed = score >= 0.88;
|
||||
|
||||
const evaluation: EvaluationArtifact = {
|
||||
id: `evaluation-${Math.random().toString(36).slice(2, 9)}`,
|
||||
providerRun: {
|
||||
id: "provider-run-demo-eval",
|
||||
capability: "evaluate_attempt",
|
||||
providerId: "local-whisper-draft",
|
||||
providerVersion: "mock-1",
|
||||
createdAt: nowIso(),
|
||||
},
|
||||
expectedText,
|
||||
recognizedText,
|
||||
missingWords,
|
||||
extraWords,
|
||||
score,
|
||||
passed,
|
||||
warnings: ["Practice evaluation is using text override in browser mock mode."],
|
||||
};
|
||||
|
||||
const attempt: PracticeAttempt = {
|
||||
id: `attempt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
lineId: input.lineId,
|
||||
recognizedText,
|
||||
score,
|
||||
passed,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
const session = project.practiceSessions.find((entry) => entry.id === input.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Unknown practice session: ${input.sessionId}`);
|
||||
}
|
||||
session.attempts = [attempt, ...session.attempts];
|
||||
|
||||
return {
|
||||
evaluation,
|
||||
attempts: session.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockApi = {
|
||||
async getDashboardState(): Promise<DashboardState> {
|
||||
return {
|
||||
workspaceRoot: "browser-preview",
|
||||
projects: projects.map(buildSummary),
|
||||
providers,
|
||||
};
|
||||
},
|
||||
|
||||
async importProject(input: ImportProjectInput): Promise<ProjectSnapshot> {
|
||||
const project = createProject(input);
|
||||
projects = [project, ...projects];
|
||||
return project;
|
||||
},
|
||||
|
||||
async getProjectState(projectId: string): Promise<ProjectSnapshot> {
|
||||
return findProject(projectId);
|
||||
},
|
||||
|
||||
async prepareMedia(projectId: string): Promise<ProjectSnapshot> {
|
||||
const project = findProject(projectId);
|
||||
project.project.status = "media_ready";
|
||||
project.project.waveformPath = `${project.project.id}/waveform.json`;
|
||||
return project;
|
||||
},
|
||||
|
||||
async runTranscription(projectId: string): Promise<ProjectSnapshot> {
|
||||
const project = findProject(projectId);
|
||||
if (project.script) {
|
||||
project.transcript = buildTranscript(project.script, project.project.totalDurationMs);
|
||||
project.project.status = "transcribed";
|
||||
}
|
||||
return project;
|
||||
},
|
||||
|
||||
async runAlignment(projectId: string): Promise<ProjectSnapshot> {
|
||||
const project = findProject(projectId);
|
||||
if (project.script && project.transcript) {
|
||||
project.alignment = buildAlignment(project.script, project.transcript);
|
||||
project.project.status = "aligned";
|
||||
}
|
||||
return project;
|
||||
},
|
||||
|
||||
async saveAlignmentEdits(projectId: string, edits: { lineId: string; startMs: number; endMs: number }[]): Promise<ProjectSnapshot> {
|
||||
const project = findProject(projectId);
|
||||
if (project.alignment) {
|
||||
project.alignment.cues = project.alignment.cues.map((cue) => {
|
||||
const edit = edits.find((item) => item.lineId === cue.lineId);
|
||||
if (!edit) {
|
||||
return cue;
|
||||
}
|
||||
return {
|
||||
...cue,
|
||||
startMs: edit.startMs,
|
||||
endMs: edit.endMs,
|
||||
status: "manual",
|
||||
};
|
||||
});
|
||||
}
|
||||
return project;
|
||||
},
|
||||
|
||||
async startPracticeSession(input: StartPracticeSessionInput): Promise<PracticeSession> {
|
||||
const project = findProject(input.projectId);
|
||||
const session: PracticeSession = {
|
||||
id: `session-${Math.random().toString(36).slice(2, 9)}`,
|
||||
roleId: input.roleId,
|
||||
startedAt: nowIso(),
|
||||
attempts: [],
|
||||
};
|
||||
project.practiceSessions = [session, ...project.practiceSessions];
|
||||
return session;
|
||||
},
|
||||
|
||||
async submitPracticeAttempt(input: SubmitPracticeAttemptInput): Promise<SubmitPracticeAttemptResult> {
|
||||
const project = findProject(input.projectId);
|
||||
return evaluateAttempt(project, input);
|
||||
},
|
||||
|
||||
async listProviders(): Promise<ProviderProfile[]> {
|
||||
return providers;
|
||||
},
|
||||
|
||||
async setRoutingPolicy(projectId: string, policy: RoutingPolicy): Promise<ProjectSnapshot> {
|
||||
const project = findProject(projectId);
|
||||
project.project.routingPolicy = policy;
|
||||
return project;
|
||||
},
|
||||
};
|
||||
196
src/lib/types.ts
Normal file
196
src/lib/types.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
export interface ProviderProfile {
|
||||
id: string;
|
||||
label: string;
|
||||
transport: "local" | "remote" | "hybrid";
|
||||
capabilities: string[];
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RoutingPolicy {
|
||||
transcriptionProvider: string;
|
||||
alignmentProvider: string;
|
||||
evaluationProvider: string;
|
||||
offlineOnly: boolean;
|
||||
}
|
||||
|
||||
export interface AudioAsset {
|
||||
id: string;
|
||||
label: string;
|
||||
originalPath: string;
|
||||
durationMs: number;
|
||||
startOffsetMs: number;
|
||||
}
|
||||
|
||||
export interface ProjectManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
scriptPath: string;
|
||||
mergedAudioPath: string | null;
|
||||
waveformPath: string | null;
|
||||
totalDurationMs: number;
|
||||
audioAssets: AudioAsset[];
|
||||
routingPolicy: RoutingPolicy;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
export interface ScriptLine {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
roleId: string | null;
|
||||
roleName: string | null;
|
||||
displayText: string;
|
||||
normalizedText: string;
|
||||
ordinal: number;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id: string;
|
||||
heading: string;
|
||||
ordinal: number;
|
||||
}
|
||||
|
||||
export interface ScriptDocument {
|
||||
sourcePath: string;
|
||||
parserWarnings: string[];
|
||||
roles: Role[];
|
||||
scenes: Scene[];
|
||||
lines: ScriptLine[];
|
||||
}
|
||||
|
||||
export interface TranscriptSegment {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface ProviderRun {
|
||||
id: string;
|
||||
capability: string;
|
||||
providerId: string;
|
||||
providerVersion: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TranscriptArtifact {
|
||||
id: string;
|
||||
providerRun: ProviderRun;
|
||||
segments: TranscriptSegment[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface AlignmentCue {
|
||||
lineId: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
status: "auto" | "manual" | "adjusted" | "unmatched";
|
||||
confidence: number;
|
||||
transcriptSpanIds: string[];
|
||||
}
|
||||
|
||||
export interface AlignmentDocument {
|
||||
providerRun: ProviderRun | null;
|
||||
unresolvedLineIds: string[];
|
||||
cues: AlignmentCue[];
|
||||
}
|
||||
|
||||
export interface WaveformDocument {
|
||||
peakCount: number;
|
||||
peaks: number[];
|
||||
}
|
||||
|
||||
export interface PracticeAttempt {
|
||||
id: string;
|
||||
lineId: string;
|
||||
recognizedText: string;
|
||||
score: number;
|
||||
passed: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PracticeSession {
|
||||
id: string;
|
||||
roleId: string;
|
||||
startedAt: string;
|
||||
attempts: PracticeAttempt[];
|
||||
}
|
||||
|
||||
export interface EvaluationArtifact {
|
||||
id: string;
|
||||
providerRun: ProviderRun;
|
||||
expectedText: string;
|
||||
recognizedText: string;
|
||||
missingWords: string[];
|
||||
extraWords: string[];
|
||||
score: number;
|
||||
passed: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ProjectSnapshot {
|
||||
project: ProjectManifest;
|
||||
script: ScriptDocument | null;
|
||||
alignment: AlignmentDocument | null;
|
||||
transcript: TranscriptArtifact | null;
|
||||
waveform: WaveformDocument | null;
|
||||
practiceSessions: PracticeSession[];
|
||||
providers: ProviderProfile[];
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
totalDurationMs: number;
|
||||
roleCount: number;
|
||||
lineCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardState {
|
||||
workspaceRoot: string;
|
||||
projects: ProjectSummary[];
|
||||
providers: ProviderProfile[];
|
||||
}
|
||||
|
||||
export interface ImportProjectInput {
|
||||
name: string;
|
||||
scriptPath: string;
|
||||
audioPaths: string[];
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface AlignmentEdit {
|
||||
lineId: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export interface StartPracticeSessionInput {
|
||||
projectId: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export interface SubmitPracticeAttemptInput {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
lineId: string;
|
||||
recordingPath?: string | null;
|
||||
recognizedTextOverride?: string | null;
|
||||
}
|
||||
|
||||
export interface SubmitPracticeAttemptResult {
|
||||
evaluation: EvaluationArtifact;
|
||||
attempts: PracticeAttempt[];
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
408
src/styles.css
Normal file
408
src/styles.css
Normal file
@@ -0,0 +1,408 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink-950: #111319;
|
||||
--ink-900: #1c2230;
|
||||
--ink-800: #293145;
|
||||
--paper: #f4efe6;
|
||||
--paper-strong: #fffaf4;
|
||||
--amber: #f4a53a;
|
||||
--amber-soft: rgba(244, 165, 58, 0.16);
|
||||
--teal: #3baea0;
|
||||
--rose: #c4536f;
|
||||
--line: rgba(20, 26, 39, 0.12);
|
||||
--shadow: 0 24px 60px rgba(17, 19, 25, 0.18);
|
||||
font-family: "IBM Plex Sans", "Aptos", "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(244, 165, 58, 0.28), transparent 36%),
|
||||
radial-gradient(circle at top right, rgba(59, 174, 160, 0.18), transparent 32%),
|
||||
linear-gradient(180deg, #f7f1e8 0%, #ece5dc 100%);
|
||||
color: var(--ink-950);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app-chrome {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(17, 19, 25, 0.95), rgba(32, 41, 58, 0.92)),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0));
|
||||
color: var(--paper);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-panel h1 {
|
||||
margin: 6px 0 0;
|
||||
font-family: "Fraunces", "Iowan Old Style", serif;
|
||||
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||
line-height: 0.98;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.hero-panel p {
|
||||
margin: 0;
|
||||
max-width: 76ch;
|
||||
color: rgba(255, 250, 244, 0.84);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.75rem;
|
||||
color: inherit;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.left-rail,
|
||||
.content-rail {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 250, 244, 0.88);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 22px;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel-header h2,
|
||||
.summary-panel h2,
|
||||
.empty-panel h2 {
|
||||
margin: 4px 0 0;
|
||||
font-family: "Fraunces", "Iowan Old Style", serif;
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.88rem;
|
||||
color: var(--ink-800);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
color: var(--ink-950);
|
||||
}
|
||||
|
||||
.compact-field input,
|
||||
.compact-field select {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.action-strip button,
|
||||
.project-card,
|
||||
.practice-line {
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.action-strip button {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, var(--amber), #ffca6b);
|
||||
color: var(--ink-950);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.action-strip button:not(.primary-button) {
|
||||
background: var(--amber-soft);
|
||||
}
|
||||
|
||||
.primary-button:hover,
|
||||
.action-strip button:hover,
|
||||
.project-card:hover,
|
||||
.practice-line:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 24px rgba(17, 19, 25, 0.1);
|
||||
}
|
||||
|
||||
.project-list,
|
||||
.script-table,
|
||||
.provider-list,
|
||||
.practice-line-list,
|
||||
.sync-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.project-card.active {
|
||||
border-color: rgba(244, 165, 58, 0.7);
|
||||
background: linear-gradient(180deg, rgba(244, 165, 58, 0.14), rgba(255, 255, 255, 0.88));
|
||||
}
|
||||
|
||||
.project-card-topline,
|
||||
.project-card-meta,
|
||||
.provider-card > div,
|
||||
.script-line-meta,
|
||||
.sync-row-topline,
|
||||
.practice-grid,
|
||||
.reader-topbar,
|
||||
.summary-grid,
|
||||
.action-strip,
|
||||
.cue-inputs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-pill,
|
||||
.source-pill {
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(17, 19, 25, 0.08);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.status-manual,
|
||||
.status-adjusted {
|
||||
background: rgba(59, 174, 160, 0.16);
|
||||
}
|
||||
|
||||
.status-unmatched {
|
||||
background: rgba(196, 83, 111, 0.16);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.summary-grid article {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(17, 19, 25, 0.04);
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
font-size: 1.6rem;
|
||||
font-family: "Fraunces", "Iowan Old Style", serif;
|
||||
}
|
||||
|
||||
.provider-grid,
|
||||
.two-column {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.provider-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17, 19, 25, 0.04);
|
||||
}
|
||||
|
||||
.provider-card.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.toggle-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.two-column {
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
||||
}
|
||||
|
||||
.waveform-shell {
|
||||
height: 132px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(17, 19, 25, 0.08), rgba(17, 19, 25, 0.02)),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.22) 0,
|
||||
rgba(255, 255, 255, 0.22) 1px,
|
||||
transparent 1px,
|
||||
transparent 16px
|
||||
);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: var(--teal);
|
||||
stroke-width: 0.9;
|
||||
}
|
||||
|
||||
.waveform-empty,
|
||||
.error-banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17, 19, 25, 0.06);
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
margin: 16px 0 20px;
|
||||
}
|
||||
|
||||
.script-line,
|
||||
.sync-row,
|
||||
.practice-line {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(17, 19, 25, 0.06);
|
||||
}
|
||||
|
||||
.script-line p,
|
||||
.sync-row p,
|
||||
.provider-card p,
|
||||
.practice-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.practice-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.practice-line {
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.practice-line.active {
|
||||
border-color: rgba(59, 174, 160, 0.6);
|
||||
background: rgba(59, 174, 160, 0.12);
|
||||
}
|
||||
|
||||
.evaluation-card {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.evaluation-card.pass {
|
||||
background: rgba(59, 174, 160, 0.14);
|
||||
}
|
||||
|
||||
.evaluation-card.fail {
|
||||
background: rgba(196, 83, 111, 0.14);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: rgba(196, 83, 111, 0.16);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.layout-grid,
|
||||
.two-column,
|
||||
.provider-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.panel {
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2021"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
9
tsconfig.node.json
Normal file
9
tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
vite.config.ts
Normal file
11
vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
});
|
||||
Reference in New Issue
Block a user