first round

This commit is contained in:
2026-06-09 14:20:20 +02:00
commit 346c897e18
55 changed files with 15453 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.venv
.rehearsal-data
src-tauri/target

31
README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1 @@
"""Provider runtime package for Rehearsal Studio."""

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
faster-whisper>=1.1.0
RapidFuzz>=3.10.1
python-docx>=1.1.2
PyMuPDF>=1.24.13

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

View 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

File diff suppressed because it is too large Load Diff

19
src-tauri/Cargo.toml Normal file
View 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
View 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()
}

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability for the main window.","local":true,"windows":["main"],"permissions":["core:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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
View 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
View 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, &current.status, None, None, None, Some(policy))?;
state.build_snapshot(project_id)
}

200
src-tauri/src/db.rs Normal file
View 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
View 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
View 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
View 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
View 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,
})
}

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View 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_"],
});