This commit is contained in:
iamdoubz
2026-06-30 23:18:29 -05:00
parent 849fa79647
commit f9b172aff4
24 changed files with 1867 additions and 177 deletions
+46 -2
View File
@@ -9,12 +9,16 @@
"version": "0.0.0", "version": "0.0.0",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0" "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"dompurify": "^3.4.11",
"marked": "^18.0.5"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.0.0",
"@tsconfig/svelte": "^5.0.0", "@tsconfig/svelte": "^5.0.0",
"@types/dompurify": "^3.0.5",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-plugin-svelte": "^3.20.0", "eslint-plugin-svelte": "^3.20.0",
"globals": "^17.7.0", "globals": "^17.7.0",
@@ -1383,6 +1387,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tsconfig/svelte": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
@@ -1390,6 +1403,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1408,7 +1431,7 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
@@ -1944,6 +1967,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -2575,6 +2607,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/marked": {
"version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+5 -1
View File
@@ -18,6 +18,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.0.0",
"@tsconfig/svelte": "^5.0.0", "@tsconfig/svelte": "^5.0.0",
"@types/dompurify": "^3.0.5",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-plugin-svelte": "^3.20.0", "eslint-plugin-svelte": "^3.20.0",
"globals": "^17.7.0", "globals": "^17.7.0",
@@ -31,6 +32,9 @@
"vite": "^5.4.0" "vite": "^5.4.0"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0" "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"dompurify": "^3.4.11",
"marked": "^18.0.5"
} }
} }
+84
View File
@@ -2408,6 +2408,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -3109,6 +3110,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -4153,6 +4178,64 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -5138,6 +5221,7 @@ dependencies = [
"sqlx", "sqlx",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tracing", "tracing",
+1
View File
@@ -42,6 +42,7 @@ rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP serve
# 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) whisper-rs = { version = "0.16", optional = true } # whisper.cpp bindings (Phase 1)
tauri-plugin-dialog = "2" # native Save/choose-folder (Phase 2 export)
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [ windows = { version = "0.58", features = [
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "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.", "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"], "windows": ["main"],
"permissions": ["core:default"] "permissions": ["core:default", "dialog:default"]
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +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"]}} {"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","dialog:default"]}}
+66
View File
@@ -2191,6 +2191,72 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
} }
] ]
}, },
+66
View File
@@ -2191,6 +2191,72 @@
"type": "string", "type": "string",
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
} }
] ]
}, },
+127
View File
@@ -344,6 +344,66 @@ impl Resampler {
} }
} }
/// Read a WAV file written by this app's own `WavWriter` (Float32 or Int16;
/// see `write_wav_bytes`) and return mono `f32` samples at 16kHz — the batch
/// counterpart to the live capture path's downmix/resample, used for crash
/// recovery (`Transcriber::transcribe_file`, T2.8).
///
/// A working WAV left behind by an abnormal exit never had `finalize()` patch
/// its RIFF/`data` chunk sizes, so they read back as 0 — exactly the file
/// recovery has to work from (FR-REL-1). Trusting hound's own duration/sample
/// iterator would silently see zero frames despite real PCM bytes on disk (and
/// feeding whisper.cpp zero samples hangs rather than erroring — verified).
/// So this reads the `data` payload as raw bytes directly off disk instead of
/// through hound's size-aware iterator; hound is only used to parse the
/// format/`fmt ` chunk.
pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
let reader =
hound::WavReader::open(path).map_err(|e| AudioError::Capture(format!("open wav: {e}")))?;
let spec = reader.spec();
let channels = spec.channels.max(1) as usize;
drop(reader);
let file = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?;
let data_marker = file
.windows(4)
.position(|w| w == b"data")
.ok_or_else(|| AudioError::Capture("wav has no data chunk".into()))?;
let payload_start = data_marker + 8; // "data" + 4-byte (possibly unfinalized) size field
let bytes = file.get(payload_start..).unwrap_or(&[]);
let mono: Vec<f32> = match (spec.sample_format, spec.bits_per_sample) {
(SampleFormat::Float, 32) => bytes
.chunks_exact(4 * channels)
.map(|frame| {
frame
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.sum::<f32>()
/ channels as f32
})
.collect(),
(SampleFormat::Int, 16) => bytes
.chunks_exact(2 * channels)
.map(|frame| {
frame
.chunks_exact(2)
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32)
.sum::<f32>()
/ channels as f32
})
.collect(),
(fmt, bits) => {
return Err(AudioError::Device(format!(
"unsupported wav format: {fmt:?} {bits}-bit"
)))
}
};
let mut resampler = Resampler::new(spec.sample_rate);
Ok(resampler.process(&mono))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -381,6 +441,73 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn read_wav_mono_16k_reads_a_normally_finalized_file() {
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: 1,
sample_rate: 16_000,
bits_per_sample: 32,
sample_format: SampleFormat::Float,
};
let mut writer = WavWriter::create(&path, spec).unwrap();
for i in 0..1600 {
writer.write_sample((i as f32 / 1600.0) - 0.5).unwrap();
}
writer.finalize().unwrap();
let mono = read_wav_mono_16k(&path).unwrap();
assert_eq!(mono.len(), 1600); // already 16kHz mono, no resampling needed
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn read_wav_mono_16k_recovers_samples_from_an_unfinalized_crash_file() {
// hound's own `WavWriter` patches the RIFF/data sizes in both
// `finalize()` and `flush()`, and its `Drop` impl finalizes too — so
// there's no way to get it to leave an unfinalized file on disk short
// of `mem::forget`-ing mid-write, which depends on `BufWriter`'s
// internal (unspecified) flush threshold. Building the crashed file
// by hand instead gives a deterministic repro of exactly what a real
// crash leaves: a valid `fmt ` chunk with the RIFF/data sizes at 0.
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 samples: Vec<f32> = (0..1600).map(|i| (i as f32 / 1600.0) - 0.5).collect();
let pcm: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
let mut file = Vec::new();
file.extend_from_slice(b"RIFF");
file.extend_from_slice(&0u32.to_le_bytes()); // unfinalized RIFF size
file.extend_from_slice(b"WAVE");
file.extend_from_slice(b"fmt ");
file.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size
file.extend_from_slice(&3u16.to_le_bytes()); // WAVE_FORMAT_IEEE_FLOAT
file.extend_from_slice(&1u16.to_le_bytes()); // channels
file.extend_from_slice(&16_000u32.to_le_bytes()); // sample rate
file.extend_from_slice(&(16_000 * 4u32).to_le_bytes()); // byte rate
file.extend_from_slice(&4u16.to_le_bytes()); // block align
file.extend_from_slice(&32u16.to_le_bytes()); // bits per sample
file.extend_from_slice(b"data");
file.extend_from_slice(&0u32.to_le_bytes()); // unfinalized data size
file.extend_from_slice(&pcm);
std::fs::write(&path, &file).unwrap();
let mono = read_wav_mono_16k(&path).unwrap();
assert_eq!(
mono.len(),
1600,
"should recover real samples despite the zeroed header"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn decode_mono_f32_downmixes_stereo() { fn decode_mono_f32_downmixes_stereo() {
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None); let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
+238 -44
View File
@@ -8,10 +8,14 @@
use crate::audio::{AudioCapture, WasapiCapture}; use crate::audio::{AudioCapture, WasapiCapture};
use crate::error::WaResult; use crate::error::WaResult;
use crate::models::*; use crate::models::*;
use crate::notes::NotesRenderer;
use crate::paths::{meeting_dir, settings_path, wa_root, whisper_model_path};
use crate::storage::{FinalizeMeeting, Meeting, NewMeeting};
use crate::transcription::{run_streaming_worker, Transcriber, WhisperTranscriber}; use crate::transcription::{run_streaming_worker, Transcriber, WhisperTranscriber};
use crate::{error::WaError, AppState, RecordingSession}; use crate::{error::WaError, AppState, RecordingSession};
use serde::Deserialize; use serde::Deserialize;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -23,30 +27,9 @@ pub struct StartRecordingArgs {
pub record: bool, pub record: bool,
} }
// ---- Phase 1 file-backed settings + storage paths ---- // ---- File-backed settings (consent/default-retention/storage policy) ----
// Full SQLite persistence is Phase 2 (`storage::Store`, still `todo!()`); Phase // `settings.json` per `docs/03-data-model.md`; meeting rows/files go through
// 1 only needs `settings.json` (consent/default-retention, FR-REC-1/2) and the // `AppState.store` (Phase 2, `storage::SqliteStore`).
// `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 { fn default_settings() -> Settings {
Settings { Settings {
@@ -60,10 +43,12 @@ fn default_settings() -> Settings {
default_record: false, default_record: false,
consent_acknowledged: false, consent_acknowledged: false,
sync_enabled: false, sync_enabled: false,
retention_max_age_days: None,
retention_max_size_gb: None,
} }
} }
fn load_settings() -> Settings { pub(crate) fn load_settings() -> Settings {
std::fs::read_to_string(settings_path()) std::fs::read_to_string(settings_path())
.ok() .ok()
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
@@ -114,10 +99,17 @@ pub async fn start_recording(
)); ));
} }
let meeting_id = uuid::Uuid::new_v4().to_string(); let meeting_id = state
let meeting_dir = meetings_dir().join(&meeting_id); .store
std::fs::create_dir_all(&meeting_dir).map_err(|e| WaError::new("storage", e.to_string()))?; .create_meeting(NewMeeting {
let wav_path = meeting_dir.join("audio.wav"); title: args
.meeting_title
.unwrap_or_else(|| "Untitled meeting".to_string()),
calendar_event_id: args.calendar_event_id,
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
// Bounded so a slow/stalled transcription worker can never back up the capture thread. // 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 (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
@@ -125,6 +117,8 @@ pub async fn start_recording(
.start(&wav_path, frame_tx) .start(&wav_path, frame_tx)
.map_err(|e| WaError::new("audio", e.to_string()))?; .map_err(|e| WaError::new("audio", e.to_string()))?;
let segments: Arc<StdMutex<Vec<TranscriptSegment>>> = Arc::new(StdMutex::new(Vec::new()));
let segments_for_worker = segments.clone();
let app_for_worker = app.clone(); let app_for_worker = app.clone();
let meeting_id_for_worker = meeting_id.clone(); let meeting_id_for_worker = meeting_id.clone();
let transcription_worker = std::thread::Builder::new() let transcription_worker = std::thread::Builder::new()
@@ -132,6 +126,9 @@ pub async fn start_recording(
.spawn( .spawn(
move || match WhisperTranscriber::load(&model_path, BackendId::Cpu) { move || match WhisperTranscriber::load(&model_path, BackendId::Cpu) {
Ok(transcriber) => run_streaming_worker(&transcriber, frame_rx, |segment| { Ok(transcriber) => run_streaming_worker(&transcriber, frame_rx, |segment| {
if let Ok(mut buf) = segments_for_worker.lock() {
buf.push(segment.clone());
}
let _ = app_for_worker.emit( let _ = app_for_worker.emit(
"transcript://segment", "transcript://segment",
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }), serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
@@ -149,6 +146,7 @@ pub async fn start_recording(
wav_path, wav_path,
started_at: std::time::Instant::now(), started_at: std::time::Instant::now(),
transcription_worker, transcription_worker,
segments,
}); });
drop(guard); drop(guard);
@@ -193,13 +191,53 @@ pub async fn stop_recording(
// guarantee it has fully drained the audio before we act on retention. // guarantee it has fully drained the audio before we act on retention.
let _ = session.transcription_worker.join(); let _ = session.transcription_worker.join();
let segments = session
.segments
.lock()
.map(|g| g.clone())
.unwrap_or_default();
let segment_count = segments.len();
// No diarization until Phase 4 — every segment is provisionally "S1".
let speakers = vec![SpeakerInfo {
label: "S1".to_string(),
display_name: None,
participant_id: None,
}];
let model_used = whisper_model_path()
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string);
// T2.10: persist transcript.json (via finalize_meeting) and notes.md
// *before* touching the working WAV, so a crash here still leaves a
// recoverable, regenerable meeting.
state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs: (summary.duration_ms / 1000) as i64,
recorded: session.retention,
language: None,
backend_used: Some(BackendId::Cpu.as_str().to_string()),
model_used,
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None);
let _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md);
let _ = app.emit( let _ = app.emit(
"transcript://finalized", "transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": serde_json::Value::Null }), serde_json::json!({ "meetingId": meeting_id, "segmentCount": segment_count }),
); );
// ADR-0009: delete the working WAV only after the transcript worker has // ADR-0009: delete the working WAV only after the transcript is finalized
// finished reading it, and only when retention is off. // above, and only when retention is off.
if !session.retention { if !session.retention {
let _ = std::fs::remove_file(&session.wav_path); let _ = std::fs::remove_file(&session.wav_path);
} }
@@ -299,35 +337,191 @@ pub async fn hardware_status() -> WaResult<Vec<BackendInfo>> {
todo!("Phase 3 — hardware_status") todo!("Phase 3 — hardware_status")
} }
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
/// (T2.8, FR-REL-1). CPU-bound, so it runs on a blocking task rather than
/// tying up an async worker.
#[tauri::command]
pub async fn resume_transcription(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
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 wav_path = meeting_dir(&meeting_id).join("audio.wav");
if !wav_path.exists() {
return Err(WaError::new(
"recovery",
"no working audio.wav to recover from",
));
}
// Note: deliberately doesn't emit `recording://state` — that event drives
// the main header's single live-session Record/Stop toggle, and reusing
// it here would make the header show a phantom "Stop" button for a
// recovery run that isn't a `RecordingSession` at all. The "recovering"
// badge in the meetings list is this operation's own progress signal.
let segments = tauri::async_runtime::spawn_blocking(move || {
WhisperTranscriber::load(&model_path, BackendId::Cpu)
.and_then(|transcriber| transcriber.transcribe_file(&wav_path))
})
.await
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
let speakers = vec![SpeakerInfo {
label: "S1".to_string(),
display_name: None,
participant_id: None,
}];
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(0);
let model_used = whisper_model_path()
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string);
state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs,
// The working WAV surviving a crash is the only signal we have
// left about intent; keep it rather than silently discard it.
recorded: true,
language: None,
backend_used: Some(BackendId::Cpu.as_str().to_string()),
model_used,
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None);
let _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md);
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
Ok(())
}
// ---- Meetings / storage (Phase 2) ---- // ---- Meetings / storage (Phase 2) ----
#[tauri::command] #[tauri::command]
pub async fn list_meetings(_query: Option<String>) -> WaResult<Vec<MeetingListItem>> { pub async fn list_meetings(
todo!("Phase 2 — list_meetings") state: State<'_, AppState>,
query: Option<String>,
) -> WaResult<Vec<MeetingListItem>> {
state
.store
.list_meetings(query)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
} }
#[tauri::command] #[tauri::command]
pub async fn get_meeting(_meeting_id: MeetingId) -> WaResult<MeetingListItem> { pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<Meeting> {
todo!("Phase 2 — get_meeting (returns full Meeting incl. transcript)") state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
} }
#[tauri::command] #[tauri::command]
pub async fn delete_meeting(_meeting_id: MeetingId) -> WaResult<()> { pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<()> {
todo!("Phase 2 — delete_meeting") let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"recording",
"cannot delete a meeting that is currently recording",
));
}
drop(guard);
state
.store
.delete_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
} }
#[tauri::command] #[tauri::command]
pub async fn update_notes(_meeting_id: MeetingId, _markdown: String) -> WaResult<()> { pub async fn update_notes(
todo!("Phase 2 — update_notes") state: State<'_, AppState>,
meeting_id: MeetingId,
markdown: String,
) -> WaResult<()> {
state
.store
.update_notes(&meeting_id, &markdown)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
} }
/// `format` ∈ `md | bundle` (PDF/Word stay Phase 8). `dest` is a file path for
/// `md`, a destination folder for `bundle` (the frontend gets it from a native
/// Save/choose-folder dialog).
#[tauri::command] #[tauri::command]
pub async fn export_meeting( pub async fn export_meeting(
_meeting_id: MeetingId, state: State<'_, AppState>,
_dest: String, meeting_id: MeetingId,
_format: String, dest: String,
format: String,
) -> WaResult<String> { ) -> WaResult<String> {
todo!("Phase 2/8 — export_meeting (md|pdf|docx|bundle)") let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let dest_path = PathBuf::from(&dest);
match format.as_str() {
"md" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
&dest_path,
crate::notes::ExportFormat::Md,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"bundle" => {
std::fs::create_dir_all(&dest_path)
.map_err(|e| WaError::new("export", e.to_string()))?;
let source_dir = meeting_dir(&meeting_id);
for name in ["audio.wav", "transcript.json"] {
let src = source_dir.join(name);
if src.exists() {
std::fs::copy(&src, dest_path.join(name))
.map_err(|e| WaError::new("export", e.to_string()))?;
}
}
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
other => {
return Err(WaError::new(
"export",
format!("unsupported export format: {other}"),
))
}
}
Ok(dest)
} }
// ---- LLM (Phase 5) ---- // ---- LLM (Phase 5) ----
+42 -6
View File
@@ -15,11 +15,13 @@ pub mod llm;
pub mod mcp; pub mod mcp;
pub mod models; pub mod models;
pub mod notes; pub mod notes;
pub mod paths;
pub mod storage; pub mod storage;
pub mod sync; pub mod sync;
pub mod transcription; pub mod transcription;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use std::thread::JoinHandle; use std::thread::JoinHandle;
use tauri::tray::TrayIcon; use tauri::tray::TrayIcon;
use tauri::Manager; use tauri::Manager;
@@ -27,12 +29,11 @@ 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`.
/// ///
/// Phase 1 only needs the in-flight recording session. `hardware`/`store`/ /// `hardware`/`llm`/`sync`/`mcp` service handles join this struct once their
/// `llm`/`sync`/`mcp` service handles join this struct once their concrete /// concrete implementations exist (Phase 3/5/9/10) — several don't have one to
/// implementations exist (Phase 2/3/5/9/10) — wiring them in ahead of that /// construct yet, so wiring them in early would just be unused plumbing.
/// 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 store: Arc<dyn storage::Store>,
/// 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>>,
} }
@@ -46,6 +47,10 @@ pub struct RecordingSession {
pub wav_path: PathBuf, pub wav_path: PathBuf,
pub started_at: std::time::Instant, pub started_at: std::time::Instant,
pub transcription_worker: JoinHandle<()>, pub transcription_worker: JoinHandle<()>,
/// Segments produced so far, appended by the same closure that emits
/// `transcript://segment` — lets `stop_recording` persist the full
/// transcript without a second round-trip through the event channel.
pub segments: Arc<StdMutex<Vec<models::TranscriptSegment>>>,
} }
/// Wraps the tray icon so it can be looked up from commands to update its /// Wraps the tray icon so it can be looked up from commands to update its
@@ -56,17 +61,47 @@ pub struct TrayHandle(pub TrayIcon);
pub fn run() { pub fn run() {
tracing_subscriber::fmt().with_env_filter("info").init(); tracing_subscriber::fmt().with_env_filter("info").init();
let store: Arc<dyn storage::Store> = Arc::new(
tauri::async_runtime::block_on(storage::SqliteStore::connect())
.expect("failed to initialize storage (wa.db)"),
);
let store_for_setup = store.clone();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(AppState { .manage(AppState {
store,
session: Mutex::new(None), session: Mutex::new(None),
}) })
.setup(|app| { .setup(move |app| {
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
let tray = tauri::tray::TrayIconBuilder::new() let tray = tauri::tray::TrayIconBuilder::new()
.icon(icon) .icon(icon)
.tooltip("WhispAssist — idle") .tooltip("WhispAssist — idle")
.build(app)?; .build(app)?;
app.manage(TrayHandle(tray)); app.manage(TrayHandle(tray));
// Startup recovery + retention pass (FR-REL-1, FR-STORE-2). Spawned so
// it never blocks the window from showing (NFR-PERF-4); nothing here
// repeats on a timer (NFR-RES-1).
let store = store_for_setup;
tauri::async_runtime::spawn(async move {
match store.recover_scan().await {
Ok(recovered) if !recovered.is_empty() => {
tracing::info!("recovered {} interrupted meeting(s)", recovered.len());
}
Ok(_) => {}
Err(e) => tracing::error!("startup recovery scan failed: {e}"),
}
let settings = commands::load_settings();
let policy = storage::Retention {
max_age_days: settings.retention_max_age_days,
max_size_gb: settings.retention_max_size_gb,
};
if let Err(e) = store.enforce_retention(policy).await {
tracing::error!("startup retention enforcement failed: {e}");
}
});
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -76,6 +111,7 @@ pub fn run() {
commands::resume_recording, commands::resume_recording,
commands::set_recording_retention, commands::set_recording_retention,
commands::acknowledge_recording_consent, commands::acknowledge_recording_consent,
commands::resume_transcription,
commands::hardware_status, commands::hardware_status,
commands::list_meetings, commands::list_meetings,
commands::get_meeting, commands::get_meeting,
+41 -1
View File
@@ -15,6 +15,18 @@ pub enum BackendId {
Cpu, Cpu,
} }
impl BackendId {
pub fn as_str(&self) -> &'static str {
match self {
BackendId::Npu => "npu",
BackendId::Nvidia => "nvidia",
BackendId::Amd => "amd",
BackendId::Intel => "intel",
BackendId::Cpu => "cpu",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendInfo { pub struct BackendInfo {
pub id: BackendId, pub id: BackendId,
@@ -25,7 +37,7 @@ pub struct BackendInfo {
pub vram_mb: Option<u32>, pub vram_mb: Option<u32>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum MeetingStatus { pub enum MeetingStatus {
Recording, Recording,
@@ -35,6 +47,31 @@ pub enum MeetingStatus {
Error, Error,
} }
impl MeetingStatus {
pub fn as_str(&self) -> &'static str {
match self {
MeetingStatus::Recording => "recording",
MeetingStatus::Transcribing => "transcribing",
MeetingStatus::Ready => "ready",
MeetingStatus::Recovering => "recovering",
MeetingStatus::Error => "error",
}
}
/// Parses a `meetings.status` DB value. Unrecognized values map to `Error`
/// rather than panicking — a stored value should always be one written by
/// this app, but a row is not worth crashing the app over.
pub fn parse(s: &str) -> Self {
match s {
"recording" => MeetingStatus::Recording,
"transcribing" => MeetingStatus::Transcribing,
"ready" => MeetingStatus::Ready,
"recovering" => MeetingStatus::Recovering,
_ => MeetingStatus::Error,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptSegment { pub struct TranscriptSegment {
pub id: u64, pub id: u64,
@@ -112,6 +149,9 @@ pub struct Settings {
pub consent_acknowledged: bool, pub consent_acknowledged: bool,
// Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain. // Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain.
pub sync_enabled: bool, pub sync_enabled: bool,
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
pub retention_max_age_days: Option<u32>,
pub retention_max_size_gb: Option<u32>,
} }
// ---- Sync (ADR-0010) ---- // ---- Sync (ADR-0010) ----
+121 -10
View File
@@ -42,20 +42,131 @@ pub struct MarkdownNotes;
impl NotesRenderer for MarkdownNotes { impl NotesRenderer for MarkdownNotes {
fn to_markdown( fn to_markdown(
&self, &self,
_segments: &[TranscriptSegment], segments: &[TranscriptSegment],
_speakers: &[SpeakerInfo], speakers: &[SpeakerInfo],
_summary_md: Option<&str>, summary_md: Option<&str>,
) -> String { ) -> String {
// T2.4: resolve speaker names, group dialogue, prepend summary section. let mut out = String::new();
todo!("Phase 2 — assemble Markdown") if let Some(summary) = summary_md {
let summary = summary.trim();
if !summary.is_empty() {
out.push_str(summary);
out.push_str("\n\n---\n\n");
} }
}
let name_for = |label: &str| -> String {
speakers
.iter()
.find(|s| s.label == label)
.and_then(|s| s.display_name.clone())
.unwrap_or_else(|| label.to_string())
};
// Group consecutive segments from the same speaker into one paragraph
// (matters once Phase 4 diarization produces more than one speaker).
let mut current_speaker: Option<&str> = None;
let mut buffer = String::new();
for seg in segments {
let text = seg.text.trim();
if text.is_empty() {
continue;
}
if current_speaker != Some(seg.speaker.as_str()) {
if let Some(speaker) = current_speaker {
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
}
current_speaker = Some(seg.speaker.as_str());
buffer.clear();
}
if !buffer.is_empty() {
buffer.push(' ');
}
buffer.push_str(text);
}
if let Some(speaker) = current_speaker {
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
}
out.trim_end().to_string()
}
fn export( fn export(
&self, &self,
_markdown: &str, markdown: &str,
_dest: &Path, dest: &Path,
_fmt: ExportFormat, fmt: ExportFormat,
) -> Result<PathBuf, NotesError> { ) -> Result<PathBuf, NotesError> {
// T2.6 (.md/bundle); T8.4 (pdf/docx via local conversion). match fmt {
todo!("Phase 2/8 — export notes") ExportFormat::Md => {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dest, markdown)?;
Ok(dest.to_path_buf())
}
// Bundling pulls in audio.wav/transcript.json alongside notes.md —
// that's meeting-folder orchestration, not Markdown rendering, so
// `commands::export_meeting` handles it directly rather than here.
ExportFormat::Bundle => Err(NotesError::Export(
"bundle export is assembled in commands::export_meeting, not NotesRenderer".into(),
)),
ExportFormat::Pdf | ExportFormat::Docx => Err(NotesError::Export(
"PDF/Word export lands in Phase 8".into(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(id: u64, speaker: &str, text: &str) -> TranscriptSegment {
TranscriptSegment {
id,
start_ms: id * 1000,
end_ms: id * 1000 + 900,
speaker: speaker.to_string(),
text: text.to_string(),
confidence: Some(0.9),
interim: false,
}
}
#[test]
fn groups_consecutive_same_speaker_segments_into_one_paragraph() {
let segments = vec![
seg(0, "S1", "Hello"),
seg(1, "S1", "world."),
seg(2, "S2", "Hi there."),
];
let md = MarkdownNotes.to_markdown(&segments, &[], None);
assert_eq!(md, "**S1:** Hello world.\n\n**S2:** Hi there.");
}
#[test]
fn resolves_display_name_and_falls_back_to_label() {
let segments = vec![seg(0, "S1", "Hi")];
let speakers = [SpeakerInfo {
label: "S1".to_string(),
display_name: Some("Alex".to_string()),
participant_id: None,
}];
assert_eq!(
MarkdownNotes.to_markdown(&segments, &speakers, None),
"**Alex:** Hi"
);
assert_eq!(
MarkdownNotes.to_markdown(&segments, &[], None),
"**S1:** Hi"
);
}
#[test]
fn skips_blank_segments_and_prepends_summary() {
let segments = vec![seg(0, "S1", " "), seg(1, "S1", "Real text.")];
let md = MarkdownNotes.to_markdown(&segments, &[], Some("## Summary\nDone."));
assert_eq!(md, "## Summary\nDone.\n\n---\n\n**S1:** Real text.");
} }
} }
+34
View File
@@ -0,0 +1,34 @@
//! Shared on-disk layout helpers (`docs/03-data-model.md`). Used by `storage`,
//! `commands`, and `lib` so the root/meetings/models/settings paths are computed
//! exactly one way.
use crate::models::MeetingId;
use std::path::PathBuf;
pub fn local_appdata() -> PathBuf {
PathBuf::from(std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()))
}
pub fn wa_root() -> PathBuf {
local_appdata().join("WhispAssist")
}
pub fn db_path() -> PathBuf {
wa_root().join("wa.db")
}
pub fn settings_path() -> PathBuf {
wa_root().join("settings.json")
}
pub fn meetings_dir() -> PathBuf {
wa_root().join("meetings")
}
pub fn meeting_dir(id: &MeetingId) -> PathBuf {
meetings_dir().join(id)
}
pub fn whisper_model_path() -> PathBuf {
wa_root().join("models").join("ggml-base.en-q5_1.bin")
}
+357 -18
View File
@@ -4,8 +4,14 @@
//! Invariant: audio is the source of truth. Derived artifacts (transcript/notes/ //! Invariant: audio is the source of truth. Derived artifacts (transcript/notes/
//! summary) are regenerable; retention never touches an in-progress meeting. //! summary) are regenerable; retention never touches an in-progress meeting.
use crate::models::{MeetingId, MeetingListItem}; use crate::models::{MeetingId, MeetingListItem, MeetingStatus, SpeakerInfo, TranscriptSegment};
use crate::paths;
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::Row;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StoreError { pub enum StoreError {
@@ -17,11 +23,49 @@ pub enum StoreError {
NotFound(String), NotFound(String),
} }
impl From<sqlx::Error> for StoreError {
fn from(e: sqlx::Error) -> Self {
StoreError::Db(e.to_string())
}
}
pub struct NewMeeting { pub struct NewMeeting {
pub title: String, pub title: String,
pub calendar_event_id: Option<String>, pub calendar_event_id: Option<String>,
} }
/// What actually happened during the session — richer than the doc's bare
/// `&MeetingId` because `finalize_meeting` has to persist what was captured.
pub struct FinalizeMeeting {
pub segments: Vec<TranscriptSegment>,
pub speakers: Vec<SpeakerInfo>,
pub duration_secs: i64,
pub recorded: bool,
pub language: Option<String>,
pub backend_used: Option<String>,
pub model_used: Option<String>,
}
/// Full meeting detail: DB row + transcript + speakers + notes (`get_meeting`'s
/// documented contract — `docs/04-api-contracts.md` — which the old
/// `MeetingListItem`-only stub couldn't represent).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meeting {
pub id: MeetingId,
pub title: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub duration_secs: Option<i64>,
pub status: MeetingStatus,
pub recorded: bool,
pub language: Option<String>,
pub backend_used: Option<String>,
pub model_used: Option<String>,
pub segments: Vec<TranscriptSegment>,
pub speakers: Vec<SpeakerInfo>,
pub notes_markdown: String,
}
pub struct Retention { pub struct Retention {
pub max_age_days: Option<u32>, pub max_age_days: Option<u32>,
pub max_size_gb: Option<u32>, pub max_size_gb: Option<u32>,
@@ -30,51 +74,346 @@ pub struct Retention {
#[async_trait] #[async_trait]
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, s: FinalizeMeeting) -> Result<(), StoreError>;
async fn list_meetings( async fn list_meetings(
&self, &self,
query: Option<String>, query: Option<String>,
) -> Result<Vec<MeetingListItem>, StoreError>; ) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, 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).
async fn search(&self, query: &str) -> Result<Vec<MeetingListItem>, StoreError>; async fn search(&self, query: &str) -> Result<Vec<MeetingListItem>, StoreError>;
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1). /// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>; async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
/// Enforce retention; returns count removed. Skips in-progress meetings. /// Enforce retention; returns count removed. Skips in-progress meetings
/// (anything whose `status` isn't a terminal `ready`/`error`).
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>; async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
} }
/// SQLite-backed store. Migrations live in `migrations/` (sqlx::migrate!). /// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`).
pub struct SqliteStore; pub struct SqliteStore {
pool: SqlitePool,
}
impl SqliteStore {
pub async fn connect() -> Result<Self, StoreError> {
let root = paths::wa_root();
std::fs::create_dir_all(&root)?;
let opts = SqliteConnectOptions::new()
.filename(paths::db_path())
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(opts)
.await?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| StoreError::Db(e.to_string()))?;
Ok(Self { pool })
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// On-disk shape of `transcript.json` (`docs/03-data-model.md`).
#[derive(Debug, Serialize, Deserialize, Default)]
struct TranscriptFile {
schema: u32,
meeting_id: MeetingId,
language: Option<String>,
model: Option<String>,
backend: Option<String>,
segments: Vec<TranscriptSegment>,
speakers: Vec<TranscriptSpeaker>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TranscriptSpeaker {
label: String,
display_name: Option<String>,
}
fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem {
MeetingListItem {
id: row.get("id"),
title: row.get("title"),
started_at: row.get("started_at"),
duration_secs: row.get("duration_secs"),
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
}
}
#[async_trait] #[async_trait]
impl Store for SqliteStore { impl Store for SqliteStore {
async fn create_meeting(&self, _m: NewMeeting) -> Result<MeetingId, StoreError> { async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError> {
todo!("Phase 2 — create meeting row + folder") let id = uuid::Uuid::new_v4().to_string();
let folder = paths::meeting_dir(&id);
std::fs::create_dir_all(&folder)?;
let audio_path = folder.join("audio.wav");
let now = now_unix();
sqlx::query(
"INSERT INTO meetings (id, title, started_at, folder_path, audio_path, status, calendar_event_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'recording', ?, ?, ?)",
)
.bind(&id)
.bind(&m.title)
.bind(now)
.bind(folder.display().to_string())
.bind(audio_path.display().to_string())
.bind(&m.calendar_event_id)
.bind(now)
.bind(now)
.execute(&self.pool)
.await?;
Ok(id)
} }
async fn finalize_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
todo!("Phase 2 — persist transcript.json + metadata") async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError> {
let now = now_unix();
sqlx::query(
"UPDATE meetings SET status = 'ready', ended_at = ?, duration_secs = ?, recorded = ?,
language = ?, backend_used = ?, model_used = ?, updated_at = ? WHERE id = ?",
)
.bind(now)
.bind(s.duration_secs)
.bind(s.recorded as i64)
.bind(&s.language)
.bind(&s.backend_used)
.bind(&s.model_used)
.bind(now)
.bind(id)
.execute(&self.pool)
.await?;
for speaker in &s.speakers {
sqlx::query(
"INSERT INTO speakers (id, meeting_id, label, display_name)
VALUES (?, ?, ?, ?)
ON CONFLICT(meeting_id, label) DO UPDATE SET display_name = excluded.display_name",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(id)
.bind(&speaker.label)
.bind(&speaker.display_name)
.execute(&self.pool)
.await?;
} }
let transcript = TranscriptFile {
schema: 1,
meeting_id: id.clone(),
language: s.language.clone(),
model: s.model_used.clone(),
backend: s.backend_used.clone(),
segments: s.segments,
speakers: s
.speakers
.iter()
.map(|sp| TranscriptSpeaker {
label: sp.label.clone(),
display_name: sp.display_name.clone(),
})
.collect(),
};
let json =
serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?;
std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?;
Ok(())
}
async fn list_meetings( async fn list_meetings(
&self, &self,
_query: Option<String>, query: Option<String>,
) -> Result<Vec<MeetingListItem>, StoreError> { ) -> Result<Vec<MeetingListItem>, StoreError> {
todo!("Phase 2 — list meetings") let rows = match query.filter(|q| !q.trim().is_empty()) {
Some(q) => {
let like = format!("%{}%", q.trim());
sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings WHERE title LIKE ? ORDER BY started_at DESC")
.bind(like)
.fetch_all(&self.pool)
.await?
} }
async fn delete_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> { None => {
todo!("Phase 2 — delete meeting + folder") sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings ORDER BY started_at DESC")
.fetch_all(&self.pool)
.await?
} }
async fn update_notes(&self, _id: &MeetingId, _markdown: &str) -> Result<(), StoreError> { };
todo!("Phase 2 — write notes.md + FTS") Ok(rows.iter().map(row_to_list_item).collect())
} }
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError> {
let row = sqlx::query(
"SELECT id, title, started_at, ended_at, duration_secs, status, recorded, language, backend_used, model_used
FROM meetings WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| StoreError::NotFound(id.clone()))?;
let speaker_rows = sqlx::query(
"SELECT label, display_name FROM speakers WHERE meeting_id = ? ORDER BY label",
)
.bind(id)
.fetch_all(&self.pool)
.await?;
let speakers: Vec<SpeakerInfo> = speaker_rows
.iter()
.map(|r| SpeakerInfo {
label: r.get("label"),
display_name: r.get("display_name"),
participant_id: None,
})
.collect();
let folder = paths::meeting_dir(id);
let segments = std::fs::read_to_string(folder.join("transcript.json"))
.ok()
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
.map(|t| t.segments)
.unwrap_or_default();
let notes_markdown = std::fs::read_to_string(folder.join("notes.md")).unwrap_or_default();
Ok(Meeting {
id: row.get("id"),
title: row.get("title"),
started_at: row.get("started_at"),
ended_at: row.get("ended_at"),
duration_secs: row.get("duration_secs"),
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
recorded: row.get::<i64, _>("recorded") != 0,
language: row.get("language"),
backend_used: row.get("backend_used"),
model_used: row.get("model_used"),
segments,
speakers,
notes_markdown,
})
}
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError> {
sqlx::query("DELETE FROM meetings WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
let folder = paths::meeting_dir(id);
if folder.exists() {
std::fs::remove_dir_all(folder)?;
}
Ok(())
}
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> {
std::fs::write(paths::meeting_dir(id).join("notes.md"), markdown)?;
sqlx::query("UPDATE meetings SET updated_at = ? WHERE id = ?")
.bind(now_unix())
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn search(&self, _query: &str) -> Result<Vec<MeetingListItem>, StoreError> { async fn search(&self, _query: &str) -> Result<Vec<MeetingListItem>, StoreError> {
todo!("Phase 8 — FTS5 search") todo!("Phase 8 — FTS5 search")
} }
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> { async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
todo!("Phase 2 — recovery scan") let rows = sqlx::query(
"SELECT id, audio_path FROM meetings WHERE status IN ('recording', 'transcribing')",
)
.fetch_all(&self.pool)
.await?;
let mut recovered = Vec::new();
for row in rows {
let id: MeetingId = row.get("id");
let audio_path: String = row.get("audio_path");
if PathBuf::from(&audio_path).exists() {
sqlx::query(
"UPDATE meetings SET status = 'recovering', updated_at = ? WHERE id = ?",
)
.bind(now_unix())
.bind(&id)
.execute(&self.pool)
.await?;
recovered.push(id);
} else {
// No working audio and never finalized: nothing to recover from.
sqlx::query("UPDATE meetings SET status = 'error', updated_at = ? WHERE id = ?")
.bind(now_unix())
.bind(&id)
.execute(&self.pool)
.await?;
} }
async fn enforce_retention(&self, _policy: Retention) -> Result<u32, StoreError> { }
todo!("Phase 2 — retention enforcement") Ok(recovered)
}
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError> {
if policy.max_age_days.is_none() && policy.max_size_gb.is_none() {
return Ok(0);
}
let candidates = sqlx::query(
"SELECT id, folder_path, started_at FROM meetings WHERE status IN ('ready', 'error') ORDER BY started_at ASC",
)
.fetch_all(&self.pool)
.await?;
let mut removed = 0u32;
let now = now_unix();
let max_age_secs = policy.max_age_days.map(|d| d as i64 * 86_400);
// Age-based pruning first (cheap, no disk walk needed).
let mut kept = Vec::new();
for row in &candidates {
let id: MeetingId = row.get("id");
let started_at: i64 = row.get("started_at");
if let Some(max_age) = max_age_secs {
if now - started_at > max_age {
self.delete_meeting(&id).await?;
removed += 1;
continue;
}
}
kept.push((id, row.get::<String, _>("folder_path")));
}
// Size-based pruning, oldest first, until under the cap.
if let Some(max_gb) = policy.max_size_gb {
let cap_bytes = max_gb as u64 * 1_073_741_824;
let sizes: Vec<(MeetingId, u64)> = kept
.iter()
.map(|(id, folder)| (id.clone(), dir_size(folder)))
.collect();
let mut total: u64 = sizes.iter().map(|(_, s)| s).sum();
let mut idx = 0;
while total > cap_bytes && idx < sizes.len() {
let (id, size) = &sizes[idx];
self.delete_meeting(id).await?;
total = total.saturating_sub(*size);
removed += 1;
idx += 1;
}
}
Ok(removed)
} }
} }
fn dir_size(path: &str) -> u64 {
std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| entry.metadata().ok())
.map(|m| m.len())
.sum()
}
+94 -51
View File
@@ -31,7 +31,8 @@ pub trait Transcriber: Send + Sync {
Self: Sized; Self: Sized;
/// Streaming: emit interim + final segments for a window (FR-TRX-2). /// Streaming: emit interim + final segments for a window (FR-TRX-2).
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>; fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>;
/// Batch: one-shot, higher accuracy (FR-TRX-3). /// Batch: one-shot over a whole file, higher accuracy — also the crash-recovery
/// path (FR-TRX-3, T2.8): re-run over the working `audio.wav` from scratch.
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>; fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>;
} }
@@ -42,6 +43,88 @@ pub struct WhisperTranscriber {
next_id: std::sync::atomic::AtomicU64, next_id: std::sync::atomic::AtomicU64,
} }
#[cfg(feature = "cpu-transcription")]
impl WhisperTranscriber {
/// Shared decode path for both streaming windows and batch files.
///
/// `single_segment` forces whisper.cpp to treat the whole buffer as one
/// segment — needed for the short (~4s) streaming windows (see
/// `transcribe_stream`'s doc comment for why), but wrong for batch/file
/// transcription: a multi-minute recording should get whisper's normal
/// speech-boundary-driven multi-segment output, and its internal 30s-chunk
/// seek loop handles audio longer than one chunk on its own.
fn run_full(
&self,
samples: &[f32],
offset_ms: u64,
single_segment: bool,
) -> Result<Vec<TranscriptSegment>, TrxError> {
// whisper.cpp hangs (not errors) on an empty buffer — verified against
// a crash-recovered WAV whose header hadn't been finalized. Belt and
// suspenders alongside `read_wav_mono_16k` reading raw bytes directly.
if samples.is_empty() {
return Ok(Vec::new());
}
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).
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);
params.set_single_segment(single_segment);
state
.full(params, samples)
.map_err(|e| TrxError::Inference(e.to_string()))?;
// A forced single_segment's reported end_timestamp() reflects
// whisper.cpp's internal 30s-padded mel frame, not the real window
// length — confirmed even with `duration_ms` set, so don't trust it.
// We already know the window's true length from `samples`, which is
// the more reliable source for `single_segment` windows; whisper's own
// multi-segment timestamps (batch/`transcribe_file`) stay authoritative.
let window_end_ms = offset_ms + (samples.len() as u64 * 1000) / 16_000;
let mut out = Vec::new();
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 = offset_ms + seg.start_timestamp().max(0) as u64 * 10;
let end_ms = if single_segment {
window_end_ms.max(start_ms)
} else {
offset_ms + seg.end_timestamp().max(0) as u64 * 10
};
out.push(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,
});
}
Ok(out)
}
}
#[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> {
@@ -57,55 +140,13 @@ impl Transcriber for WhisperTranscriber {
} }
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError> { 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 // Each call already gets exactly one fixed-size window (not a full 30s
// clip); without this, whisper.cpp's segment-boundary heuristic hits // clip); without `single_segment: true`, whisper.cpp's segment-boundary
// "single timestamp ending" on short buffers and silently drops the // heuristic hits "single timestamp ending" on short buffers and drops
// whole window even when decoding succeeded (verified against real // the whole window even when decoding succeeded (verified against real
// audio: text decoded correctly but zero segments came out until this // audio — see whisper.cpp's own `stream` example, which sets this for
// was set — see whisper.cpp's own `stream` example, which sets this // the same reason).
// for the same reason). for segment in self.run_full(&audio.samples, audio.offset_ms, true)? {
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() { if out.send(segment).is_err() {
break; // receiver gone (meeting stopped) — nothing left to do break; // receiver gone (meeting stopped) — nothing left to do
} }
@@ -113,8 +154,10 @@ impl Transcriber for WhisperTranscriber {
Ok(()) 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") let samples =
crate::audio::read_wav_mono_16k(wav).map_err(|e| TrxError::Load(e.to_string()))?;
self.run_full(&samples, 0, false)
} }
} }
+3
View File
@@ -8,6 +8,7 @@
import ConsentNotice from "./lib/components/ConsentNotice.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 { settings } from "./lib/stores/settings.svelte";
import { meetings } from "./lib/stores/meetings.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
let showSettings = $state(false); let showSettings = $state(false);
@@ -16,9 +17,11 @@
onMount(() => { onMount(() => {
recording.init(); recording.init();
settings.load(); settings.load();
meetings.init();
}); });
function startRecording() { function startRecording() {
meetings.deselect(); // show the live view, not whatever past meeting was open
recording.start(undefined, settings.settings.default_record); recording.start(undefined, settings.settings.default_record);
} }
+36 -3
View File
@@ -8,12 +8,14 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type MeetingId = string; export type MeetingId = string;
export type BackendId = "npu" | "nvidia" | "amd" | "intel" | "cpu"; export type BackendId = "npu" | "nvidia" | "amd" | "intel" | "cpu";
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
export interface MeetingListItem { export interface MeetingListItem {
id: MeetingId; id: MeetingId;
title: string; title: string;
started_at: number; started_at: number;
duration_secs: number | null; duration_secs: number | null;
status: "recording" | "transcribing" | "ready" | "recovering" | "error"; status: MeetingStatus;
} }
export interface TranscriptSegment { export interface TranscriptSegment {
@@ -26,6 +28,29 @@ export interface TranscriptSegment {
interim: boolean; interim: boolean;
} }
export interface SpeakerInfo {
label: string;
display_name: string | null;
participant_id: string | null;
}
// Full meeting detail (get_meeting) — DB row + transcript + speakers + notes.
export interface Meeting {
id: MeetingId;
title: string;
started_at: number;
ended_at: number | null;
duration_secs: number | null;
status: MeetingStatus;
recorded: boolean;
language: string | null;
backend_used: string | null;
model_used: string | null;
segments: TranscriptSegment[];
speakers: SpeakerInfo[];
notes_markdown: string;
}
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box"; export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6). // What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
@@ -74,6 +99,8 @@ export interface AppSettings {
consent_acknowledged: boolean; consent_acknowledged: boolean;
sync_enabled: boolean; sync_enabled: boolean;
mcp_enabled: boolean; mcp_enabled: boolean;
retention_max_age_days: number | null;
retention_max_size_gb: number | null;
} }
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011). // Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
@@ -116,12 +143,15 @@ export const api = {
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"), acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
hardwareStatus: () => invoke("hardware_status"), hardwareStatus: () => invoke("hardware_status"),
resumeTranscription: (meetingId: MeetingId) =>
invoke<void>("resume_transcription", { meetingId }),
listMeetings: (query?: string) => invoke<MeetingListItem[]>("list_meetings", { query }), listMeetings: (query?: string) => invoke<MeetingListItem[]>("list_meetings", { query }),
getMeeting: (meetingId: MeetingId) => invoke("get_meeting", { meetingId }), getMeeting: (meetingId: MeetingId) => invoke<Meeting>("get_meeting", { meetingId }),
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }), deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
updateNotes: (meetingId: MeetingId, markdown: string) => updateNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("update_notes", { meetingId, markdown }), invoke<void>("update_notes", { meetingId, markdown }),
exportMeeting: (meetingId: MeetingId, dest: string, format: string) => // format ∈ "md" | "bundle"; dest is a file path for md, a folder for bundle.
exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "bundle") =>
invoke<string>("export_meeting", { meetingId, dest, format }), invoke<string>("export_meeting", { meetingId, dest, format }),
llmStatus: () => invoke("llm_status"), llmStatus: () => invoke("llm_status"),
@@ -194,6 +224,9 @@ export const events = {
onSegment: ( onSegment: (
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void, cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)), ): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
onFinalized: (
cb: (p: { meetingId: string; segmentCount: number }) => void,
): Promise<UnlistenFn> => listen("transcript://finalized", (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: ( onSyncJob: (
+61
View File
@@ -0,0 +1,61 @@
// Meetings list + selected-meeting detail (Phase 2, FR-STORE-5).
// Svelte 5 runes store, same shape as recording.svelte.ts.
import { api, events, type Meeting, type MeetingId, type MeetingListItem } from "../api";
class MeetingsStore {
list = $state<MeetingListItem[]>([]);
selectedId = $state<MeetingId | null>(null);
selected = $state<Meeting | null>(null);
loading = $state(false);
async init() {
await this.load();
// A meeting finishing (live stop, or a recovered meeting resuming
// transcription) changes the list and, if it's the open one, its detail.
await events.onFinalized(async ({ meetingId }) => {
await this.load();
if (this.selectedId === meetingId) await this.select(meetingId);
});
}
async load(query?: string) {
this.loading = true;
try {
this.list = await api.listMeetings(query);
} catch {
// list_meetings backend not reachable; leave the list as-is.
} finally {
this.loading = false;
}
}
async select(id: MeetingId) {
this.selectedId = id;
this.selected = await api.getMeeting(id);
}
deselect() {
this.selectedId = null;
this.selected = null;
}
async remove(id: MeetingId) {
await api.deleteMeeting(id);
if (this.selectedId === id) this.deselect();
await this.load();
}
async resume(id: MeetingId) {
await api.resumeTranscription(id);
await this.load();
if (this.selectedId === id) await this.select(id);
}
async saveNotes(id: MeetingId, markdown: string) {
await api.updateNotes(id, markdown);
if (this.selected && this.selectedId === id) this.selected.notes_markdown = markdown;
}
}
export const meetings = new MeetingsStore();
+12 -1
View File
@@ -17,6 +17,8 @@ const DEFAULT_SETTINGS: AppSettings = {
consent_acknowledged: false, consent_acknowledged: false,
sync_enabled: false, // sync OFF by default (ADR-0010) sync_enabled: false, // sync OFF by default (ADR-0010)
mcp_enabled: false, // MCP server OFF by default (ADR-0011) mcp_enabled: false, // MCP server OFF by default (ADR-0011)
retention_max_age_days: null, // no cap by default (FR-STORE-2)
retention_max_size_gb: null,
}; };
class SettingsStore { class SettingsStore {
@@ -29,12 +31,17 @@ class SettingsStore {
async load() { async load() {
try { try {
this.settings = await api.getSettings(); this.settings = await api.getSettings();
this.targets = await api.listSyncTargets();
} catch { } catch {
// Backend command is still a todo!(); fall back to local defaults. // Backend command is still a todo!(); fall back to local defaults.
this.backendStub = true; this.backendStub = true;
this.settings = { ...DEFAULT_SETTINGS }; this.settings = { ...DEFAULT_SETTINGS };
} }
try {
this.targets = await api.listSyncTargets();
} catch {
// Sync lands in Phase 9 — don't let its stub discard real settings above.
this.backendStub = true;
}
this.loaded = true; this.loaded = true;
} }
@@ -52,6 +59,10 @@ class SettingsStore {
return this.patch({ default_record: on }); return this.patch({ default_record: on });
} }
setRetentionPolicy(maxAgeDays: number | null, maxSizeGb: number | null) {
return this.patch({ retention_max_age_days: maxAgeDays, retention_max_size_gb: maxSizeGb });
}
async acknowledgeConsent() { async acknowledgeConsent() {
this.settings = { ...this.settings, consent_acknowledged: true }; this.settings = { ...this.settings, consent_acknowledged: true };
try { try {
+157 -17
View File
@@ -1,37 +1,177 @@
<script lang="ts"> <script lang="ts">
// Left pane: meetings list + search/filter (Phase 2, FR-STORE-5; Phase 8 search). // Left pane: meetings list + search/filter (Phase 2, FR-STORE-5; Phase 8 search).
import { api, type MeetingListItem } from "../api"; import { meetings } from "../stores/meetings.svelte";
import { onMount } from "svelte"; import { recording } from "../stores/recording.svelte";
import type { MeetingListItem } from "../api";
let meetings = $state<MeetingListItem[]>([]);
let query = $state(""); let query = $state("");
let searchTimer: ReturnType<typeof setTimeout> | undefined;
onMount(async () => { function onSearchInput() {
try { clearTimeout(searchTimer);
meetings = await api.listMeetings(); searchTimer = setTimeout(() => meetings.load(query || undefined), 250);
} catch {
// list_meetings is a Phase 2 todo!() — skeleton tolerates the error.
} }
function formatDate(unixSecs: number): string {
return new Date(unixSecs * 1000).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}); });
}
function formatDuration(secs: number | null): string {
if (!secs) return "";
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
async function openMeeting(m: MeetingListItem) {
if (recording.state !== "idle" && recording.meetingId === m.id) return; // still live
await meetings.select(m.id);
}
async function removeMeeting(e: Event, id: string) {
e.stopPropagation();
if (!confirm("Delete this meeting? This removes its recording, transcript, and notes.")) return;
await meetings.remove(id);
}
async function resumeMeeting(e: Event, id: string) {
e.stopPropagation();
await meetings.resume(id);
}
</script> </script>
<div class="wrap"> <div class="wrap">
<input placeholder="Search meetings…" bind:value={query} /> <input placeholder="Search meetings…" bind:value={query} oninput={onSearchInput} />
{#if meetings.length === 0} {#if meetings.list.length === 0}
<p class="muted">No meetings yet. Click ● Record to start.</p> <p class="muted">No meetings yet. Click ● Record to start.</p>
{:else} {:else}
<ul> <ul>
{#each meetings as m (m.id)} {#each meetings.list as m (m.id)}
<li>{m.title}</li> <li class:active={meetings.selectedId === m.id}>
<button class="item-btn" onclick={() => openMeeting(m)}>
<span class="row">
<span class="title">{m.title}</span>
{#if m.status === "recording" || m.status === "transcribing"}
<span class="badge live">{m.status}</span>
{:else if m.status === "recovering"}
<span class="badge recovering">recovering</span>
{:else if m.status === "error"}
<span class="badge error">error</span>
{/if}
</span>
<span class="row muted small">
<span>{formatDate(m.started_at)}</span>
{#if m.duration_secs}<span>{formatDuration(m.duration_secs)}</span>{/if}
</span>
</button>
<div class="row actions">
{#if m.status === "recovering"}
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}
>Resume transcription</button
>
{/if}
<button
class="link danger"
onclick={(e) => removeMeeting(e, m.id)}
aria-label="Delete meeting"
>
🗑
</button>
</div>
</li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</div> </div>
<style> <style>
.wrap { padding: 0.75rem; } .wrap {
input { width: 100%; padding: 0.4rem; margin-bottom: 0.5rem; } padding: 0.75rem;
.muted { color: var(--muted); font-size: 0.85rem; } }
ul { list-style: none; padding: 0; margin: 0; } input {
li { padding: 0.4rem 0.2rem; border-bottom: 1px solid var(--border); cursor: pointer; } width: 100%;
padding: 0.4rem;
margin-bottom: 0.5rem;
box-sizing: border-box;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.small {
font-size: 0.75rem;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
padding: 0.5rem 0.4rem;
border-bottom: 1px solid var(--border);
}
li.active {
background: color-mix(in srgb, #2563eb 12%, transparent);
}
.item-btn {
display: block;
width: 100%;
background: none;
border: none;
color: inherit;
font: inherit;
text-align: left;
padding: 0;
cursor: pointer;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 10px;
border: 1px solid var(--border);
}
.badge.live {
color: #e11d48;
border-color: #e11d48;
}
.badge.recovering {
color: #b45309;
border-color: #b45309;
}
.badge.error {
color: #6b7280;
border-color: #6b7280;
}
.actions {
margin-top: 0.2rem;
justify-content: flex-end;
gap: 0.6rem;
}
.link {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 0.75rem;
padding: 0;
}
.link.danger:hover {
color: #e11d48;
}
</style> </style>
+54 -4
View File
@@ -9,7 +9,7 @@
let { onClose }: { onClose: () => void } = $props(); let { onClose }: { onClose: () => void } = $props();
let section = $state<"recording" | "sync">("recording"); let section = $state<"recording" | "storage" | "sync">("recording");
let showConsent = $state(false); let showConsent = $state(false);
let testResult = $state<string | null>(null); let testResult = $state<string | null>(null);
@@ -31,6 +31,19 @@
showConsent = false; showConsent = false;
} }
// ---- Storage retention (FR-STORE-2) ----
let maxAgeInput = $state("");
let maxSizeInput = $state("");
$effect(() => {
maxAgeInput = settings.settings.retention_max_age_days?.toString() ?? "";
maxSizeInput = settings.settings.retention_max_size_gb?.toString() ?? "";
});
function saveRetention() {
const age = maxAgeInput.trim() === "" ? null : Math.max(1, parseInt(maxAgeInput, 10) || 0);
const size = maxSizeInput.trim() === "" ? null : Math.max(1, parseInt(maxSizeInput, 10) || 0);
settings.setRetentionPolicy(age, size);
}
// ---- Add-target form ---- // ---- Add-target form ----
const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"]; const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"];
let form = $state<SyncTargetConfig>(blankForm("webdav")); let form = $state<SyncTargetConfig>(blankForm("webdav"));
@@ -80,15 +93,18 @@
<button class:active={section === "recording"} onclick={() => (section = "recording")} <button class:active={section === "recording"} onclick={() => (section = "recording")}
>Recording</button >Recording</button
> >
<button class:active={section === "storage"} onclick={() => (section = "storage")}
>Storage</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 && section === "sync"}
<p class="stub"> <p class="stub">
Preview mode — the backend for these settings isn't implemented yet (Phase 9). Changes are Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI
kept in the UI only. only.
</p> </p>
{/if} {/if}
@@ -117,6 +133,40 @@
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} /> <ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
{/if} {/if}
</section> </section>
{:else if section === "storage"}
<section>
<h3>Storage</h3>
<div class="row">Location<code>{settings.settings.storage_root}</code></div>
<p class="muted">
Changing the storage location isn't supported yet — this is where meetings, models, and
the database currently live.
</p>
<h4>Retention</h4>
<p class="muted">
Automatically delete the oldest meetings past a limit. Checked once at startup; empty
means no limit. Never touches a meeting that's currently recording.
</p>
<div class="grid">
<label
>Max age (days)<input
type="number"
min="1"
placeholder="no limit"
bind:value={maxAgeInput}
onchange={saveRetention}
/></label
>
<label
>Max size (GB)<input
type="number"
min="1"
placeholder="no limit"
bind:value={maxSizeInput}
onchange={saveRetention}
/></label
>
</div>
</section>
{:else} {:else}
<section> <section>
<h3>Sync &amp; upload</h3> <h3>Sync &amp; upload</h3>
+213 -10
View File
@@ -1,27 +1,230 @@
<script lang="ts"> <script lang="ts">
// Center pane: live transcript + Markdown notes (Phase 1/2, FR-TRX-2/FR-NOTE-*). // Center pane: live transcript while recording (FR-TRX-2), or — once a past
// meeting is selected — raw transcript + editable/rendered notes side by
// side (FR-NOTE-1/2/6) with export (FR-NOTE-3, T2.6).
import { recording } from "../stores/recording.svelte"; import { recording } from "../stores/recording.svelte";
import { meetings } from "../stores/meetings.svelte";
import { api, type SpeakerInfo } from "../api";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { save, open } from "@tauri-apps/plugin-dialog";
function speakerName(label: string): string { // Notes can contain pasted/typed Markdown from anywhere; this webview has an
return label; // Phase 4: resolve to display name from mapping. // IPC bridge to the Rust backend, so unsanitized `{@html}` here would be a
// real local-privilege risk, not just a cosmetic one — sanitize before render.
function renderNotes(markdown: string): string {
return DOMPurify.sanitize(marked.parse(markdown || "") as string);
}
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
return speakers.find((s) => s.label === label)?.display_name ?? label;
}
let notesText = $state("");
let editorEl: HTMLTextAreaElement | undefined = $state();
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let loadedForId: string | null = null;
// Sync the editor buffer whenever a different meeting is selected.
$effect(() => {
const m = meetings.selected;
if (m && m.id !== loadedForId) {
notesText = m.notes_markdown;
loadedForId = m.id;
} else if (!m) {
loadedForId = null;
}
});
function scheduleSave() {
const id = meetings.selected?.id;
if (!id) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(() => meetings.saveNotes(id, notesText), 500);
}
function wrapSelection(before: string, after: string = before) {
const el = editorEl;
if (!el) return;
const { selectionStart: s, selectionEnd: e, value } = el;
const selected = value.slice(s, e);
notesText = value.slice(0, s) + before + selected + after + value.slice(e);
queueMicrotask(() => {
el.focus();
el.selectionStart = s + before.length;
el.selectionEnd = s + before.length + selected.length;
});
scheduleSave();
}
function insertLinePrefix(prefix: string) {
const el = editorEl;
if (!el) return;
const { selectionStart: s, value } = el;
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
notesText = value.slice(0, lineStart) + prefix + value.slice(lineStart);
queueMicrotask(() => el.focus());
scheduleSave();
}
async function exportMd() {
const m = meetings.selected;
if (!m) return;
const path = await save({
defaultPath: `${m.title}.md`,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (path) await api.exportMeeting(m.id, path, "md");
}
async function exportBundle() {
const m = meetings.selected;
if (!m) return;
const dir = await open({ directory: true, defaultPath: m.title });
if (typeof dir === "string") await api.exportMeeting(m.id, dir, "bundle");
} }
</script> </script>
<div class="wrap"> <div class="wrap">
{#if recording.segments.length === 0} {#if meetings.selected}
<p class="muted">Transcript will appear here as you record.</p> {@const m = meetings.selected}
<div class="split">
<div class="pane transcript">
<h4>Transcript</h4>
{#if m.segments.length === 0}
<p class="muted">No transcript for this meeting.</p>
{:else} {:else}
{#each m.segments as s (s.id)}
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
{/each}
{/if}
</div>
<div class="pane notes">
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
<button onclick={() => wrapSelection("**")} title="Bold"><b>B</b></button>
<button onclick={() => wrapSelection("_")} title="Italic"><i>I</i></button>
<button onclick={() => insertLinePrefix("# ")} title="Heading 1">H1</button>
<button onclick={() => insertLinePrefix("## ")} title="Heading 2">H2</button>
<button onclick={() => insertLinePrefix("- ")} title="Bullet list">•</button>
<button onclick={() => insertLinePrefix("- [ ] ")} title="Checkbox">☑</button>
<span class="spacer"></span>
<button onclick={exportMd} title="Export notes as .md">Export .md</button>
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
Export bundle
</button>
</div>
<div class="editor-preview">
<textarea
bind:this={editorEl}
bind:value={notesText}
oninput={scheduleSave}
placeholder="Notes…"
></textarea>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify in renderNotes() -->
<div class="preview">{@html renderNotes(notesText)}</div>
</div>
</div>
</div>
{:else if recording.segments.length === 0}
<p class="muted pad">Transcript will appear here as you record.</p>
{:else}
<div class="pad">
{#each recording.segments as s (s.id)} {#each recording.segments as s (s.id)}
<p class:interim={s.interim}> <p class:interim={s.interim}>
<strong>{speakerName(s.speaker)}:</strong> {s.text} <strong>{speakerName(s.speaker)}:</strong>
{s.text}
</p> </p>
{/each} {/each}
</div>
{/if} {/if}
</div> </div>
<style> <style>
.wrap { padding: 1rem; max-width: 760px; } .wrap {
.muted { color: var(--muted); } height: 100%;
.interim { opacity: 0.55; font-style: italic; } }
p { margin: 0.35rem 0; line-height: 1.5; } .pad {
padding: 1rem;
max-width: 760px;
}
.muted {
color: var(--muted);
}
.interim {
opacity: 0.55;
font-style: italic;
}
p {
margin: 0.35rem 0;
line-height: 1.5;
}
.split {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
}
.pane {
overflow: auto;
padding: 0.75rem 1rem;
min-width: 0;
}
.pane.transcript {
border-right: 1px solid var(--border);
}
.pane h4 {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--muted);
}
.toolbar {
display: flex;
align-items: center;
gap: 0.3rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.toolbar button {
background: transparent;
border: 1px solid var(--border);
color: var(--fg);
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
cursor: pointer;
}
.toolbar .spacer {
flex: 1;
}
.editor-preview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
height: calc(100% - 2.5rem);
}
textarea {
resize: none;
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font: inherit;
}
.preview {
overflow: auto;
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
line-height: 1.5;
}
.preview :global(h1),
.preview :global(h2),
.preview :global(h3) {
margin: 0.6rem 0 0.3rem;
}
</style> </style>