Phase 1
@@ -118,9 +118,10 @@ The "customer asks for a feature, get started right away" path, **pull** model:
|
|||||||
|
|
||||||
- **UI/IPC thread:** Tauri commands return quickly. Nothing that runs for the length of a meeting
|
- **UI/IPC thread:** Tauri commands return quickly. Nothing that runs for the length of a meeting
|
||||||
blocks a command — long work is spawned and reports via **events**.
|
blocks a command — long work is spawned and reports via **events**.
|
||||||
- **Audio thread:** a dedicated thread runs the WASAPI **polling** capture loop. (WASAPI loopback
|
- **Audio thread:** a dedicated thread runs the WASAPI capture loop, waiting on the WASAPI event
|
||||||
cannot use event-callback mode, so we poll with a small interval — see `07-research-findings.md`.)
|
handle (short timeout so it can also notice a stop/pause request) rather than busy-polling —
|
||||||
It only does capture + disk write + ring-buffer push; no inference.
|
see `07-research-findings.md` for why the earlier "polling only" note was corrected. It only
|
||||||
|
does capture + disk write + ring-buffer push; no inference.
|
||||||
- **Inference worker(s):** transcription runs on its own worker, pulling audio windows from the
|
- **Inference worker(s):** transcription runs on its own worker, pulling audio windows from the
|
||||||
ring buffer; diarization runs as a discrete post-stop job. Backends are constructed once per
|
ring buffer; diarization runs as a discrete post-stop job. Backends are constructed once per
|
||||||
session from the `hardware` ranking.
|
session from the `hardware` ranking.
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ low-overhead NFRs. Pairs cleanly with Tauri via Vite. → **ADR-0002.**
|
|||||||
|
|
||||||
### Audio capture — WASAPI loopback in Rust ✅
|
### Audio capture — WASAPI loopback in Rust ✅
|
||||||
The `wasapi` crate provides safe Rust access to WASAPI including a **loopback** example
|
The `wasapi` crate provides safe Rust access to WASAPI including a **loopback** example
|
||||||
(capture system output without a meeting bot). `cpal` is the cross-platform fallback. Known
|
(capture system output without a meeting bot). `cpal` is the cross-platform fallback.
|
||||||
constraint: `AUDCLNT_STREAMFLAGS_LOOPBACK` and `AUDCLNT_STREAMFLAGS_EVENTCALLBACK` cannot be
|
**Correction (Phase 1 implementation, verified against `wasapi` v0.15 source and current
|
||||||
combined, so the capture loop must **poll** rather than rely on event callbacks. This shapes
|
Windows):** `AUDCLNT_STREAMFLAGS_LOOPBACK` and `AUDCLNT_STREAMFLAGS_EVENTCALLBACK` **can** be
|
||||||
the audio thread design in `docs/02-architecture.md`. → **ADR-0001 / architecture.**
|
combined — the crate wires both flags together for a render-device-opened-as-capture client, and
|
||||||
|
the capture loop waits on the WASAPI event handle (with a short timeout so it can also notice a
|
||||||
|
stop/pause request) rather than busy-polling. The pre-implementation note below describing a
|
||||||
|
polling-only design was inaccurate; `docs/02-architecture.md`'s "Audio thread" section has been
|
||||||
|
corrected to match.
|
||||||
|
|
||||||
### Transcription — whisper-rs (+ ONNX for NPU) ✅
|
### Transcription — whisper-rs (+ ONNX for NPU) ✅
|
||||||
`whisper-rs` wraps **whisper.cpp** with feature-gated acceleration: **CUDA** (NVIDIA),
|
`whisper-rs` wraps **whisper.cpp** with feature-gated acceleration: **CUDA** (NVIDIA),
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import svelte from "eslint-plugin-svelte";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.svelte"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,ts}"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["scripts/**/*.mjs"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ["dist/", "src-tauri/", "node_modules/"],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -19,11 +19,15 @@
|
|||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-plugin-svelte": "^3.20.0",
|
||||||
|
"globals": "^17.7.0",
|
||||||
"prettier": "^3.3.0",
|
"prettier": "^3.3.0",
|
||||||
"prettier-plugin-svelte": "^3.2.0",
|
"prettier-plugin-svelte": "^3.2.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
"svelte-eslint-parser": "^1.8.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
|
"typescript-eslint": "^8.62.1",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
// Dev helper (placeholder) — download default models into %LOCALAPPDATA%\WhispAssist\models.
|
// Dev helper — download the Phase 1 CPU whisper model into
|
||||||
// In the app, model download is a Phase 3 in-product flow (FR-MODEL-1) with progress events;
|
// %LOCALAPPDATA%\WhispAssist\models. In-product model management (choose size,
|
||||||
// this script is only for setting up a dev machine quickly.
|
// download/remove with progress) is Phase 3 (FR-MODEL-1); this script only gets
|
||||||
|
// a dev machine to a working `whisper-rs` baseline quickly.
|
||||||
//
|
//
|
||||||
// Intended models (pin URLs/checksums at implementation time):
|
// Usage: node scripts/download-models.mjs
|
||||||
// - Whisper: base (q5) GGUF for whisper.cpp
|
|
||||||
// - Diarization: pyannote segmentation (ONNX) + ERes2Net speaker embedding (ONNX)
|
|
||||||
//
|
|
||||||
// Usage (once implemented): node scripts/download-models.mjs --set default
|
|
||||||
|
|
||||||
console.log("TODO: implement model download (Phase 3). See docs/05-roadmap.md T3.7.");
|
import { createWriteStream, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en-q5_1.bin";
|
||||||
|
const MODEL_NAME = "ggml-base.en-q5_1.bin";
|
||||||
|
|
||||||
|
const modelsDir = path.join(process.env.LOCALAPPDATA ?? process.cwd(), "WhispAssist", "models");
|
||||||
|
const dest = path.join(modelsDir, MODEL_NAME);
|
||||||
|
|
||||||
|
if (existsSync(dest)) {
|
||||||
|
console.log(`Already have ${dest}`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(modelsDir, { recursive: true });
|
||||||
|
console.log(`Downloading ${MODEL_URL} -> ${dest}`);
|
||||||
|
|
||||||
|
const res = await fetch(MODEL_URL, { redirect: "follow" });
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
console.error(`Download failed: HTTP ${res.status}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = `${dest}.part`;
|
||||||
|
await pipeline(res.body, createWriteStream(tmp));
|
||||||
|
const { renameSync } = await import("node:fs");
|
||||||
|
renameSync(tmp, dest);
|
||||||
|
console.log("Done.");
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
# storage
|
# storage
|
||||||
@@ -41,6 +41,7 @@ rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP serve
|
|||||||
# audio / transcription / diarization / calendar are integrated per-phase and are
|
# audio / transcription / diarization / calendar are integrated per-phase and are
|
||||||
# feature-gated so the CPU-only build always compiles (NFR-MNT-4).
|
# feature-gated so the CPU-only build always compiles (NFR-MNT-4).
|
||||||
hound = { version = "3", optional = true } # WAV I/O (Phase 1)
|
hound = { version = "3", optional = true } # WAV I/O (Phase 1)
|
||||||
|
whisper-rs = { version = "0.16", optional = true } # whisper.cpp bindings (Phase 1)
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.58", features = [
|
windows = { version = "0.58", features = [
|
||||||
@@ -54,7 +55,7 @@ wasapi = { version = "0.15", optional = true } # Phase 1
|
|||||||
default = ["audio", "cpu-transcription"]
|
default = ["audio", "cpu-transcription"]
|
||||||
# Phase 1
|
# Phase 1
|
||||||
audio = ["dep:wasapi", "dep:hound"]
|
audio = ["dep:wasapi", "dep:hound"]
|
||||||
cpu-transcription = [] # enables whisper-rs CPU build (add dep when wired in Phase 1)
|
cpu-transcription = ["dep:whisper-rs"] # whisper-rs CPU build
|
||||||
# Phase 3 acceleration (built on capable CI runners; never required)
|
# Phase 3 acceleration (built on capable CI runners; never required)
|
||||||
cuda = [] # whisper.cpp CUDA
|
cuda = [] # whisper.cpp CUDA
|
||||||
vulkan = [] # whisper.cpp Vulkan
|
vulkan = [] # whisper.cpp Vulkan
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,7 +1,4 @@
|
|||||||
# Icons
|
# Icons
|
||||||
|
|
||||||
Placeholder. Add before first bundle:
|
Placeholder icon set (Phase 1) generated with `npm run tauri icon`. Replace `icon.ico`/`tray.png`
|
||||||
- `icon.ico` — app/installer icon (multi-resolution).
|
(and re-run `npm run tauri icon path/to/source.png`) with real branding before shipping.
|
||||||
- `tray.png` — system tray icon (referenced by `tauri.conf.json`).
|
|
||||||
|
|
||||||
Generate the full icon set from a single source with `npm run tauri icon path/to/source.png`.
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -32,13 +32,22 @@ pub struct RunOutcome {
|
|||||||
/// Spawns a local coding-agent CLI headless against a repo (claude -p / codex exec / opencode run / copilot).
|
/// Spawns a local coding-agent CLI headless against a repo (claude -p / codex exec / opencode run / copilot).
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AgentRunner: Send + Sync {
|
pub trait AgentRunner: Send + Sync {
|
||||||
async fn run(&self, brief: &FeatureBrief, repo: &Path, out: LineSink) -> Result<RunOutcome, AgentError>;
|
async fn run(
|
||||||
|
&self,
|
||||||
|
brief: &FeatureBrief,
|
||||||
|
repo: &Path,
|
||||||
|
out: LineSink,
|
||||||
|
) -> Result<RunOutcome, AgentError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a work item from a brief (e.g. a GitHub issue, optionally assigned to Copilot's cloud agent).
|
/// Creates a work item from a brief (e.g. a GitHub issue, optionally assigned to Copilot's cloud agent).
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait IssueTracker: Send + Sync {
|
pub trait IssueTracker: Send + Sync {
|
||||||
async fn create_issue(&self, brief: &FeatureBrief, assign_copilot: bool) -> Result<String /* url */, AgentError>;
|
async fn create_issue(
|
||||||
|
&self,
|
||||||
|
brief: &FeatureBrief,
|
||||||
|
assign_copilot: bool,
|
||||||
|
) -> Result<String /* url */, AgentError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concrete impls (CliAgentRunner, GitHubIssueTracker) are added in Phase 10c behind a feature flag.
|
// Concrete impls (CliAgentRunner, GitHubIssueTracker) are added in Phase 10c behind a feature flag.
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
//! Audio capture service — WASAPI loopback (Phase 1, FR-CAP-1/2).
|
//! Audio capture service — WASAPI loopback (Phase 1, FR-CAP-1/2).
|
||||||
//!
|
//!
|
||||||
//! Design notes (see docs/02-architecture.md):
|
//! Design notes (see docs/02-architecture.md):
|
||||||
//! - WASAPI loopback CANNOT use event-callback mode, so capture POLLS on a
|
//! - Loopback capture opens the default **render** device and initializes it for
|
||||||
//! dedicated thread (research finding, docs/07).
|
//! `Direction::Capture` (see `initialize_client` below). The `wasapi` crate wires
|
||||||
//! - Writes PCM to disk continuously (audio = source of truth) AND pushes frames
|
//! `AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_LOOPBACK` together for
|
||||||
//! into a bounded ring buffer consumed by the transcription worker.
|
//! this combination and it works correctly on current Windows — the polling-only
|
||||||
|
//! claim in `docs/07-research-findings.md` was written before verifying against the
|
||||||
|
//! crate's actual source and doesn't hold for this dependency; the capture loop
|
||||||
|
//! below waits on the WASAPI event handle (with a short timeout so it can also
|
||||||
|
//! notice a stop/pause request) rather than busy-polling.
|
||||||
|
//! - Writes PCM to disk continuously, in the device's native format, so `audio.wav`
|
||||||
|
//! is a byte-accurate capture (audio = source of truth). A downmixed/resampled
|
||||||
|
//! 16kHz-mono copy is pushed into the bounded `FrameSink` for live transcription;
|
||||||
|
//! a full consumer drops preview frames (`try_send`) without blocking the disk
|
||||||
|
//! write or the capture thread.
|
||||||
//! - Does no inference itself.
|
//! - Does no inference itself.
|
||||||
|
|
||||||
|
use hound::{SampleFormat, WavSpec, WavWriter};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufWriter;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc::SyncSender;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use wasapi::{Direction, SampleType, ShareMode, WaveFormat};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AudioError {
|
pub enum AudioError {
|
||||||
@@ -20,10 +37,15 @@ pub enum AudioError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque handle to a running capture, returned by `start` and consumed by `stop`.
|
/// Opaque handle to a running capture, returned by `start` and consumed by `stop`.
|
||||||
pub struct CaptureHandle;
|
pub struct CaptureHandle {
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
paused: Arc<AtomicBool>,
|
||||||
|
thread: JoinHandle<Result<CaptureSummary, AudioError>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Where captured frames are delivered for live transcription.
|
/// Where captured frames are delivered for live transcription: mono f32 @ 16kHz,
|
||||||
pub type FrameSink = std::sync::mpsc::Sender<Vec<f32>>;
|
/// bounded so a slow/absent consumer can never stall the capture thread.
|
||||||
|
pub type FrameSink = SyncSender<Vec<f32>>;
|
||||||
|
|
||||||
pub struct CaptureSummary {
|
pub struct CaptureSummary {
|
||||||
pub duration_ms: u64,
|
pub duration_ms: u64,
|
||||||
@@ -44,18 +66,358 @@ pub struct WasapiCapture;
|
|||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
impl AudioCapture for WasapiCapture {
|
impl AudioCapture for WasapiCapture {
|
||||||
fn start(&self, _wav_path: &Path, _sink: FrameSink) -> Result<CaptureHandle, AudioError> {
|
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError> {
|
||||||
// T1.2: open default render device in loopback mode, spawn polling thread,
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
// write WAV via `hound`, push frames to `sink`.
|
let paused = Arc::new(AtomicBool::new(false));
|
||||||
todo!("Phase 1 — WASAPI loopback capture")
|
let running_th = running.clone();
|
||||||
|
let paused_th = paused.clone();
|
||||||
|
let wav_path = wav_path.to_path_buf();
|
||||||
|
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("wa-audio-capture".into())
|
||||||
|
.spawn(move || capture_loop(&wav_path, &sink, &running_th, &paused_th))
|
||||||
|
.map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?;
|
||||||
|
|
||||||
|
Ok(CaptureHandle {
|
||||||
|
running,
|
||||||
|
paused,
|
||||||
|
thread,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
fn pause(&self, _h: &CaptureHandle) -> Result<(), AudioError> {
|
|
||||||
todo!("Phase 1 — pause capture")
|
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError> {
|
||||||
|
h.paused.store(true, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
fn resume(&self, _h: &CaptureHandle) -> Result<(), AudioError> {
|
|
||||||
todo!("Phase 1 — resume capture")
|
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError> {
|
||||||
|
h.paused.store(false, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
fn stop(&self, _h: CaptureHandle) -> Result<CaptureSummary, AudioError> {
|
|
||||||
todo!("Phase 1 — stop capture, finalize WAV")
|
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError> {
|
||||||
|
h.running.store(false, Ordering::SeqCst);
|
||||||
|
h.thread
|
||||||
|
.join()
|
||||||
|
.map_err(|_| AudioError::Capture("capture thread panicked".into()))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs on a dedicated OS thread for the lifetime of a `CaptureHandle`. Owns the
|
||||||
|
/// WASAPI client and the WAV writer; exits (and finalizes the WAV) once `running`
|
||||||
|
/// is cleared.
|
||||||
|
#[cfg(feature = "audio")]
|
||||||
|
fn capture_loop(
|
||||||
|
wav_path: &Path,
|
||||||
|
sink: &FrameSink,
|
||||||
|
running: &AtomicBool,
|
||||||
|
paused: &AtomicBool,
|
||||||
|
) -> Result<CaptureSummary, AudioError> {
|
||||||
|
wasapi::initialize_mta()
|
||||||
|
.ok()
|
||||||
|
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
|
||||||
|
|
||||||
|
let device = wasapi::get_default_device(&Direction::Render)
|
||||||
|
.map_err(|e| AudioError::Device(format!("no default render device: {e}")))?;
|
||||||
|
let mut audio_client = device
|
||||||
|
.get_iaudioclient()
|
||||||
|
.map_err(|e| AudioError::Device(format!("activate IAudioClient failed: {e}")))?;
|
||||||
|
let format = audio_client
|
||||||
|
.get_mixformat()
|
||||||
|
.map_err(|e| AudioError::Device(format!("GetMixFormat failed: {e}")))?;
|
||||||
|
let (_default_period, min_period) = audio_client
|
||||||
|
.get_periods()
|
||||||
|
.map_err(|e| AudioError::Device(format!("GetDevicePeriod failed: {e}")))?;
|
||||||
|
|
||||||
|
// `convert = false`: we use the device's own mix format, so no on-the-fly
|
||||||
|
// resampling/conversion is needed for a byte-accurate capture.
|
||||||
|
audio_client
|
||||||
|
.initialize_client(
|
||||||
|
&format,
|
||||||
|
min_period,
|
||||||
|
&Direction::Capture,
|
||||||
|
&ShareMode::Shared,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Capture(format!("Initialize failed: {e}")))?;
|
||||||
|
let event_handle = audio_client
|
||||||
|
.set_get_eventhandle()
|
||||||
|
.map_err(|e| AudioError::Capture(format!("SetEventHandle failed: {e}")))?;
|
||||||
|
let capture_client = audio_client
|
||||||
|
.get_audiocaptureclient()
|
||||||
|
.map_err(|e| AudioError::Capture(format!("GetService(IAudioCaptureClient) failed: {e}")))?;
|
||||||
|
|
||||||
|
let spec = wav_spec_for(&format)?;
|
||||||
|
let mut writer = WavWriter::create(wav_path, spec).map_err(|e| {
|
||||||
|
AudioError::Capture(format!("could not create {}: {e}", wav_path.display()))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
audio_client
|
||||||
|
.start_stream()
|
||||||
|
.map_err(|e| AudioError::Capture(format!("start_stream failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut resampler = Resampler::new(format.get_samplespersec());
|
||||||
|
let mut queue: std::collections::VecDeque<u8> = std::collections::VecDeque::new();
|
||||||
|
let mut frames_written: u64 = 0;
|
||||||
|
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
// Short timeout so we periodically re-check `running` even with no data.
|
||||||
|
let _ = event_handle.wait_for_event(100);
|
||||||
|
capture_client
|
||||||
|
.read_from_device_to_deque(&mut queue)
|
||||||
|
.map_err(|e| AudioError::Capture(format!("GetBuffer failed: {e}")))?;
|
||||||
|
if queue.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes: Vec<u8> = queue.drain(..).collect();
|
||||||
|
|
||||||
|
// Must keep pulling WASAPI buffers even while paused (required to avoid
|
||||||
|
// overrun); only skip persisting/forwarding the audio.
|
||||||
|
if paused.load(Ordering::Relaxed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
frames_written += write_wav_bytes(&mut writer, &bytes, &format)?;
|
||||||
|
let mono = decode_mono_f32(&bytes, &format)?;
|
||||||
|
let resampled = resampler.process(&mono);
|
||||||
|
if !resampled.is_empty() {
|
||||||
|
let _ = sink.try_send(resampled); // drop on backpressure; disk write is unaffected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audio_client.stop_stream().ok();
|
||||||
|
writer
|
||||||
|
.finalize()
|
||||||
|
.map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?;
|
||||||
|
|
||||||
|
let sample_rate = format.get_samplespersec();
|
||||||
|
Ok(CaptureSummary {
|
||||||
|
duration_ms: (frames_written * 1000) / sample_rate.max(1) as u64,
|
||||||
|
sample_rate,
|
||||||
|
channels: format.get_nchannels(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wav_spec_for(format: &WaveFormat) -> Result<WavSpec, AudioError> {
|
||||||
|
let sample_format = match format
|
||||||
|
.get_subformat()
|
||||||
|
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?
|
||||||
|
{
|
||||||
|
SampleType::Float => SampleFormat::Float,
|
||||||
|
SampleType::Int => SampleFormat::Int,
|
||||||
|
};
|
||||||
|
Ok(WavSpec {
|
||||||
|
channels: format.get_nchannels(),
|
||||||
|
sample_rate: format.get_samplespersec(),
|
||||||
|
bits_per_sample: format.get_bitspersample(),
|
||||||
|
sample_format,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write raw WASAPI capture bytes to the WAV writer in their native format.
|
||||||
|
/// Returns the number of frames written. Only the two mix formats WASAPI shared
|
||||||
|
/// mode actually produces in practice (32-bit float, 16-bit PCM) are supported;
|
||||||
|
/// anything else is a loud error rather than a silently corrupt recording.
|
||||||
|
fn write_wav_bytes(
|
||||||
|
writer: &mut WavWriter<BufWriter<File>>,
|
||||||
|
bytes: &[u8],
|
||||||
|
format: &WaveFormat,
|
||||||
|
) -> Result<u64, AudioError> {
|
||||||
|
let sample_type = format
|
||||||
|
.get_subformat()
|
||||||
|
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
|
||||||
|
let channels = format.get_nchannels() as u64;
|
||||||
|
match (sample_type, format.get_bitspersample()) {
|
||||||
|
(SampleType::Float, 32) => {
|
||||||
|
for chunk in bytes.chunks_exact(4) {
|
||||||
|
let v = f32::from_le_bytes(chunk.try_into().unwrap());
|
||||||
|
writer
|
||||||
|
.write_sample(v)
|
||||||
|
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||||
|
}
|
||||||
|
Ok(bytes.len() as u64 / 4 / channels.max(1))
|
||||||
|
}
|
||||||
|
(SampleType::Int, 16) => {
|
||||||
|
for chunk in bytes.chunks_exact(2) {
|
||||||
|
let v = i16::from_le_bytes(chunk.try_into().unwrap());
|
||||||
|
writer
|
||||||
|
.write_sample(v)
|
||||||
|
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||||
|
}
|
||||||
|
Ok(bytes.len() as u64 / 2 / channels.max(1))
|
||||||
|
}
|
||||||
|
(st, bits) => Err(AudioError::Device(format!(
|
||||||
|
"unsupported capture format: {st} {bits}-bit"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downmix raw WASAPI capture bytes to mono `f32` in `[-1.0, 1.0]`, at the
|
||||||
|
/// device's native sample rate (resampling to 16kHz happens separately).
|
||||||
|
fn decode_mono_f32(bytes: &[u8], format: &WaveFormat) -> Result<Vec<f32>, AudioError> {
|
||||||
|
let sample_type = format
|
||||||
|
.get_subformat()
|
||||||
|
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
|
||||||
|
let channels = format.get_nchannels() as usize;
|
||||||
|
if channels == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut mono = Vec::new();
|
||||||
|
match (sample_type, format.get_bitspersample()) {
|
||||||
|
(SampleType::Float, 32) => {
|
||||||
|
for frame in bytes.chunks_exact(4 * channels) {
|
||||||
|
let sum: f32 = frame
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||||
|
.sum();
|
||||||
|
mono.push(sum / channels as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(SampleType::Int, 16) => {
|
||||||
|
for frame in bytes.chunks_exact(2 * channels) {
|
||||||
|
let sum: f32 = frame
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32)
|
||||||
|
.sum();
|
||||||
|
mono.push(sum / channels as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(st, bits) => {
|
||||||
|
return Err(AudioError::Device(format!(
|
||||||
|
"unsupported capture format: {st} {bits}-bit"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(mono)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGET_SAMPLE_RATE: u32 = 16_000;
|
||||||
|
|
||||||
|
/// Streaming linear-interpolation resampler, mono f32 in -> mono f32 @ 16kHz out.
|
||||||
|
/// Carries fractional position and unconsumed tail samples across calls so
|
||||||
|
/// chunk boundaries don't introduce phase discontinuities.
|
||||||
|
/// `// ponytail: linear resampler, ceiling is STT-grade quality; swap for a sinc
|
||||||
|
/// resampler if transcription accuracy complaints trace back to aliasing.`
|
||||||
|
struct Resampler {
|
||||||
|
src_rate: u32,
|
||||||
|
pos: f64,
|
||||||
|
carry: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resampler {
|
||||||
|
fn new(src_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
src_rate,
|
||||||
|
pos: 0.0,
|
||||||
|
carry: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, mono_in: &[f32]) -> Vec<f32> {
|
||||||
|
if mono_in.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if self.src_rate == TARGET_SAMPLE_RATE {
|
||||||
|
return mono_in.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = std::mem::take(&mut self.carry);
|
||||||
|
buf.extend_from_slice(mono_in);
|
||||||
|
if buf.len() < 2 {
|
||||||
|
self.carry = buf;
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = self.src_rate as f64 / TARGET_SAMPLE_RATE as f64;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while (self.pos as usize) + 1 < buf.len() {
|
||||||
|
let i = self.pos as usize;
|
||||||
|
let frac = (self.pos - i as f64) as f32;
|
||||||
|
out.push(buf[i] * (1.0 - frac) + buf[i + 1] * frac);
|
||||||
|
self.pos += ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
let keep_from = (self.pos as usize).min(buf.len() - 1);
|
||||||
|
self.pos -= keep_from as f64;
|
||||||
|
self.carry = buf[keep_from..].to_vec();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wav_writer_roundtrip_produces_valid_header_and_duration() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("audio.wav");
|
||||||
|
|
||||||
|
let spec = WavSpec {
|
||||||
|
channels: 2,
|
||||||
|
sample_rate: 48_000,
|
||||||
|
bits_per_sample: 32,
|
||||||
|
sample_format: SampleFormat::Float,
|
||||||
|
};
|
||||||
|
let mut writer = WavWriter::create(&path, spec).unwrap();
|
||||||
|
let samples: Vec<f32> = (0..2000).map(|i| (i as f32 / 2000.0) - 0.5).collect();
|
||||||
|
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||||
|
|
||||||
|
// format.get_subformat()/get_bitspersample() need a real WaveFormat; build one
|
||||||
|
// via WaveFormat::new instead of a live device for this pure unit test.
|
||||||
|
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||||
|
write_wav_bytes(&mut writer, &bytes, &format).unwrap();
|
||||||
|
writer.finalize().unwrap();
|
||||||
|
|
||||||
|
let reader = hound::WavReader::open(&path).unwrap();
|
||||||
|
let read_spec = reader.spec();
|
||||||
|
assert_eq!(read_spec.channels, 2);
|
||||||
|
assert_eq!(read_spec.sample_rate, 48_000);
|
||||||
|
assert_eq!(read_spec.bits_per_sample, 32);
|
||||||
|
let frame_count = reader.duration();
|
||||||
|
assert_eq!(frame_count as usize, samples.len() / 2);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_mono_f32_downmixes_stereo() {
|
||||||
|
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||||
|
// One frame: left=1.0, right=-1.0 -> mono average 0.0.
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend_from_slice(&1.0f32.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&(-1.0f32).to_le_bytes());
|
||||||
|
let mono = decode_mono_f32(&bytes, &format).unwrap();
|
||||||
|
assert_eq!(mono, vec![0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resampler_exact_integer_ratio_picks_aligned_samples() {
|
||||||
|
// 48kHz -> 16kHz is an exact 3:1 ratio, so linear interpolation should land
|
||||||
|
// exactly on the original samples at indices 0, 3, 6, ...
|
||||||
|
let mut r = Resampler::new(48_000);
|
||||||
|
let input: Vec<f32> = (0..30).map(|i| i as f32).collect();
|
||||||
|
let out = r.process(&input);
|
||||||
|
let expected: Vec<f32> = (0..10).map(|i| (i * 3) as f32).collect();
|
||||||
|
assert_eq!(out, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resampler_continues_correctly_across_chunk_boundaries() {
|
||||||
|
let mut r = Resampler::new(48_000);
|
||||||
|
let first: Vec<f32> = (0..30).map(|i| i as f32).collect();
|
||||||
|
let second: Vec<f32> = (30..60).map(|i| i as f32).collect();
|
||||||
|
let mut out = r.process(&first);
|
||||||
|
out.extend(r.process(&second));
|
||||||
|
let expected: Vec<f32> = (0..20).map(|i| (i * 3) as f32).collect();
|
||||||
|
assert_eq!(out, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resampler_passthrough_when_already_target_rate() {
|
||||||
|
let mut r = Resampler::new(16_000);
|
||||||
|
let input = vec![0.1f32, 0.2, 0.3];
|
||||||
|
assert_eq!(r.process(&input), input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
//! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work
|
//! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work
|
||||||
//! is spawned and reported via events ("recording://*", "transcript://*", …).
|
//! is spawned and reported via events ("recording://*", "transcript://*", …).
|
||||||
//!
|
//!
|
||||||
//! These are typed stubs. Each `todo!()` maps to a roadmap task and must be
|
//! Recording/settings commands (Phase 1) are real implementations; everything
|
||||||
//! replaced with a real implementation that depends on the service traits.
|
//! else is still a typed `todo!()` stub mapped to its roadmap task.
|
||||||
|
|
||||||
|
use crate::audio::{AudioCapture, WasapiCapture};
|
||||||
use crate::error::WaResult;
|
use crate::error::WaResult;
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
|
use crate::transcription::{run_streaming_worker, Transcriber, WhisperTranscriber};
|
||||||
|
use crate::{error::WaError, AppState, RecordingSession};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct StartRecordingArgs {
|
pub struct StartRecordingArgs {
|
||||||
@@ -18,40 +23,273 @@ pub struct StartRecordingArgs {
|
|||||||
pub record: bool,
|
pub record: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Phase 1 file-backed settings + storage paths ----
|
||||||
|
// Full SQLite persistence is Phase 2 (`storage::Store`, still `todo!()`); Phase
|
||||||
|
// 1 only needs `settings.json` (consent/default-retention, FR-REC-1/2) and the
|
||||||
|
// `meetings/<id>/` folder layout from `docs/03-data-model.md`.
|
||||||
|
|
||||||
|
fn local_appdata() -> PathBuf {
|
||||||
|
PathBuf::from(std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wa_root() -> PathBuf {
|
||||||
|
local_appdata().join("WhispAssist")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_path() -> PathBuf {
|
||||||
|
wa_root().join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meetings_dir() -> PathBuf {
|
||||||
|
wa_root().join("meetings")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whisper_model_path() -> PathBuf {
|
||||||
|
wa_root().join("models").join("ggml-base.en-q5_1.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_settings() -> Settings {
|
||||||
|
Settings {
|
||||||
|
theme: "system".into(),
|
||||||
|
storage_root: wa_root().display().to_string(),
|
||||||
|
llm_provider: "ollama".into(),
|
||||||
|
llm_endpoint: "http://localhost:11434".into(),
|
||||||
|
llm_model: "llama3".into(),
|
||||||
|
preferred_backend: "auto".into(),
|
||||||
|
low_overhead: false,
|
||||||
|
default_record: false,
|
||||||
|
consent_acknowledged: false,
|
||||||
|
sync_enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_settings() -> Settings {
|
||||||
|
std::fs::read_to_string(settings_path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_else(default_settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_settings(settings: &Settings) -> Result<(), WaError> {
|
||||||
|
let path = settings_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(settings)
|
||||||
|
.map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||||
|
std::fs::write(path, json).map_err(|e| WaError::new("settings", e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Recording lifecycle (Phase 1) ----
|
// ---- Recording lifecycle (Phase 1) ----
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_recording(_args: StartRecordingArgs) -> WaResult<MeetingId> {
|
pub async fn start_recording(
|
||||||
// T1.2/T1.3: create meeting row, start WASAPI capture, spawn transcription worker.
|
app: AppHandle,
|
||||||
todo!("Phase 1 — start_recording")
|
state: State<'_, AppState>,
|
||||||
|
args: StartRecordingArgs,
|
||||||
|
) -> WaResult<MeetingId> {
|
||||||
|
let mut guard = state.session.lock().await;
|
||||||
|
if guard.is_some() {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"recording",
|
||||||
|
"a meeting is already in progress",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.record && !load_settings().consent_acknowledged {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"consent",
|
||||||
|
"recording consent has not been acknowledged yet",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_path = whisper_model_path();
|
||||||
|
if !model_path.exists() {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"transcription",
|
||||||
|
format!(
|
||||||
|
"whisper model not found at {}; run `npm run download-models` first",
|
||||||
|
model_path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let meeting_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let meeting_dir = meetings_dir().join(&meeting_id);
|
||||||
|
std::fs::create_dir_all(&meeting_dir).map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||||
|
let wav_path = meeting_dir.join("audio.wav");
|
||||||
|
|
||||||
|
// Bounded so a slow/stalled transcription worker can never back up the capture thread.
|
||||||
|
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
||||||
|
let capture = WasapiCapture
|
||||||
|
.start(&wav_path, frame_tx)
|
||||||
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||||
|
|
||||||
|
let app_for_worker = app.clone();
|
||||||
|
let meeting_id_for_worker = meeting_id.clone();
|
||||||
|
let transcription_worker = std::thread::Builder::new()
|
||||||
|
.name("wa-transcription".into())
|
||||||
|
.spawn(
|
||||||
|
move || match WhisperTranscriber::load(&model_path, BackendId::Cpu) {
|
||||||
|
Ok(transcriber) => run_streaming_worker(&transcriber, frame_rx, |segment| {
|
||||||
|
let _ = app_for_worker.emit(
|
||||||
|
"transcript://segment",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Err(e) => tracing::error!("failed to load whisper model: {e}"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
||||||
|
|
||||||
|
*guard = Some(RecordingSession {
|
||||||
|
meeting_id: meeting_id.clone(),
|
||||||
|
capture,
|
||||||
|
retention: args.record,
|
||||||
|
wav_path,
|
||||||
|
started_at: std::time::Instant::now(),
|
||||||
|
transcription_worker,
|
||||||
|
});
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
crate::update_tray_tooltip(&app, "WhispAssist — recording");
|
||||||
|
let _ = app.emit(
|
||||||
|
"recording://state",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
|
||||||
|
);
|
||||||
|
Ok(meeting_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_recording(_meeting_id: MeetingId) -> WaResult<()> {
|
pub async fn stop_recording(
|
||||||
// T1.3 + T4: finalize WAV, run diarization, persist, optional summary.
|
app: AppHandle,
|
||||||
todo!("Phase 1 — stop_recording")
|
state: State<'_, AppState>,
|
||||||
|
meeting_id: MeetingId,
|
||||||
|
) -> WaResult<()> {
|
||||||
|
let mut guard = state.session.lock().await;
|
||||||
|
let session = match guard.take() {
|
||||||
|
Some(s) if s.meeting_id == meeting_id => s,
|
||||||
|
Some(s) => {
|
||||||
|
*guard = Some(s);
|
||||||
|
return Err(WaError::new(
|
||||||
|
"recording",
|
||||||
|
"meeting_id does not match the active recording",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"recording",
|
||||||
|
"no meeting is currently recording",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let summary = WasapiCapture
|
||||||
|
.stop(session.capture)
|
||||||
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||||
|
// The capture thread has dropped its `FrameSink`; the transcription worker's
|
||||||
|
// `recv()` now returns `Err` and the worker exits on its own — join to
|
||||||
|
// guarantee it has fully drained the audio before we act on retention.
|
||||||
|
let _ = session.transcription_worker.join();
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"transcript://finalized",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id, "segmentCount": serde_json::Value::Null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ADR-0009: delete the working WAV only after the transcript worker has
|
||||||
|
// finished reading it, and only when retention is off.
|
||||||
|
if !session.retention {
|
||||||
|
let _ = std::fs::remove_file(&session.wav_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::update_tray_tooltip(&app, "WhispAssist — idle");
|
||||||
|
let _ = app.emit(
|
||||||
|
"recording://state",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id, "state": "stopped", "elapsedMs": summary.duration_ms }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pause_recording(_meeting_id: MeetingId) -> WaResult<()> {
|
pub async fn pause_recording(
|
||||||
todo!("Phase 1 — pause_recording")
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
meeting_id: MeetingId,
|
||||||
|
) -> WaResult<()> {
|
||||||
|
let guard = state.session.lock().await;
|
||||||
|
let session = guard
|
||||||
|
.as_ref()
|
||||||
|
.filter(|s| s.meeting_id == meeting_id)
|
||||||
|
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
||||||
|
WasapiCapture
|
||||||
|
.pause(&session.capture)
|
||||||
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||||
|
drop(guard);
|
||||||
|
let _ = app.emit(
|
||||||
|
"recording://state",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id, "state": "paused", "elapsedMs": 0 }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn resume_recording(_meeting_id: MeetingId) -> WaResult<()> {
|
pub async fn resume_recording(
|
||||||
todo!("Phase 1 — resume_recording")
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
meeting_id: MeetingId,
|
||||||
|
) -> WaResult<()> {
|
||||||
|
let guard = state.session.lock().await;
|
||||||
|
let session = guard
|
||||||
|
.as_ref()
|
||||||
|
.filter(|s| s.meeting_id == meeting_id)
|
||||||
|
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
||||||
|
WasapiCapture
|
||||||
|
.resume(&session.capture)
|
||||||
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||||
|
drop(guard);
|
||||||
|
let _ = app.emit(
|
||||||
|
"recording://state",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
|
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_recording_retention(_meeting_id: MeetingId, _record: bool) -> WaResult<()> {
|
pub async fn set_recording_retention(
|
||||||
todo!("Phase 1 — set_recording_retention")
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
meeting_id: MeetingId,
|
||||||
|
record: bool,
|
||||||
|
) -> WaResult<()> {
|
||||||
|
if record && !load_settings().consent_acknowledged {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"consent",
|
||||||
|
"recording consent has not been acknowledged yet",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut guard = state.session.lock().await;
|
||||||
|
let session = guard
|
||||||
|
.as_mut()
|
||||||
|
.filter(|s| s.meeting_id == meeting_id)
|
||||||
|
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
||||||
|
session.retention = record;
|
||||||
|
drop(guard);
|
||||||
|
let _ = app.emit(
|
||||||
|
"recording://retention",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id, "record": record }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record the one-time recording-consent acknowledgment (FR-REC-2).
|
/// Record the one-time recording-consent acknowledgment (FR-REC-2).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn acknowledge_recording_consent() -> WaResult<()> {
|
pub async fn acknowledge_recording_consent() -> WaResult<()> {
|
||||||
todo!("Phase 1 — acknowledge_recording_consent")
|
let mut settings = load_settings();
|
||||||
|
settings.consent_acknowledged = true;
|
||||||
|
save_settings(&settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Hardware (Phase 3) ----
|
// ---- Hardware (Phase 3) ----
|
||||||
@@ -107,7 +345,10 @@ pub async fn set_llm_provider(_config: serde_json::Value) -> WaResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_summary(_meeting_id: MeetingId, _template_id: Option<String>) -> WaResult<()> {
|
pub async fn generate_summary(
|
||||||
|
_meeting_id: MeetingId,
|
||||||
|
_template_id: Option<String>,
|
||||||
|
) -> WaResult<()> {
|
||||||
// Streams via "llm://token" / "llm://done".
|
// Streams via "llm://token" / "llm://done".
|
||||||
todo!("Phase 5 — generate_summary")
|
todo!("Phase 5 — generate_summary")
|
||||||
}
|
}
|
||||||
@@ -179,13 +420,18 @@ pub async fn sync_status(_meeting_id: Option<MeetingId>) -> WaResult<Vec<SyncJob
|
|||||||
// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ----
|
// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ----
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_feature_brief(_meeting_id: MeetingId, _target_repo: Option<String>) -> WaResult<FeatureBrief> {
|
pub async fn create_feature_brief(
|
||||||
|
_meeting_id: MeetingId,
|
||||||
|
_target_repo: Option<String>,
|
||||||
|
) -> WaResult<FeatureBrief> {
|
||||||
// T10.6: distill transcript → structured brief via the configured LlmProvider.
|
// T10.6: distill transcript → structured brief via the configured LlmProvider.
|
||||||
todo!("Phase 10b — create_feature_brief")
|
todo!("Phase 10b — create_feature_brief")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_feature_briefs(_meeting_id: Option<MeetingId>) -> WaResult<Vec<FeatureBriefInfo>> {
|
pub async fn list_feature_briefs(
|
||||||
|
_meeting_id: Option<MeetingId>,
|
||||||
|
) -> WaResult<Vec<FeatureBriefInfo>> {
|
||||||
todo!("Phase 10b — list_feature_briefs")
|
todo!("Phase 10b — list_feature_briefs")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +453,11 @@ pub async fn mcp_status() -> WaResult<serde_json::Value> {
|
|||||||
|
|
||||||
/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6).
|
/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_mcp_enabled(_enabled: bool, _transport: Option<String>, _port: Option<u16>) -> WaResult<serde_json::Value> {
|
pub async fn set_mcp_enabled(
|
||||||
|
_enabled: bool,
|
||||||
|
_transport: Option<String>,
|
||||||
|
_port: Option<u16>,
|
||||||
|
) -> WaResult<serde_json::Value> {
|
||||||
todo!("Phase 10b — set_mcp_enabled (loopback only, token)")
|
todo!("Phase 10b — set_mcp_enabled (loopback only, token)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,12 +475,20 @@ pub async fn mcp_access_log(_limit: Option<u32>) -> WaResult<Vec<McpAccessEntry>
|
|||||||
// ---- Agent push / task-tracker handoff (Phase 10c, later) ----
|
// ---- Agent push / task-tracker handoff (Phase 10c, later) ----
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn run_agent(_brief_id: String, _tool: String, _repo_path: String) -> WaResult<serde_json::Value> {
|
pub async fn run_agent(
|
||||||
|
_brief_id: String,
|
||||||
|
_tool: String,
|
||||||
|
_repo_path: String,
|
||||||
|
) -> WaResult<serde_json::Value> {
|
||||||
todo!("Phase 10c — run_agent (claude|codex|opencode|copilot)")
|
todo!("Phase 10c — run_agent (claude|codex|opencode|copilot)")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_issue_from_brief(_brief_id: String, _tracker: String, _assign_copilot: Option<bool>) -> WaResult<serde_json::Value> {
|
pub async fn create_issue_from_brief(
|
||||||
|
_brief_id: String,
|
||||||
|
_tracker: String,
|
||||||
|
_assign_copilot: Option<bool>,
|
||||||
|
) -> WaResult<serde_json::Value> {
|
||||||
todo!("Phase 10c — create_issue_from_brief")
|
todo!("Phase 10c — create_issue_from_brief")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,12 +496,22 @@ pub async fn create_issue_from_brief(_brief_id: String, _tracker: String, _assig
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_settings() -> WaResult<Settings> {
|
pub async fn get_settings() -> WaResult<Settings> {
|
||||||
todo!("Phase 2 — get_settings")
|
Ok(load_settings())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_settings(_patch: serde_json::Value) -> WaResult<Settings> {
|
pub async fn update_settings(patch: serde_json::Value) -> WaResult<Settings> {
|
||||||
todo!("Phase 2 — update_settings")
|
let mut current = serde_json::to_value(load_settings())
|
||||||
|
.map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||||
|
if let (Some(base), Some(patch)) = (current.as_object_mut(), patch.as_object()) {
|
||||||
|
for (key, value) in patch {
|
||||||
|
base.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let merged: Settings =
|
||||||
|
serde_json::from_value(current).map_err(|e| WaError::new("settings", e.to_string()))?;
|
||||||
|
save_settings(&merged)?;
|
||||||
|
Ok(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2).
|
/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2).
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ pub struct WaError {
|
|||||||
|
|
||||||
impl WaError {
|
impl WaError {
|
||||||
pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
|
pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
Self { kind: kind.into(), message: message.into() }
|
Self {
|
||||||
|
kind: kind.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,20 @@ pub mod storage;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::path::PathBuf;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use tauri::tray::TrayIcon;
|
||||||
|
use tauri::Manager;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
/// Shared application state handed to every Tauri command via `tauri::State`.
|
/// Shared application state handed to every Tauri command via `tauri::State`.
|
||||||
/// Service handles are `Arc`-wrapped so they can be cloned into worker tasks.
|
///
|
||||||
|
/// Phase 1 only needs the in-flight recording session. `hardware`/`store`/
|
||||||
|
/// `llm`/`sync`/`mcp` service handles join this struct once their concrete
|
||||||
|
/// implementations exist (Phase 2/3/5/9/10) — wiring them in ahead of that
|
||||||
|
/// isn't possible yet (several don't have a concrete impl to construct) and
|
||||||
|
/// would just be unused plumbing until then.
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub hardware: Arc<dyn hardware::HardwareDetector>,
|
|
||||||
pub store: Arc<dyn storage::Store>,
|
|
||||||
pub llm: Arc<dyn llm::LlmProvider>,
|
|
||||||
pub sync: Arc<dyn sync::SyncManager>,
|
|
||||||
pub mcp: Arc<dyn mcp::McpServer>,
|
|
||||||
/// The single in-flight recording, if any. Guarded so start/stop/pause are atomic.
|
/// The single in-flight recording, if any. Guarded so start/stop/pause are atomic.
|
||||||
pub session: Mutex<Option<RecordingSession>>,
|
pub session: Mutex<Option<RecordingSession>>,
|
||||||
}
|
}
|
||||||
@@ -37,16 +40,35 @@ pub struct AppState {
|
|||||||
/// Tracks the currently-recording meeting and its worker handles.
|
/// Tracks the currently-recording meeting and its worker handles.
|
||||||
pub struct RecordingSession {
|
pub struct RecordingSession {
|
||||||
pub meeting_id: models::MeetingId,
|
pub meeting_id: models::MeetingId,
|
||||||
// capture handle, transcription worker join handle, etc. — populated in Phase 1.
|
pub capture: audio::CaptureHandle,
|
||||||
|
/// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting.
|
||||||
|
pub retention: bool,
|
||||||
|
pub wav_path: PathBuf,
|
||||||
|
pub started_at: std::time::Instant,
|
||||||
|
pub transcription_worker: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps the tray icon so it can be looked up from commands to update its
|
||||||
|
/// tooltip on recording state changes (FR-CAP-4).
|
||||||
|
pub struct TrayHandle(pub TrayIcon);
|
||||||
|
|
||||||
/// Build state, register commands/events, and run the app.
|
/// Build state, register commands/events, and run the app.
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||||
|
|
||||||
// NOTE: concrete service implementations are constructed here once they exist.
|
|
||||||
// The skeleton wires the command surface; `todo!()`s mark per-phase tasks.
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.manage(AppState {
|
||||||
|
session: Mutex::new(None),
|
||||||
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
|
||||||
|
let tray = tauri::tray::TrayIconBuilder::new()
|
||||||
|
.icon(icon)
|
||||||
|
.tooltip("WhispAssist — idle")
|
||||||
|
.build(app)?;
|
||||||
|
app.manage(TrayHandle(tray));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::start_recording,
|
commands::start_recording,
|
||||||
commands::stop_recording,
|
commands::stop_recording,
|
||||||
@@ -91,3 +113,10 @@ pub fn run() {
|
|||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running WhispAssist");
|
.expect("error while running WhispAssist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Used by `commands.rs` to keep the tray tooltip honest about capture state (FR-CAP-4).
|
||||||
|
pub(crate) fn update_tray_tooltip(app: &tauri::AppHandle, text: &str) {
|
||||||
|
if let Some(tray) = app.try_state::<TrayHandle>() {
|
||||||
|
let _ = tray.0.set_tooltip(Some(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ pub trait McpServer: Send + Sync {
|
|||||||
/// Distills a transcript into an agent-ready spec (FR-MCP-4) using the configured LlmProvider.
|
/// Distills a transcript into an agent-ready spec (FR-MCP-4) using the configured LlmProvider.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FeatureBriefBuilder: Send + Sync {
|
pub trait FeatureBriefBuilder: Send + Sync {
|
||||||
async fn build(&self, meeting_id: &MeetingId, target_repo: Option<&str>) -> Result<FeatureBrief, BriefError>;
|
async fn build(
|
||||||
|
&self,
|
||||||
|
meeting_id: &MeetingId,
|
||||||
|
target_repo: Option<&str>,
|
||||||
|
) -> Result<FeatureBrief, BriefError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default rmcp-backed server (feature `mcp`).
|
/// Default rmcp-backed server (feature `mcp`).
|
||||||
@@ -97,10 +101,22 @@ impl McpServer for RmcpServer {
|
|||||||
fn tools(&self) -> Vec<McpToolDescriptor> {
|
fn tools(&self) -> Vec<McpToolDescriptor> {
|
||||||
// T10.5: the tools an agent can call. Tools-first for Copilot compatibility.
|
// T10.5: the tools an agent can call. Tools-first for Copilot compatibility.
|
||||||
vec![
|
vec![
|
||||||
McpToolDescriptor { name: "list_recent_meetings", description: "Recent meetings (scoped)." },
|
McpToolDescriptor {
|
||||||
McpToolDescriptor { name: "get_transcript", description: "Transcript for a meeting (scoped)." },
|
name: "list_recent_meetings",
|
||||||
McpToolDescriptor { name: "get_action_items", description: "Action items for a meeting." },
|
description: "Recent meetings (scoped).",
|
||||||
McpToolDescriptor { name: "get_feature_brief", description: "Agent-ready spec distilled from a meeting." },
|
},
|
||||||
|
McpToolDescriptor {
|
||||||
|
name: "get_transcript",
|
||||||
|
description: "Transcript for a meeting (scoped).",
|
||||||
|
},
|
||||||
|
McpToolDescriptor {
|
||||||
|
name: "get_action_items",
|
||||||
|
description: "Action items for a meeting.",
|
||||||
|
},
|
||||||
|
McpToolDescriptor {
|
||||||
|
name: "get_feature_brief",
|
||||||
|
description: "Agent-ready spec distilled from a meeting.",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ pub trait NotesRenderer: Send + Sync {
|
|||||||
summary_md: Option<&str>,
|
summary_md: Option<&str>,
|
||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
fn export(&self, markdown: &str, dest: &Path, fmt: ExportFormat) -> Result<PathBuf, NotesError>;
|
fn export(&self, markdown: &str, dest: &Path, fmt: ExportFormat)
|
||||||
|
-> Result<PathBuf, NotesError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MarkdownNotes;
|
pub struct MarkdownNotes;
|
||||||
@@ -48,7 +49,12 @@ impl NotesRenderer for MarkdownNotes {
|
|||||||
// T2.4: resolve speaker names, group dialogue, prepend summary section.
|
// T2.4: resolve speaker names, group dialogue, prepend summary section.
|
||||||
todo!("Phase 2 — assemble Markdown")
|
todo!("Phase 2 — assemble Markdown")
|
||||||
}
|
}
|
||||||
fn export(&self, _markdown: &str, _dest: &Path, _fmt: ExportFormat) -> Result<PathBuf, NotesError> {
|
fn export(
|
||||||
|
&self,
|
||||||
|
_markdown: &str,
|
||||||
|
_dest: &Path,
|
||||||
|
_fmt: ExportFormat,
|
||||||
|
) -> Result<PathBuf, NotesError> {
|
||||||
// T2.6 (.md/bundle); T8.4 (pdf/docx via local conversion).
|
// T2.6 (.md/bundle); T8.4 (pdf/docx via local conversion).
|
||||||
todo!("Phase 2/8 — export notes")
|
todo!("Phase 2/8 — export notes")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ pub struct Retention {
|
|||||||
pub trait Store: Send + Sync {
|
pub trait Store: Send + Sync {
|
||||||
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
|
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
|
||||||
async fn finalize_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
async fn finalize_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||||
async fn list_meetings(&self, query: Option<String>) -> Result<Vec<MeetingListItem>, StoreError>;
|
async fn list_meetings(
|
||||||
|
&self,
|
||||||
|
query: Option<String>,
|
||||||
|
) -> Result<Vec<MeetingListItem>, StoreError>;
|
||||||
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||||
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
|
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
|
||||||
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
|
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
|
||||||
@@ -53,7 +56,10 @@ impl Store for SqliteStore {
|
|||||||
async fn finalize_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
async fn finalize_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
||||||
todo!("Phase 2 — persist transcript.json + metadata")
|
todo!("Phase 2 — persist transcript.json + metadata")
|
||||||
}
|
}
|
||||||
async fn list_meetings(&self, _query: Option<String>) -> Result<Vec<MeetingListItem>, StoreError> {
|
async fn list_meetings(
|
||||||
|
&self,
|
||||||
|
_query: Option<String>,
|
||||||
|
) -> Result<Vec<MeetingListItem>, StoreError> {
|
||||||
todo!("Phase 2 — list meetings")
|
todo!("Phase 2 — list meetings")
|
||||||
}
|
}
|
||||||
async fn delete_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
async fn delete_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ pub trait SyncTarget: Send + Sync {
|
|||||||
/// True if a file with this hash already exists remotely (skip-if-unchanged).
|
/// True if a file with this hash already exists remotely (skip-if-unchanged).
|
||||||
async fn exists(&self, remote_path: &str, sha256: &str) -> Result<bool, SyncError>;
|
async fn exists(&self, remote_path: &str, sha256: &str) -> Result<bool, SyncError>;
|
||||||
/// Upload a file; resumable/chunked for large artifacts.
|
/// Upload a file; resumable/chunked for large artifacts.
|
||||||
async fn put(&self, local: &Path, remote_path: &str, prog: ProgressSink) -> Result<(), SyncError>;
|
async fn put(
|
||||||
|
&self,
|
||||||
|
local: &Path,
|
||||||
|
remote_path: &str,
|
||||||
|
prog: ProgressSink,
|
||||||
|
) -> Result<(), SyncError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns the durable queue, retry/backoff, credential resolution, TLS enforcement,
|
/// Owns the durable queue, retry/backoff, credential resolution, TLS enforcement,
|
||||||
@@ -49,7 +54,11 @@ pub trait SyncTarget: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SyncManager: Send + Sync {
|
pub trait SyncManager: Send + Sync {
|
||||||
/// Enqueue a meeting's selected artifacts for one or all enabled targets.
|
/// Enqueue a meeting's selected artifacts for one or all enabled targets.
|
||||||
async fn enqueue_meeting(&self, meeting_id: &MeetingId, target_id: Option<&str>) -> Result<(), SyncError>;
|
async fn enqueue_meeting(
|
||||||
|
&self,
|
||||||
|
meeting_id: &MeetingId,
|
||||||
|
target_id: Option<&str>,
|
||||||
|
) -> Result<(), SyncError>;
|
||||||
/// Drive pending jobs (called on finalize, on startup, and on a low-frequency timer).
|
/// Drive pending jobs (called on finalize, on startup, and on a low-frequency timer).
|
||||||
async fn pump(&self) -> Result<(), SyncError>;
|
async fn pump(&self) -> Result<(), SyncError>;
|
||||||
async fn status(&self, meeting_id: Option<&MeetingId>) -> Result<Vec<SyncJobInfo>, SyncError>;
|
async fn status(&self, meeting_id: Option<&MeetingId>) -> Result<Vec<SyncJobInfo>, SyncError>;
|
||||||
@@ -89,7 +98,12 @@ impl SyncTarget for WebDavTarget {
|
|||||||
async fn exists(&self, _remote_path: &str, _sha256: &str) -> Result<bool, SyncError> {
|
async fn exists(&self, _remote_path: &str, _sha256: &str) -> Result<bool, SyncError> {
|
||||||
todo!("Phase 9 — WebDAV exists / hash compare")
|
todo!("Phase 9 — WebDAV exists / hash compare")
|
||||||
}
|
}
|
||||||
async fn put(&self, _local: &Path, _remote_path: &str, _prog: ProgressSink) -> Result<(), SyncError> {
|
async fn put(
|
||||||
|
&self,
|
||||||
|
_local: &Path,
|
||||||
|
_remote_path: &str,
|
||||||
|
_prog: ProgressSink,
|
||||||
|
) -> Result<(), SyncError> {
|
||||||
// T9.2/T9.5: PUT (chunked for large files); stream progress.
|
// T9.2/T9.5: PUT (chunked for large files); stream progress.
|
||||||
todo!("Phase 9 — WebDAV upload")
|
todo!("Phase 9 — WebDAV upload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use crate::models::{BackendId, TranscriptSegment};
|
use crate::models::{BackendId, TranscriptSegment};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum TrxError {
|
pub enum TrxError {
|
||||||
@@ -22,7 +23,7 @@ pub struct AudioWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Where produced segments are delivered (interim then final).
|
/// Where produced segments are delivered (interim then final).
|
||||||
pub type SegmentSink = std::sync::mpsc::Sender<TranscriptSegment>;
|
pub type SegmentSink = Sender<TranscriptSegment>;
|
||||||
|
|
||||||
pub trait Transcriber: Send + Sync {
|
pub trait Transcriber: Send + Sync {
|
||||||
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError>
|
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError>
|
||||||
@@ -36,22 +37,151 @@ pub trait Transcriber: Send + Sync {
|
|||||||
|
|
||||||
/// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features).
|
/// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features).
|
||||||
#[cfg(feature = "cpu-transcription")]
|
#[cfg(feature = "cpu-transcription")]
|
||||||
pub struct WhisperTranscriber;
|
pub struct WhisperTranscriber {
|
||||||
|
ctx: whisper_rs::WhisperContext,
|
||||||
|
next_id: std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "cpu-transcription")]
|
#[cfg(feature = "cpu-transcription")]
|
||||||
impl Transcriber for WhisperTranscriber {
|
impl Transcriber for WhisperTranscriber {
|
||||||
fn load(_model: &Path, _backend: BackendId) -> Result<Self, TrxError> {
|
fn load(model: &Path, _backend: BackendId) -> Result<Self, TrxError> {
|
||||||
// T1.5 / T3.3: init whisper-rs with the backend's acceleration features.
|
let ctx = whisper_rs::WhisperContext::new_with_params(
|
||||||
todo!("Phase 1 — load whisper model")
|
model,
|
||||||
|
whisper_rs::WhisperContextParameters::default(),
|
||||||
|
)
|
||||||
|
.map_err(|e| TrxError::Load(e.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
ctx,
|
||||||
|
next_id: std::sync::atomic::AtomicU64::new(0),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
fn transcribe_stream(&self, _audio: AudioWindow, _out: SegmentSink) -> Result<(), TrxError> {
|
|
||||||
todo!("Phase 1 — streaming transcription")
|
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError> {
|
||||||
|
let mut state = self
|
||||||
|
.ctx
|
||||||
|
.create_state()
|
||||||
|
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||||
|
|
||||||
|
// best_of: 1 — fastest greedy decode, matching the CPU/typical-laptop
|
||||||
|
// baseline (NFR-PERF-3); real-time windowed transcription favors low
|
||||||
|
// latency per window over the extra accuracy of a wider search.
|
||||||
|
let mut params =
|
||||||
|
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
|
||||||
|
params.set_n_threads(available_threads());
|
||||||
|
params.set_translate(false);
|
||||||
|
params.set_print_progress(false);
|
||||||
|
params.set_print_realtime(false);
|
||||||
|
params.set_print_timestamps(false);
|
||||||
|
params.set_suppress_blank(true);
|
||||||
|
// Each call already gets exactly one fixed-size window (not a full 30s
|
||||||
|
// clip); without this, whisper.cpp's segment-boundary heuristic hits
|
||||||
|
// "single timestamp ending" on short buffers and silently drops the
|
||||||
|
// whole window even when decoding succeeded (verified against real
|
||||||
|
// audio: text decoded correctly but zero segments came out until this
|
||||||
|
// was set — see whisper.cpp's own `stream` example, which sets this
|
||||||
|
// for the same reason).
|
||||||
|
params.set_single_segment(true);
|
||||||
|
|
||||||
|
state
|
||||||
|
.full(params, &audio.samples)
|
||||||
|
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||||
|
|
||||||
|
for seg in state.as_iter() {
|
||||||
|
let text = seg.to_str_lossy().unwrap_or_default().trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
// Whisper timestamps are centiseconds (10ms units).
|
||||||
|
let start_ms = audio.offset_ms + seg.start_timestamp().max(0) as u64 * 10;
|
||||||
|
let end_ms = audio.offset_ms + seg.end_timestamp().max(0) as u64 * 10;
|
||||||
|
let segment = TranscriptSegment {
|
||||||
|
id: self
|
||||||
|
.next_id
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||||
|
start_ms,
|
||||||
|
end_ms,
|
||||||
|
// Diarization lands in Phase 4; every segment is provisionally "S1" until then.
|
||||||
|
speaker: "S1".to_string(),
|
||||||
|
text,
|
||||||
|
confidence: Some((1.0 - seg.no_speech_probability()).clamp(0.0, 1.0)),
|
||||||
|
interim: false,
|
||||||
|
};
|
||||||
|
if out.send(segment).is_err() {
|
||||||
|
break; // receiver gone (meeting stopped) — nothing left to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn transcribe_file(&self, _wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError> {
|
fn transcribe_file(&self, _wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError> {
|
||||||
todo!("Phase 3 — batch transcription")
|
todo!("Phase 3 — batch transcription")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cpu-transcription")]
|
||||||
|
fn available_threads() -> std::ffi::c_int {
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get() as std::ffi::c_int)
|
||||||
|
.unwrap_or(4)
|
||||||
|
}
|
||||||
|
|
||||||
/// ONNX Runtime + DirectML transcriber for the NPU tier (Phase 3).
|
/// ONNX Runtime + DirectML transcriber for the NPU tier (Phase 3).
|
||||||
#[cfg(feature = "directml")]
|
#[cfg(feature = "directml")]
|
||||||
pub struct OnnxNpuTranscriber;
|
pub struct OnnxNpuTranscriber;
|
||||||
|
|
||||||
|
/// Streaming window worker (Phase 1, T1.5/T1.6): accumulates raw 16kHz-mono
|
||||||
|
/// chunks from the `audio` service into fixed-size, **non-overlapping** windows
|
||||||
|
/// and runs one `transcribe_stream` pass per window as it fills, forwarding
|
||||||
|
/// each produced segment to `on_segment` (e.g. a Tauri event emit).
|
||||||
|
///
|
||||||
|
/// `transcribe_stream` takes a `SegmentSink` per the `Transcriber` trait (so a
|
||||||
|
/// future async/threaded engine can push mid-inference), but whisper.cpp's
|
||||||
|
/// `full()` call is synchronous — by the time a window's `transcribe_stream`
|
||||||
|
/// call returns, every segment it produced is already sitting in a fresh
|
||||||
|
/// per-window channel, so this drains it inline rather than needing a second
|
||||||
|
/// long-lived thread just to bridge segments out.
|
||||||
|
///
|
||||||
|
/// True incremental/partial-word streaming (and window overlap for continuity)
|
||||||
|
/// are out of scope for Phase 1: whisper.cpp transcribes each window from
|
||||||
|
/// scratch, so an overlapping window would re-emit the overlapped words a
|
||||||
|
/// second time with no stitching logic to merge them — a worse rough edge for
|
||||||
|
/// a live transcript than the occasional word clipped at a window boundary.
|
||||||
|
/// "Near real time" (FR-TRX-2) is met by short (~4s) windows; `interim` stays
|
||||||
|
/// `false` for every segment produced here.
|
||||||
|
#[cfg(feature = "cpu-transcription")]
|
||||||
|
pub fn run_streaming_worker<T, F>(transcriber: &T, frame_rx: Receiver<Vec<f32>>, mut on_segment: F)
|
||||||
|
where
|
||||||
|
T: Transcriber,
|
||||||
|
F: FnMut(TranscriptSegment),
|
||||||
|
{
|
||||||
|
const SAMPLE_RATE: usize = 16_000;
|
||||||
|
const WINDOW_SECS: f32 = 4.0;
|
||||||
|
let window_len = (WINDOW_SECS * SAMPLE_RATE as f32) as usize;
|
||||||
|
|
||||||
|
let mut buf: Vec<f32> = Vec::new();
|
||||||
|
let mut offset_ms: u64 = 0;
|
||||||
|
|
||||||
|
let run_window = |transcriber: &T, samples: Vec<f32>, offset_ms: u64, on_segment: &mut F| {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let window = AudioWindow { samples, offset_ms };
|
||||||
|
if let Err(e) = transcriber.transcribe_stream(window, tx) {
|
||||||
|
tracing::warn!("transcription window failed: {e}");
|
||||||
|
}
|
||||||
|
while let Ok(segment) = rx.try_recv() {
|
||||||
|
on_segment(segment);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(chunk) = frame_rx.recv() {
|
||||||
|
buf.extend_from_slice(&chunk);
|
||||||
|
while buf.len() >= window_len {
|
||||||
|
let samples: Vec<f32> = buf.drain(..window_len).collect();
|
||||||
|
run_window(transcriber, samples, offset_ms, &mut on_segment);
|
||||||
|
offset_ms += (window_len as u64 * 1000) / SAMPLE_RATE as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final partial window on stop, if there's enough audio to be worth a pass.
|
||||||
|
if buf.len() > SAMPLE_RATE / 2 {
|
||||||
|
run_window(transcriber, buf, offset_ms, &mut on_segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,40 @@
|
|||||||
import TranscriptNotes from "./lib/views/TranscriptNotes.svelte";
|
import TranscriptNotes from "./lib/views/TranscriptNotes.svelte";
|
||||||
import SummaryPanel from "./lib/views/SummaryPanel.svelte";
|
import SummaryPanel from "./lib/views/SummaryPanel.svelte";
|
||||||
import Settings from "./lib/views/Settings.svelte";
|
import Settings from "./lib/views/Settings.svelte";
|
||||||
|
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
||||||
import { recording } from "./lib/stores/recording.svelte";
|
import { recording } from "./lib/stores/recording.svelte";
|
||||||
|
import { settings } from "./lib/stores/settings.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let showSettings = $state(false);
|
let showSettings = $state(false);
|
||||||
|
let showConsent = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
recording.init();
|
recording.init();
|
||||||
|
settings.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
recording.start(undefined, settings.settings.default_record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mid-meeting retention toggle (FR-REC-1); gates on the same one-time
|
||||||
|
// consent notice as the Settings default-record toggle (FR-REC-2).
|
||||||
|
function onToggleRetention(e: Event) {
|
||||||
|
const on = (e.target as HTMLInputElement).checked;
|
||||||
|
if (on && !settings.settings.consent_acknowledged) {
|
||||||
|
showConsent = true;
|
||||||
|
(e.target as HTMLInputElement).checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recording.setRetention(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptConsentAndRecord() {
|
||||||
|
await settings.acknowledgeConsent();
|
||||||
|
await recording.setRetention(true);
|
||||||
|
showConsent = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app" data-theme="system">
|
<div class="app" data-theme="system">
|
||||||
@@ -21,14 +47,29 @@
|
|||||||
<span class="muted">local · private</span>
|
<span class="muted">local · private</span>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
{#if recording.state === "idle"}
|
{#if recording.state === "idle"}
|
||||||
<button onclick={() => recording.start()}>● Record</button>
|
<button onclick={startRecording}>● Record</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button onclick={() => recording.stop()}>■ Stop</button>
|
<button onclick={() => recording.stop()}>■ Stop</button>
|
||||||
<span class="rec">Recording…</span>
|
<span class="rec">Recording…</span>
|
||||||
|
<label class="retention" title="Save audio as .wav for this meeting">
|
||||||
|
<input type="checkbox" checked={recording.retention} onchange={onToggleRetention} />
|
||||||
|
<span>{recording.retention ? "saving" : "not saved"}</span>
|
||||||
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="icon" aria-label="Settings" title="Settings" onclick={() => (showSettings = true)}>⚙</button>
|
<button
|
||||||
|
class="icon"
|
||||||
|
aria-label="Settings"
|
||||||
|
title="Settings"
|
||||||
|
onclick={() => (showSettings = true)}>⚙</button
|
||||||
|
>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if showConsent}
|
||||||
|
<div class="consent-overlay" role="dialog" aria-modal="true" aria-label="Recording consent">
|
||||||
|
<ConsentNotice onAccept={acceptConsentAndRecord} onCancel={() => (showConsent = false)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showSettings}
|
{#if showSettings}
|
||||||
<Settings onClose={() => (showSettings = false)} />
|
<Settings onClose={() => (showSettings = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -54,15 +95,75 @@
|
|||||||
--muted: #9aa0a6;
|
--muted: #9aa0a6;
|
||||||
--border: #242832;
|
--border: #242832;
|
||||||
}
|
}
|
||||||
.app { display: flex; flex-direction: column; height: 100vh; background: var(--bg); color: var(--fg); font-family: system-ui, sans-serif; }
|
.app {
|
||||||
.bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 1rem; border-bottom: 1px solid var(--border); }
|
display: flex;
|
||||||
.spacer { flex: 1; }
|
flex-direction: column;
|
||||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
height: 100vh;
|
||||||
.rec { color: #e11d48; font-size: 0.85rem; }
|
background: var(--bg);
|
||||||
.icon { background: transparent; border: 1px solid var(--border); color: var(--fg); border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; }
|
color: var(--fg);
|
||||||
.panes { display: grid; grid-template-columns: 260px 1fr 320px; flex: 1; min-height: 0; }
|
font-family: system-ui, sans-serif;
|
||||||
.left, .right { border-color: var(--border); overflow: auto; }
|
}
|
||||||
.left { border-right: 1px solid var(--border); }
|
.bar {
|
||||||
.right { border-left: 1px solid var(--border); }
|
display: flex;
|
||||||
.center { overflow: auto; }
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.rec {
|
||||||
|
color: #e11d48;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.retention {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.consent-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--fg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.panes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr 320px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
border-color: var(--border);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -126,8 +126,12 @@ export const api = {
|
|||||||
|
|
||||||
llmStatus: () => invoke("llm_status"),
|
llmStatus: () => invoke("llm_status"),
|
||||||
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
|
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
|
||||||
setLlmProvider: (config: { provider: string; endpoint?: string; model?: string; apiKey?: string }) =>
|
setLlmProvider: (config: {
|
||||||
invoke<void>("set_llm_provider", { config }),
|
provider: string;
|
||||||
|
endpoint?: string;
|
||||||
|
model?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
}) => invoke<void>("set_llm_provider", { config }),
|
||||||
generateSummary: (meetingId: MeetingId, templateId?: string) =>
|
generateSummary: (meetingId: MeetingId, templateId?: string) =>
|
||||||
invoke<void>("generate_summary", { meetingId, templateId }),
|
invoke<void>("generate_summary", { meetingId, templateId }),
|
||||||
|
|
||||||
@@ -135,7 +139,8 @@ export const api = {
|
|||||||
|
|
||||||
// Sync (ADR-0010) — off by default; secrets never returned by listSyncTargets.
|
// Sync (ADR-0010) — off by default; secrets never returned by listSyncTargets.
|
||||||
listSyncTargets: () => invoke<SyncTargetInfo[]>("list_sync_targets"),
|
listSyncTargets: () => invoke<SyncTargetInfo[]>("list_sync_targets"),
|
||||||
addSyncTarget: (config: SyncTargetConfig) => invoke<SyncTargetInfo>("add_sync_target", { config }),
|
addSyncTarget: (config: SyncTargetConfig) =>
|
||||||
|
invoke<SyncTargetInfo>("add_sync_target", { config }),
|
||||||
updateSyncTarget: (config: SyncTargetConfig & { id: string }) =>
|
updateSyncTarget: (config: SyncTargetConfig & { id: string }) =>
|
||||||
invoke<SyncTargetInfo>("update_sync_target", { config }),
|
invoke<SyncTargetInfo>("update_sync_target", { config }),
|
||||||
removeSyncTarget: (id: string) => invoke<void>("remove_sync_target", { id }),
|
removeSyncTarget: (id: string) => invoke<void>("remove_sync_target", { id }),
|
||||||
@@ -164,13 +169,17 @@ export const api = {
|
|||||||
invoke<void>("set_mcp_scope", { expose, exposeRecordings }),
|
invoke<void>("set_mcp_scope", { expose, exposeRecordings }),
|
||||||
mcpAccessLog: (limit?: number) => invoke<McpAccessEntry[]>("mcp_access_log", { limit }),
|
mcpAccessLog: (limit?: number) => invoke<McpAccessEntry[]>("mcp_access_log", { limit }),
|
||||||
// Layer 3 (later) push handoff.
|
// Layer 3 (later) push handoff.
|
||||||
runAgent: (briefId: string, tool: "claude" | "codex" | "opencode" | "copilot", repoPath: string) =>
|
runAgent: (
|
||||||
invoke<{ ok: boolean }>("run_agent", { briefId, tool, repoPath }),
|
briefId: string,
|
||||||
|
tool: "claude" | "codex" | "opencode" | "copilot",
|
||||||
|
repoPath: string,
|
||||||
|
) => invoke<{ ok: boolean }>("run_agent", { briefId, tool, repoPath }),
|
||||||
createIssueFromBrief: (briefId: string, tracker: "github", assignCopilot?: boolean) =>
|
createIssueFromBrief: (briefId: string, tracker: "github", assignCopilot?: boolean) =>
|
||||||
invoke<{ url: string }>("create_issue_from_brief", { briefId, tracker, assignCopilot }),
|
invoke<{ url: string }>("create_issue_from_brief", { briefId, tracker, assignCopilot }),
|
||||||
|
|
||||||
getSettings: () => invoke<AppSettings>("get_settings"),
|
getSettings: () => invoke<AppSettings>("get_settings"),
|
||||||
updateSettings: (patch: Partial<AppSettings>) => invoke<AppSettings>("update_settings", { patch }),
|
updateSettings: (patch: Partial<AppSettings>) =>
|
||||||
|
invoke<AppSettings>("update_settings", { patch }),
|
||||||
privacySelfCheck: () => invoke("privacy_self_check"),
|
privacySelfCheck: () => invoke("privacy_self_check"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,18 +187,31 @@ export const api = {
|
|||||||
export const events = {
|
export const events = {
|
||||||
onRecordingState: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
|
onRecordingState: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
|
||||||
listen("recording://state", (e) => cb(e.payload)),
|
listen("recording://state", (e) => cb(e.payload)),
|
||||||
|
onRetention: (cb: (p: { meetingId: string; record: boolean }) => void): Promise<UnlistenFn> =>
|
||||||
|
listen("recording://retention", (e) => cb(e.payload as never)),
|
||||||
onLevel: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
|
onLevel: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
|
||||||
listen("recording://level", (e) => cb(e.payload)),
|
listen("recording://level", (e) => cb(e.payload)),
|
||||||
onSegment: (cb: (p: { meetingId: string; segment: TranscriptSegment }) => void): Promise<UnlistenFn> =>
|
onSegment: (
|
||||||
listen("transcript://segment", (e) => cb(e.payload as never)),
|
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
|
||||||
|
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
|
||||||
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
|
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
|
||||||
listen("llm://token", (e) => cb(e.payload as never)),
|
listen("llm://token", (e) => cb(e.payload as never)),
|
||||||
onSyncJob: (cb: (p: { jobId: string; meetingId: string; targetId: string; artifact: string; status: string; bytesSent: number; bytesTotal: number }) => void): Promise<UnlistenFn> =>
|
onSyncJob: (
|
||||||
listen("sync://job", (e) => cb(e.payload as never)),
|
cb: (p: {
|
||||||
|
jobId: string;
|
||||||
|
meetingId: string;
|
||||||
|
targetId: string;
|
||||||
|
artifact: string;
|
||||||
|
status: string;
|
||||||
|
bytesSent: number;
|
||||||
|
bytesTotal: number;
|
||||||
|
}) => void,
|
||||||
|
): Promise<UnlistenFn> => listen("sync://job", (e) => cb(e.payload as never)),
|
||||||
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
|
onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise<UnlistenFn> =>
|
||||||
listen("mcp://access", (e) => cb(e.payload as never)),
|
listen("mcp://access", (e) => cb(e.payload as never)),
|
||||||
onAgentProgress: (cb: (p: { briefId: string; tool: string; line: string }) => void): Promise<UnlistenFn> =>
|
onAgentProgress: (
|
||||||
listen("agent://progress", (e) => cb(e.payload as never)),
|
cb: (p: { briefId: string; tool: string; line: string }) => void,
|
||||||
|
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
|
||||||
onError: (cb: (p: { kind: string; message: string }) => void): Promise<UnlistenFn> =>
|
onError: (cb: (p: { kind: string; message: string }) => void): Promise<UnlistenFn> =>
|
||||||
listen("error", (e) => cb(e.payload as never)),
|
listen("error", (e) => cb(e.payload as never)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// One-time recording-consent notice (ADR-0009, FR-REC-2). Shared by the
|
||||||
|
// Settings "Record by default" toggle and the mid-meeting retention toggle
|
||||||
|
// so both gate on the same copy and the same acknowledgment call.
|
||||||
|
let { onAccept, onCancel }: { onAccept: () => void; onCancel: () => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="consent">
|
||||||
|
<p>
|
||||||
|
<strong>Before you record:</strong> recording conversations without the consent of participants may
|
||||||
|
be illegal in your region. Check your local recording laws. This is a caution, not legal advice.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" onclick={onAccept}>I understand — enable recording</button>
|
||||||
|
<button onclick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.consent {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,8 @@ class RecordingStore {
|
|||||||
state = $state<"idle" | "recording" | "paused">("idle");
|
state = $state<"idle" | "recording" | "paused">("idle");
|
||||||
elapsedMs = $state(0);
|
elapsedMs = $state(0);
|
||||||
segments = $state<TranscriptSegment[]>([]);
|
segments = $state<TranscriptSegment[]>([]);
|
||||||
|
/** Whether audio is being retained as .wav for the in-flight meeting (ADR-0009). */
|
||||||
|
retention = $state(false);
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await events.onRecordingState((p) => {
|
await events.onRecordingState((p) => {
|
||||||
@@ -15,6 +17,9 @@ class RecordingStore {
|
|||||||
this.state = e.state === "stopped" ? "idle" : e.state;
|
this.state = e.state === "stopped" ? "idle" : e.state;
|
||||||
this.elapsedMs = e.elapsedMs ?? this.elapsedMs;
|
this.elapsedMs = e.elapsedMs ?? this.elapsedMs;
|
||||||
});
|
});
|
||||||
|
await events.onRetention((p) => {
|
||||||
|
this.retention = p.record;
|
||||||
|
});
|
||||||
await events.onSegment(({ segment }) => {
|
await events.onSegment(({ segment }) => {
|
||||||
// Replace an interim segment with the same id, else append.
|
// Replace an interim segment with the same id, else append.
|
||||||
const i = this.segments.findIndex((s) => s.id === segment.id);
|
const i = this.segments.findIndex((s) => s.id === segment.id);
|
||||||
@@ -23,9 +28,10 @@ class RecordingStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(title?: string) {
|
async start(title?: string, record = false) {
|
||||||
this.segments = [];
|
this.segments = [];
|
||||||
this.meetingId = await api.startRecording(title);
|
this.retention = record;
|
||||||
|
this.meetingId = await api.startRecording(title, undefined, record);
|
||||||
this.state = "recording";
|
this.state = "recording";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +39,13 @@ class RecordingStore {
|
|||||||
if (this.meetingId) await api.stopRecording(this.meetingId);
|
if (this.meetingId) await api.stopRecording(this.meetingId);
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle audio retention mid-meeting (FR-REC-1); caller must have gated consent already. */
|
||||||
|
async setRetention(record: boolean) {
|
||||||
|
if (!this.meetingId) return;
|
||||||
|
await api.setRecordingRetention(this.meetingId, record);
|
||||||
|
this.retention = record;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const recording = new RecordingStore();
|
export const recording = new RecordingStore();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// is fully demonstrable. Replace nothing here when Phase 9 lands — it just starts persisting.
|
// is fully demonstrable. Replace nothing here when Phase 9 lands — it just starts persisting.
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { settings } from "../stores/settings.svelte";
|
import { settings } from "../stores/settings.svelte";
|
||||||
|
import ConsentNotice from "../components/ConsentNotice.svelte";
|
||||||
import type { SyncKind, SyncTargetConfig } from "../api";
|
import type { SyncKind, SyncTargetConfig } from "../api";
|
||||||
|
|
||||||
let { onClose }: { onClose: () => void } = $props();
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
@@ -56,9 +57,6 @@
|
|||||||
form = blankForm(k);
|
form = blankForm(k);
|
||||||
testResult = null;
|
testResult = null;
|
||||||
}
|
}
|
||||||
function isThirdParty(k: SyncKind) {
|
|
||||||
return k !== "webdav";
|
|
||||||
}
|
|
||||||
async function testConnection() {
|
async function testConnection() {
|
||||||
const r = await settings.test(form);
|
const r = await settings.test(form);
|
||||||
testResult = (r.ok ? "✓ " : "✗ ") + r.message;
|
testResult = (r.ok ? "✓ " : "✗ ") + r.message;
|
||||||
@@ -79,21 +77,30 @@
|
|||||||
<header>
|
<header>
|
||||||
<strong>Settings</strong>
|
<strong>Settings</strong>
|
||||||
<nav>
|
<nav>
|
||||||
<button class:active={section === "recording"} onclick={() => (section = "recording")}>Recording</button>
|
<button class:active={section === "recording"} onclick={() => (section = "recording")}
|
||||||
|
>Recording</button
|
||||||
|
>
|
||||||
<button class:active={section === "sync"} onclick={() => (section = "sync")}>Sync</button>
|
<button class:active={section === "sync"} onclick={() => (section = "sync")}>Sync</button>
|
||||||
</nav>
|
</nav>
|
||||||
<button class="close" onclick={onClose} aria-label="Close">✕</button>
|
<button class="close" onclick={onClose} aria-label="Close">✕</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if settings.backendStub}
|
{#if settings.backendStub}
|
||||||
<p class="stub">Preview mode — the backend for these settings isn't implemented yet (Phase 9). Changes are kept in the UI only.</p>
|
<p class="stub">
|
||||||
|
Preview mode — the backend for these settings isn't implemented yet (Phase 9). Changes are
|
||||||
|
kept in the UI only.
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if section === "recording"}
|
{#if section === "recording"}
|
||||||
<section>
|
<section>
|
||||||
<h3>Recording</h3>
|
<h3>Recording</h3>
|
||||||
<label class="row">
|
<label class="row">
|
||||||
<input type="checkbox" checked={settings.settings.default_record} onchange={onToggleDefaultRecord} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.settings.default_record}
|
||||||
|
onchange={onToggleDefaultRecord}
|
||||||
|
/>
|
||||||
<span>Record meetings by default <em>(save audio as <code>.wav</code>)</em></span>
|
<span>Record meetings by default <em>(save audio as <code>.wav</code>)</em></span>
|
||||||
</label>
|
</label>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@@ -101,21 +108,13 @@
|
|||||||
the meeting is finalized. You can also toggle recording per meeting.
|
the meeting is finalized. You can also toggle recording per meeting.
|
||||||
</p>
|
</p>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Consent: {settings.settings.consent_acknowledged ? "acknowledged" : "not yet acknowledged"}.
|
Consent: {settings.settings.consent_acknowledged
|
||||||
|
? "acknowledged"
|
||||||
|
: "not yet acknowledged"}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if showConsent}
|
{#if showConsent}
|
||||||
<div class="consent">
|
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
|
||||||
<p>
|
|
||||||
<strong>Before you record:</strong> recording conversations without the consent of
|
|
||||||
participants may be illegal in your region. Check your local recording laws. This is a
|
|
||||||
caution, not legal advice.
|
|
||||||
</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="primary" onclick={acceptConsent}>I understand — enable recording</button>
|
|
||||||
<button onclick={() => (showConsent = false)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -142,12 +141,20 @@
|
|||||||
{#each settings.targets as t (t.id)}
|
{#each settings.targets as t (t.id)}
|
||||||
<li>
|
<li>
|
||||||
<label class="tg">
|
<label class="tg">
|
||||||
<input type="checkbox" checked={t.enabled} onchange={() => settings.toggleTargetEnabled(t)} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={t.enabled}
|
||||||
|
onchange={() => settings.toggleTargetEnabled(t)}
|
||||||
|
/>
|
||||||
<span class="name">{t.name || t.kind}</span>
|
<span class="name">{t.name || t.kind}</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="badge" class:third={t.third_party}>{t.third_party ? "third-party" : "your server"}</span>
|
<span class="badge" class:third={t.third_party}
|
||||||
|
>{t.third_party ? "third-party" : "your server"}</span
|
||||||
|
>
|
||||||
<span class="host">{t.host ?? t.kind}</span>
|
<span class="host">{t.host ?? t.kind}</span>
|
||||||
<button class="link danger" onclick={() => settings.removeTarget(t.id)}>Remove</button>
|
<button class="link danger" onclick={() => settings.removeTarget(t.id)}
|
||||||
|
>Remove</button
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -155,91 +162,297 @@
|
|||||||
|
|
||||||
<h4>Add a target</h4>
|
<h4>Add a target</h4>
|
||||||
<div class="kinds">
|
<div class="kinds">
|
||||||
{#each ["webdav", "onedrive", "dropbox", "box"] as k}
|
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
|
||||||
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}>{k}</button>
|
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
|
||||||
|
>{k}</button
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form.kind === "webdav"}
|
{#if form.kind === "webdav"}
|
||||||
<p class="muted">WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable SeafDAV server-side.)</p>
|
<p class="muted">
|
||||||
|
WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable
|
||||||
|
SeafDAV server-side.)
|
||||||
|
</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Name<input bind:value={form.name} placeholder="Home Nextcloud" /></label>
|
<label>Name<input bind:value={form.name} placeholder="Home Nextcloud" /></label>
|
||||||
<label>Provider
|
<label
|
||||||
|
>Provider
|
||||||
<select bind:value={form.provider_hint}>
|
<select bind:value={form.provider_hint}>
|
||||||
{#each PROVIDERS as p}<option value={p}>{p}</option>{/each}
|
{#each PROVIDERS as p (p)}<option value={p}>{p}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="wide">Server URL<input bind:value={form.base_url} placeholder="https://cloud.example.com/remote.php/dav/files/me/" /></label>
|
<label class="wide"
|
||||||
|
>Server URL<input
|
||||||
|
bind:value={form.base_url}
|
||||||
|
placeholder="https://cloud.example.com/remote.php/dav/files/me/"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
<label>Remote folder<input bind:value={form.remote_base_path} /></label>
|
<label>Remote folder<input bind:value={form.remote_base_path} /></label>
|
||||||
<label>Username<input bind:value={form.username} /></label>
|
<label>Username<input bind:value={form.username} /></label>
|
||||||
<label>App password<input type="password" bind:value={form.secret} placeholder="stored in OS credential store" /></label>
|
<label
|
||||||
|
>App password<input
|
||||||
|
type="password"
|
||||||
|
bind:value={form.secret}
|
||||||
|
placeholder="stored in OS credential store"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="artifacts">
|
<fieldset class="artifacts">
|
||||||
<legend>Upload</legend>
|
<legend>Upload</legend>
|
||||||
<label><input type="checkbox" bind:checked={form.upload_transcript} /> transcript</label>
|
<label><input type="checkbox" bind:checked={form.upload_transcript} /> transcript</label
|
||||||
|
>
|
||||||
<label><input type="checkbox" bind:checked={form.upload_notes} /> notes</label>
|
<label><input type="checkbox" bind:checked={form.upload_notes} /> notes</label>
|
||||||
<label><input type="checkbox" bind:checked={form.upload_summary} /> summary</label>
|
<label><input type="checkbox" bind:checked={form.upload_summary} /> summary</label>
|
||||||
<label><input type="checkbox" bind:checked={form.upload_recording} /> recording (.wav, if retained)</label>
|
<label
|
||||||
|
><input type="checkbox" bind:checked={form.upload_recording} /> recording (.wav, if retained)</label
|
||||||
|
>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label class="row small"><input type="checkbox" bind:checked={form.allow_plaintext_lan} /> Allow plaintext http for a LAN address (not recommended)</label>
|
<label class="row small"
|
||||||
|
><input type="checkbox" bind:checked={form.allow_plaintext_lan} /> Allow plaintext http for
|
||||||
|
a LAN address (not recommended)</label
|
||||||
|
>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick={testConnection}>Test connection</button>
|
<button onclick={testConnection}>Test connection</button>
|
||||||
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}>Add target</button>
|
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}
|
||||||
|
>Add target</button
|
||||||
|
>
|
||||||
{#if testResult}<span class="test">{testResult}</span>{/if}
|
{#if testResult}<span class="test">{testResult}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="banner">⚠ {form.kind} is a third-party cloud — uploading sends your data off your device to {form.kind}.</div>
|
<div class="banner">
|
||||||
|
⚠ {form.kind} is a third-party cloud — uploading sends your data off your device to {form.kind}.
|
||||||
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label class="wide">Name<input bind:value={form.name} placeholder={form.kind} /></label>
|
<label class="wide">Name<input bind:value={form.name} placeholder={form.kind} /></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="primary" onclick={linkOAuth}>Link {form.kind} account…</button>
|
<button class="primary" onclick={linkOAuth}>Link {form.kind} account…</button>
|
||||||
<span class="muted">Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.</span>
|
<span class="muted"
|
||||||
|
>Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential
|
||||||
|
store.</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="muted footnote">Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.</p>
|
<p class="muted footnote">
|
||||||
|
Credentials are never written to settings or the database — only the OS credential store.
|
||||||
|
TLS is required for non-LAN targets.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: grid; place-items: center; z-index: 50; }
|
.overlay {
|
||||||
.panel { background: var(--bg); color: var(--fg); width: min(720px, 92vw); max-height: 88vh; overflow: auto; border: 1px solid var(--border); border-radius: 10px; padding: 0 1.25rem 1.25rem; }
|
position: fixed;
|
||||||
header { position: sticky; top: 0; background: var(--bg); display: flex; align-items: center; gap: 1rem; padding: 0.9rem 0; border-bottom: 1px solid var(--border); }
|
inset: 0;
|
||||||
nav { display: flex; gap: 0.25rem; }
|
background: rgba(0, 0, 0, 0.45);
|
||||||
nav button, .kinds button { background: transparent; border: 1px solid var(--border); color: var(--fg); padding: 0.3rem 0.7rem; border-radius: 6px; cursor: pointer; }
|
display: grid;
|
||||||
nav button.active, .kinds button.active { background: var(--fg); color: var(--bg); }
|
place-items: center;
|
||||||
.close { margin-left: auto; background: transparent; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; }
|
z-index: 50;
|
||||||
h3 { margin: 1rem 0 0.5rem; }
|
}
|
||||||
h4 { margin: 1.25rem 0 0.4rem; font-size: 0.9rem; }
|
.panel {
|
||||||
.row { display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0; }
|
background: var(--bg);
|
||||||
.row.small { font-size: 0.85rem; color: var(--muted); }
|
color: var(--fg);
|
||||||
.muted { color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
|
width: min(720px, 92vw);
|
||||||
.footnote { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.6rem; }
|
max-height: 88vh;
|
||||||
em { color: var(--muted); font-style: normal; }
|
overflow: auto;
|
||||||
code { background: var(--border); padding: 0 0.25rem; border-radius: 3px; }
|
border: 1px solid var(--border);
|
||||||
.stub { background: color-mix(in srgb, orange 18%, var(--bg)); border: 1px solid var(--border); padding: 0.5rem 0.7rem; border-radius: 6px; font-size: 0.82rem; }
|
border-radius: 10px;
|
||||||
.consent { border: 1px solid var(--border); border-radius: 8px; padding: 0.8rem; margin-top: 0.6rem; }
|
padding: 0 1.25rem 1.25rem;
|
||||||
.actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin-top: 0.6rem; }
|
}
|
||||||
button.primary { background: #2563eb; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; }
|
header {
|
||||||
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
position: sticky;
|
||||||
.kinds { display: flex; gap: 0.4rem; margin: 0.5rem 0; }
|
top: 0;
|
||||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin: 0.5rem 0; }
|
background: var(--bg);
|
||||||
.grid label { display: flex; flex-direction: column; font-size: 0.8rem; gap: 0.2rem; }
|
display: flex;
|
||||||
.grid label.wide { grid-column: 1 / -1; }
|
align-items: center;
|
||||||
input, select { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 0.35rem; }
|
gap: 1rem;
|
||||||
.artifacts { border: 1px solid var(--border); border-radius: 6px; display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.85rem; }
|
padding: 0.9rem 0;
|
||||||
.artifacts label { display: flex; align-items: center; gap: 0.3rem; }
|
border-bottom: 1px solid var(--border);
|
||||||
.banner { background: color-mix(in srgb, #e11d48 14%, var(--bg)); border: 1px solid var(--border); padding: 0.5rem 0.7rem; border-radius: 6px; font-size: 0.85rem; margin: 0.5rem 0; }
|
}
|
||||||
ul.targets { list-style: none; padding: 0; margin: 0.3rem 0; }
|
nav {
|
||||||
ul.targets li { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
|
display: flex;
|
||||||
.tg { display: flex; align-items: center; gap: 0.4rem; flex: 1; }
|
gap: 0.25rem;
|
||||||
.badge { font-size: 0.7rem; padding: 0.1rem 0.4rem; border: 1px solid var(--border); border-radius: 10px; color: var(--muted); }
|
}
|
||||||
.badge.third { color: #e11d48; }
|
nav button,
|
||||||
.host { font-size: 0.75rem; color: var(--muted); }
|
.kinds button {
|
||||||
.link { background: none; border: none; cursor: pointer; color: var(--muted); }
|
background: transparent;
|
||||||
.link.danger { color: #e11d48; }
|
border: 1px solid var(--border);
|
||||||
.test { font-size: 0.82rem; color: var(--muted); }
|
color: var(--fg);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
nav button.active,
|
||||||
|
.kinds button.active {
|
||||||
|
background: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
margin-left: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
margin: 1.25rem 0 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.row.small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.footnote {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
em {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: var(--border);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.stub {
|
||||||
|
background: color-mix(in srgb, orange 18%, var(--bg));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.kinds {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.grid label.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
.artifacts {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.artifacts label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.banner {
|
||||||
|
background: color-mix(in srgb, #e11d48 14%, var(--bg));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
ul.targets {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
ul.targets li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.badge.third {
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
.host {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.link.danger {
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
.test {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
watch: {
|
||||||
|
// Without this, Vite's watcher races cargo writing target/debug/deps/*.dll
|
||||||
|
// on Windows and dies with EBUSY mid-build.
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
|
|||||||