chore: remove 11 orphan Tauri commands and downstream dead code
Sweep of registered-but-never-invoked commands surfaced by an audit against the frontend invoke() call sites. Each was confirmed dead via grep across src-tauri, src, and crates: no caller anywhere. Removed commands: - check_model, count_transcripts_command, get_profile_cmd, install_update, list_feedback_examples_cmd (utility/CRUD shapes never wired) - save_audio, start_native_capture, stop_native_capture (native-capture path superseded by the live transcription session) - transcribe_pcm, transcribe_pcm_parakeet (PCM commands superseded by live session; no frontend caller) - close_preview_window (preview window is hidden via the core:window:allow-hide capability, not the command) Cascade in audio.rs (~430 lines removed): - CaptureWorker, NativeCaptureState struct + impl, stop_worker, append_recorded_chunk, MAX_NATIVE_CAPTURE_RETURN_SAMPLES, persist_audio_samples - The two cfg(test) tests that exercised stop_worker (the recording_filename tests stay, supporting resolve_recording_path which the live session uses) Cascade elsewhere: - FeedbackDto struct and its From<FeedbackRow> impl - Stale storage imports in feedback.rs, profiles.rs, transcripts.rs - tauri::Emitter import in transcription.rs - app.manage(NativeCaptureState::new()) in lib.rs setup generate_handler entries removed for all 11 commands. cargo check passes cleanly with zero warnings; no tests reference any deleted symbols. Net: 709 deletions, 17 insertions across 9 files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,10 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::{mpsc as tokio_mpsc, Mutex as AsyncMutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::commands::security::ensure_main_window;
|
||||
use magnotia_audio::{DeviceInfo, MicrophoneCapture, WavWriter};
|
||||
use magnotia_core::constants::WHISPER_SAMPLE_RATE;
|
||||
use magnotia_core::types::AudioSamples;
|
||||
|
||||
const MAX_NATIVE_CAPTURE_RETURN_SAMPLES: usize = WHISPER_SAMPLE_RATE as usize * 60 * 10;
|
||||
use magnotia_audio::{DeviceInfo, MicrophoneCapture};
|
||||
|
||||
/// Enumerate every input device available to cpal, with metadata for the
|
||||
/// Settings device-picker UI. Includes a flag for likely PulseAudio /
|
||||
@@ -25,311 +18,10 @@ pub async fn list_audio_devices(window: tauri::WebviewWindow) -> Result<Vec<Devi
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A running native-capture accumulator worker, held so the command
|
||||
/// layer can both signal it to stop and `await` its termination. RB-06
|
||||
/// replaced a fire-and-forget `tokio::spawn` that let the previous
|
||||
/// worker keep flushing and appending samples after `stop_native_capture`
|
||||
/// returned — a rapid start → stop → start could contaminate the new
|
||||
/// session's samples vector with tail writes from the old one.
|
||||
struct CaptureWorker {
|
||||
stop_tx: tokio_mpsc::Sender<()>,
|
||||
join: JoinHandle<()>,
|
||||
}
|
||||
|
||||
/// Send the stop signal and await full worker termination. Consumes
|
||||
/// `CaptureWorker` because the contained handles are single-use.
|
||||
/// Errors from `join.await` (task panicked or was cancelled) are
|
||||
/// logged and swallowed — the caller only needs the synchronisation
|
||||
/// barrier, not the worker's return value.
|
||||
async fn stop_worker(worker: CaptureWorker) {
|
||||
let _ = worker.stop_tx.send(()).await;
|
||||
drop(worker.stop_tx);
|
||||
if let Err(e) = worker.join.await {
|
||||
eprintln!("[native-capture] worker task did not terminate cleanly: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state for native microphone capture.
|
||||
pub struct NativeCaptureState {
|
||||
/// The running accumulator worker, if any. `tokio::sync::Mutex`
|
||||
/// because the fastest-moving consumer (`stop_worker`) awaits while
|
||||
/// holding the lock — a `std::sync::Mutex` would have to be released
|
||||
/// and reacquired around each await point.
|
||||
worker: AsyncMutex<Option<CaptureWorker>>,
|
||||
/// Compatibility buffer returned by stop_native_capture. Capped; the
|
||||
/// authoritative capture is streamed to temp WAV on disk.
|
||||
all_samples: Arc<Mutex<Vec<f32>>>,
|
||||
wav_writer: Arc<Mutex<Option<WavWriter>>>,
|
||||
temp_audio_path: Arc<Mutex<Option<PathBuf>>>,
|
||||
capture_truncated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl NativeCaptureState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
worker: AsyncMutex::new(None),
|
||||
all_samples: Arc::new(Mutex::new(Vec::new())),
|
||||
wav_writer: Arc::new(Mutex::new(None)),
|
||||
temp_audio_path: Arc::new(Mutex::new(None)),
|
||||
capture_truncated: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_recorded_chunk(
|
||||
all_samples: &Arc<Mutex<Vec<f32>>>,
|
||||
wav_writer: &Arc<Mutex<Option<WavWriter>>>,
|
||||
truncated: &Arc<AtomicBool>,
|
||||
chunk: &[f32],
|
||||
) {
|
||||
if let Ok(mut writer) = wav_writer.lock() {
|
||||
if let Some(writer) = writer.as_mut() {
|
||||
if let Err(e) = writer.append(chunk) {
|
||||
eprintln!("[native-capture] temp WAV append failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut all) = all_samples.lock() {
|
||||
let remaining = MAX_NATIVE_CAPTURE_RETURN_SAMPLES.saturating_sub(all.len());
|
||||
if remaining >= chunk.len() {
|
||||
all.extend_from_slice(chunk);
|
||||
} else {
|
||||
if remaining > 0 {
|
||||
all.extend_from_slice(&chunk[..remaining]);
|
||||
}
|
||||
truncated.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start native microphone capture via cpal.
|
||||
/// Streams 16kHz mono PCM chunks to the frontend via `native-pcm` events.
|
||||
///
|
||||
/// `device_name`: explicit device name (from `list_audio_devices`) or None / ""
|
||||
/// to auto-select. The frontend passes `settings.microphoneDevice` here so the
|
||||
/// user's pick from Settings → Audio → Microphone takes effect.
|
||||
#[tauri::command]
|
||||
pub async fn start_native_capture(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, NativeCaptureState>,
|
||||
device_name: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
eprintln!(
|
||||
"[native-capture] start_native_capture called (device='{}')",
|
||||
device_name.as_deref().unwrap_or("<auto>")
|
||||
);
|
||||
|
||||
// Stop any in-flight worker and AWAIT its termination before opening
|
||||
// a new capture. Without the join we would race a draining worker
|
||||
// against the `all_samples.clear()` below, leaving old-session
|
||||
// samples in the new-session vector (RB-06).
|
||||
if let Some(existing) = state.worker.lock().await.take() {
|
||||
stop_worker(existing).await;
|
||||
}
|
||||
|
||||
// `MicrophoneCapture::start()` is synchronous and may spend up to
|
||||
// DEVICE_VALIDATION_MS per device per pass (350ms × N devices × 2 passes
|
||||
// worst case). Run on a blocking thread so the async runtime stays
|
||||
// responsive to other Tauri commands. (Codex review 2026/04/17 D2)
|
||||
let device_name_for_blocking = device_name.clone();
|
||||
let (capture, rx) =
|
||||
tokio::task::spawn_blocking(move || match device_name_for_blocking.as_deref() {
|
||||
Some(name) if !name.is_empty() => MicrophoneCapture::start_with_device(name),
|
||||
_ => MicrophoneCapture::start(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("audio task join error: {e}"))?
|
||||
.map_err(|e| {
|
||||
eprintln!("[native-capture] MicrophoneCapture::start failed: {e}");
|
||||
e.to_string()
|
||||
})?;
|
||||
eprintln!(
|
||||
"[native-capture] cpal capture started successfully on '{}'",
|
||||
capture.device_name
|
||||
);
|
||||
|
||||
// Wrap capture in Arc<Mutex> so it can be moved into the blocking task
|
||||
let capture = Arc::new(Mutex::new(Some(capture)));
|
||||
let capture_clone = capture.clone();
|
||||
|
||||
let all_samples = state.all_samples.clone();
|
||||
all_samples.lock().unwrap().clear();
|
||||
state.capture_truncated.store(false, Ordering::Relaxed);
|
||||
|
||||
let temp_path = std::env::temp_dir().join(recording_filename());
|
||||
let writer = WavWriter::create(&temp_path, WHISPER_SAMPLE_RATE, 1)
|
||||
.map_err(|e| format!("Failed to create temp capture WAV: {e}"))?;
|
||||
*state.wav_writer.lock().unwrap() = Some(writer);
|
||||
*state.temp_audio_path.lock().unwrap() = Some(temp_path);
|
||||
|
||||
let (stop_tx, mut stop_rx) = tokio_mpsc::channel::<()>(1);
|
||||
|
||||
let all_samples_clone = all_samples.clone();
|
||||
let wav_writer_clone = state.wav_writer.clone();
|
||||
let truncated_clone = state.capture_truncated.clone();
|
||||
|
||||
// Spawn a task that reads cpal chunks, downsamples to 16kHz mono,
|
||||
// and emits events to the frontend. The JoinHandle is retained in
|
||||
// `state.worker` so `stop_native_capture` can await full termination.
|
||||
let join = tokio::spawn(async move {
|
||||
let mut pcm_buffer: Vec<f32> = Vec::new();
|
||||
let chunk_size = 8000_usize; // ~0.5s at 16kHz
|
||||
|
||||
loop {
|
||||
// Check for stop signal (non-blocking)
|
||||
if stop_rx.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Drain available audio chunks from cpal (non-blocking).
|
||||
// Distinguish Empty (try again) from Disconnected (capture stream
|
||||
// is dead — exit the loop, don't spin forever).
|
||||
// (Codex review 2026/04/17 M3)
|
||||
let mut got_data = false;
|
||||
let mut capture_dead = false;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(chunk) => {
|
||||
got_data = true;
|
||||
let sample_rate = chunk.sample_rate;
|
||||
let channels = chunk.channels as usize;
|
||||
|
||||
// Downmix to mono if stereo
|
||||
let mono: Vec<f32> = if channels > 1 {
|
||||
chunk
|
||||
.samples
|
||||
.chunks(channels)
|
||||
.map(|frame| frame.iter().sum::<f32>() / channels as f32)
|
||||
.collect()
|
||||
} else {
|
||||
chunk.samples
|
||||
};
|
||||
|
||||
// Downsample to 16kHz using simple decimation
|
||||
// (acceptable quality for speech — same approach as pcm-processor.js)
|
||||
let ratio = sample_rate as f64 / WHISPER_SAMPLE_RATE as f64;
|
||||
if (ratio - 1.0).abs() < 0.01 {
|
||||
pcm_buffer.extend_from_slice(&mono);
|
||||
} else {
|
||||
let mut pos: f64 = 0.0;
|
||||
for &s in &mono {
|
||||
pos += 1.0;
|
||||
if pos >= ratio {
|
||||
pcm_buffer.push(s);
|
||||
pos -= ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
eprintln!(
|
||||
"[native-capture] capture stream disconnected; accumulator exiting"
|
||||
);
|
||||
capture_dead = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit chunks to frontend when we have enough
|
||||
while pcm_buffer.len() >= chunk_size {
|
||||
let chunk: Vec<f32> = pcm_buffer.drain(..chunk_size).collect();
|
||||
|
||||
append_recorded_chunk(
|
||||
&all_samples_clone,
|
||||
&wav_writer_clone,
|
||||
&truncated_clone,
|
||||
&chunk,
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"native-pcm",
|
||||
serde_json::json!({
|
||||
"samples": chunk,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if capture_dead {
|
||||
break;
|
||||
}
|
||||
if !got_data {
|
||||
// Avoid busy-spinning when no audio data is available
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit any remaining samples
|
||||
if !pcm_buffer.is_empty() {
|
||||
append_recorded_chunk(
|
||||
&all_samples_clone,
|
||||
&wav_writer_clone,
|
||||
&truncated_clone,
|
||||
&pcm_buffer,
|
||||
);
|
||||
let _ = app.emit(
|
||||
"native-pcm",
|
||||
serde_json::json!({
|
||||
"samples": pcm_buffer,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Drop the capture to stop the cpal stream
|
||||
if let Ok(mut cap) = capture_clone.lock() {
|
||||
cap.take();
|
||||
}
|
||||
if let Ok(mut writer) = wav_writer_clone.lock() {
|
||||
if let Some(writer) = writer.take() {
|
||||
if let Err(e) = writer.finalize() {
|
||||
eprintln!("[native-capture] temp WAV finalize failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*state.worker.lock().await = Some(CaptureWorker { stop_tx, join });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop native microphone capture. Returns all captured samples (16kHz mono).
|
||||
///
|
||||
/// Awaits full worker termination before reading `all_samples`, so the
|
||||
/// returned vector contains every sample the worker flushed — and
|
||||
/// nothing from a worker that technically outlived the call (RB-06).
|
||||
#[tauri::command]
|
||||
pub async fn stop_native_capture(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, NativeCaptureState>,
|
||||
) -> Result<Vec<f32>, String> {
|
||||
ensure_main_window(&window)?;
|
||||
if let Some(worker) = state.worker.lock().await.take() {
|
||||
stop_worker(worker).await;
|
||||
}
|
||||
|
||||
let samples = {
|
||||
let mut all = state.all_samples.lock().unwrap();
|
||||
std::mem::take(&mut *all)
|
||||
};
|
||||
if state.capture_truncated.load(Ordering::Relaxed) {
|
||||
eprintln!(
|
||||
"[native-capture] stop_native_capture returned a capped compatibility buffer; temp WAV contains the full capture"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(samples)
|
||||
}
|
||||
|
||||
/// Resolve the destination path for a new live-capture recording,
|
||||
/// ensuring the parent directory exists. Extracted from
|
||||
/// `persist_audio_samples` so `start_live_transcription_session` can
|
||||
/// hand the path to the progressive WAV writer before any samples
|
||||
/// arrive (brief item #19).
|
||||
/// ensuring the parent directory exists. Used by
|
||||
/// `start_live_transcription_session` to hand the path to the progressive
|
||||
/// WAV writer before any samples arrive (brief item #19).
|
||||
pub fn resolve_recording_path(
|
||||
app: &tauri::AppHandle,
|
||||
output_folder: Option<&str>,
|
||||
@@ -368,7 +60,7 @@ fn recording_filename() -> String {
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
let nanos = duration.subsec_nanos();
|
||||
let counter = RECORDING_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let counter = RECORDING_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("magnotia-{secs}-{nanos:09}-{counter:04}.wav")
|
||||
}
|
||||
|
||||
@@ -377,14 +69,11 @@ fn recording_filename() -> String {
|
||||
/// restarts, so cross-launch collisions are already impossible — the
|
||||
/// counter is the last-mile guarantee against within-launch same-tick
|
||||
/// collisions.
|
||||
static RECORDING_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
static RECORDING_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{recording_filename, stop_worker, CaptureWorker};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use super::recording_filename;
|
||||
|
||||
#[test]
|
||||
fn recording_filenames_are_unique_across_rapid_calls() {
|
||||
@@ -437,104 +126,4 @@ mod tests {
|
||||
);
|
||||
assert!(parts[2].chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
// RB-06 regression: after `stop_worker(worker).await` completes, the
|
||||
// underlying task must have exited — no lingering writes to shared
|
||||
// state can leak past the stop point. The real native-capture
|
||||
// worker drains a capture queue and appends to `all_samples`; this
|
||||
// test swaps that for a synthetic worker that bumps an atomic
|
||||
// counter in a loop and applies a distinct "flush" marker at exit.
|
||||
// The assertions mirror the real-world invariant a caller needs:
|
||||
// (a) after stop_worker returns, the worker has run its flush;
|
||||
// (b) subsequent sleeps see the counter frozen — no writes occur
|
||||
// after the join barrier.
|
||||
// Pre-fix behaviour (fire-and-forget `tokio::spawn`) failed both:
|
||||
// a start→stop→start cycle could observe tail writes from the
|
||||
// previous worker in the new session's vector.
|
||||
#[tokio::test]
|
||||
async fn stop_worker_awaits_full_termination_no_writes_after_join() {
|
||||
let counter = Arc::new(AtomicU32::new(0));
|
||||
let counter_task = counter.clone();
|
||||
let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
let join = tokio::spawn(async move {
|
||||
loop {
|
||||
if stop_rx.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
counter_task.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
}
|
||||
// Flush marker — mirrors the final pcm_buffer drain in the
|
||||
// real worker. Setting a value with a distinctive high bit
|
||||
// so the test can prove the flush ran.
|
||||
counter_task.fetch_or(0x8000_0000, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
// Let the worker accumulate a few bumps before we signal stop.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
stop_worker(CaptureWorker { stop_tx, join }).await;
|
||||
|
||||
let after_stop = counter.load(Ordering::SeqCst);
|
||||
assert!(
|
||||
after_stop & 0x8000_0000 != 0,
|
||||
"flush marker must be set post-stop (got {after_stop:#x})"
|
||||
);
|
||||
|
||||
// Post-join, no further writes are possible because the task
|
||||
// has ended. Sleep briefly and re-read to confirm.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
let later = counter.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
later, after_stop,
|
||||
"no writes must happen after stop_worker returns"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_worker_is_idempotent_on_a_worker_that_has_already_exited() {
|
||||
// A worker that stops itself (channel disconnected, capture
|
||||
// dead, etc.) must still be join-able cleanly by stop_worker —
|
||||
// the helper should swallow any expected "task already done"
|
||||
// condition without panicking.
|
||||
let (stop_tx, _stop_rx) = mpsc::channel::<()>(1);
|
||||
let join = tokio::spawn(async { /* exit immediately */ });
|
||||
|
||||
// Give the task a tick to finish.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
|
||||
// This must not hang or panic.
|
||||
stop_worker(CaptureWorker { stop_tx, join }).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn persist_audio_samples(
|
||||
app: &tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
output_folder: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let path = resolve_recording_path(app, output_folder.as_deref())?;
|
||||
let path_clone = path.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let audio = AudioSamples::mono_16khz(samples);
|
||||
magnotia_audio::write_wav(&path_clone, &audio).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Save PCM f32 samples as a WAV file. Returns the file path.
|
||||
#[tauri::command]
|
||||
pub async fn save_audio(
|
||||
window: tauri::WebviewWindow,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
output_folder: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
ensure_main_window(&window)?;
|
||||
persist_audio_samples(&app, samples, output_folder).await
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
// on AI-generated output feeds a few-shot loop that conditions future
|
||||
// prompts on the user's preferred style.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
|
||||
use magnotia_storage::{
|
||||
list_feedback_examples as db_list_feedback_examples, record_feedback as db_record_feedback,
|
||||
FeedbackRow, FeedbackTargetType, RecordFeedbackParams,
|
||||
record_feedback as db_record_feedback, FeedbackTargetType, RecordFeedbackParams,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
@@ -35,36 +34,6 @@ pub struct RecordFeedbackInput {
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FeedbackDto {
|
||||
pub id: i64,
|
||||
pub target_type: String,
|
||||
pub target_id: Option<String>,
|
||||
pub rating: i64,
|
||||
pub original_text: Option<String>,
|
||||
pub corrected_text: Option<String>,
|
||||
pub context_json: Option<String>,
|
||||
pub profile_id: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<FeedbackRow> for FeedbackDto {
|
||||
fn from(r: FeedbackRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
target_type: r.target_type,
|
||||
target_id: r.target_id,
|
||||
rating: r.rating,
|
||||
original_text: r.original_text,
|
||||
corrected_text: r.corrected_text,
|
||||
context_json: r.context_json,
|
||||
profile_id: r.profile_id,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_target_type(raw: &str) -> Result<FeedbackTargetType, String> {
|
||||
FeedbackTargetType::parse(raw).ok_or_else(|| format!("unknown feedback target_type: {raw}"))
|
||||
}
|
||||
@@ -91,20 +60,3 @@ pub async fn record_feedback(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_feedback_examples_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
target_type: String,
|
||||
limit: Option<i64>,
|
||||
min_rating: Option<i8>,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<Vec<FeedbackDto>, String> {
|
||||
let target = parse_target_type(&target_type)?;
|
||||
let limit = limit.unwrap_or(8).clamp(1, 64);
|
||||
let min_rating = min_rating.unwrap_or(0).clamp(-1, 1);
|
||||
let rows =
|
||||
db_list_feedback_examples(&state.db, target, limit, min_rating, profile_id.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows.into_iter().map(FeedbackDto::from).collect())
|
||||
}
|
||||
|
||||
@@ -529,12 +529,6 @@ pub async fn download_model(
|
||||
Ok(format!("Model {} downloaded", size))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_model(size: String) -> Result<bool, String> {
|
||||
let id = whisper_model_id(&size);
|
||||
Ok(model_manager::is_downloaded(&id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_models() -> Result<Vec<String>, String> {
|
||||
let ids = model_manager::list_downloaded();
|
||||
|
||||
@@ -15,9 +15,8 @@ use magnotia_ai_formatting::extract_corrections;
|
||||
use magnotia_storage::{
|
||||
add_profile_term as db_add_profile_term, create_profile as db_create_profile,
|
||||
delete_profile as db_delete_profile, delete_profile_term as db_delete_profile_term,
|
||||
get_profile as db_get_profile, list_profile_terms as db_list_profile_terms,
|
||||
list_profiles as db_list_profiles, update_profile as db_update_profile, ProfileRow,
|
||||
ProfileTermRow,
|
||||
list_profile_terms as db_list_profile_terms, list_profiles as db_list_profiles,
|
||||
update_profile as db_update_profile, ProfileRow, ProfileTermRow,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
@@ -79,17 +78,6 @@ pub async fn list_profiles_cmd(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<ProfileDto>, String> {
|
||||
db_get_profile(&state.db, &id)
|
||||
.await
|
||||
.map(|opt| opt.map(ProfileDto::from))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::commands::build_initial_prompt;
|
||||
use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded};
|
||||
use crate::commands::security::ensure_main_window;
|
||||
@@ -137,96 +135,6 @@ fn transcribe_samples_sync(
|
||||
})
|
||||
}
|
||||
|
||||
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
chunk_id: u32,
|
||||
language: String,
|
||||
initial_prompt: String,
|
||||
remove_fillers: bool,
|
||||
british_english: bool,
|
||||
anti_hallucination: bool,
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
if !state.whisper_engine.is_loaded() {
|
||||
return Err(
|
||||
"Whisper model not loaded. Download a Whisper model in Settings.".to_string(),
|
||||
);
|
||||
}
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| magnotia_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
|
||||
let profile = magnotia_storage::database::get_profile(&state.db, &resolved_profile_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Profile {resolved_profile_id} not found"))?;
|
||||
|
||||
let profile_terms: Vec<String> =
|
||||
magnotia_storage::database::list_profile_terms(&state.db, &resolved_profile_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|t| t.term)
|
||||
.collect();
|
||||
|
||||
let engine = state.whisper_engine.clone();
|
||||
let options = TranscriptionOptions {
|
||||
language: Some(language),
|
||||
initial_prompt: build_initial_prompt(
|
||||
&initial_prompt,
|
||||
&profile.initial_prompt,
|
||||
&profile_terms,
|
||||
),
|
||||
};
|
||||
|
||||
let timed = tokio::task::spawn_blocking(move || {
|
||||
let audio = AudioSamples::mono_16khz(samples);
|
||||
engine
|
||||
.transcribe_sync(&audio, &options)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let dictionary_terms = profile_terms.clone();
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
let raw_text = join_segment_text(&segments);
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
remove_fillers,
|
||||
british_english,
|
||||
anti_hallucination,
|
||||
format_mode: FormatMode::parse(&format_mode),
|
||||
dictionary_terms,
|
||||
},
|
||||
Some(state.llm_engine.as_ref()),
|
||||
);
|
||||
|
||||
app.emit(
|
||||
"transcription-result",
|
||||
serde_json::json!({
|
||||
"status": "transcription",
|
||||
"segments": segments,
|
||||
"language": timed.transcript.language(),
|
||||
"duration": timed.transcript.duration(),
|
||||
"chunk_id": chunk_id,
|
||||
"inference_ms": timed.inference_ms,
|
||||
"raw_text": raw_text,
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn join_segment_text(segments: &[Segment]) -> String {
|
||||
segments
|
||||
.iter()
|
||||
@@ -342,82 +250,3 @@ pub async fn transcribe_file(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_pcm_parakeet(
|
||||
window: tauri::WebviewWindow,
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
samples: Vec<f32>,
|
||||
chunk_id: u32,
|
||||
remove_fillers: bool,
|
||||
british_english: bool,
|
||||
anti_hallucination: bool,
|
||||
format_mode: String,
|
||||
profile_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
if !state.parakeet_engine.is_loaded() {
|
||||
return Err(
|
||||
"Parakeet model not loaded. Enable Parakeet in Settings.".to_string(),
|
||||
);
|
||||
}
|
||||
let resolved_profile_id =
|
||||
profile_id.unwrap_or_else(|| magnotia_storage::DEFAULT_PROFILE_ID.to_string());
|
||||
|
||||
// Validate the profile exists so parakeet and whisper behave identically
|
||||
// when a bogus id slips through from the frontend.
|
||||
magnotia_storage::database::get_profile(&state.db, &resolved_profile_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Profile {resolved_profile_id} not found"))?;
|
||||
|
||||
let profile_terms: Vec<String> =
|
||||
magnotia_storage::database::list_profile_terms(&state.db, &resolved_profile_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|t| t.term)
|
||||
.collect();
|
||||
|
||||
let engine = state.parakeet_engine.clone();
|
||||
let options = TranscriptionOptions::default();
|
||||
|
||||
let timed = tokio::task::spawn_blocking(move || {
|
||||
transcribe_samples_sync(engine, "parakeet", samples, options)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let dictionary_terms = profile_terms.clone();
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
let raw_text = join_segment_text(&segments);
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
remove_fillers,
|
||||
british_english,
|
||||
anti_hallucination,
|
||||
format_mode: FormatMode::parse(&format_mode),
|
||||
dictionary_terms,
|
||||
},
|
||||
Some(state.llm_engine.as_ref()),
|
||||
);
|
||||
|
||||
app.emit(
|
||||
"transcription-result",
|
||||
serde_json::json!({
|
||||
"status": "transcription",
|
||||
"segments": segments,
|
||||
"language": timed.transcript.language(),
|
||||
"duration": timed.transcript.duration(),
|
||||
"chunk_id": chunk_id,
|
||||
"inference_ms": timed.inference_ms,
|
||||
"raw_text": raw_text,
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,11 +14,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use magnotia_storage::{
|
||||
count_transcripts, delete_transcript as db_delete_transcript,
|
||||
get_transcript as db_get_transcript, insert_transcript as db_insert_transcript,
|
||||
list_transcripts_paged, search_transcripts as db_search_transcripts,
|
||||
update_transcript as db_update_transcript, update_transcript_meta as db_update_transcript_meta,
|
||||
InsertTranscriptParams, TranscriptRow,
|
||||
delete_transcript as db_delete_transcript, get_transcript as db_get_transcript,
|
||||
insert_transcript as db_insert_transcript, list_transcripts_paged,
|
||||
search_transcripts as db_search_transcripts, update_transcript as db_update_transcript,
|
||||
update_transcript_meta as db_update_transcript_meta, InsertTranscriptParams, TranscriptRow,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
@@ -145,14 +144,6 @@ pub async fn list_transcripts(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Total count of transcripts (for "showing X of N" UI).
|
||||
#[tauri::command]
|
||||
pub async fn count_transcripts_command(state: tauri::State<'_, AppState>) -> Result<i64, String> {
|
||||
count_transcripts(&state.db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transcript(
|
||||
state: tauri::State<'_, AppState>,
|
||||
|
||||
@@ -8,9 +8,3 @@ pub async fn check_for_update(window: tauri::WebviewWindow) -> Result<Option<Str
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Download and stage the update. The app will install it on next launch.
|
||||
#[tauri::command]
|
||||
pub async fn install_update(window: tauri::WebviewWindow) -> Result<(), String> {
|
||||
ensure_main_window(&window)?;
|
||||
Err("Updates are disabled until release signing is configured.".to_string())
|
||||
}
|
||||
|
||||
@@ -27,12 +27,6 @@ pub async fn open_preview_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
Err(ANDROID_MULTIWINDOW_ERR.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[tauri::command]
|
||||
pub async fn close_preview_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
Err(ANDROID_MULTIWINDOW_ERR.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[tauri::command]
|
||||
pub async fn open_viewer_window(_app: tauri::AppHandle) -> Result<(), String> {
|
||||
@@ -152,17 +146,6 @@ pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hide the transcription preview window without destroying it so the next
|
||||
/// open is instant. Returns Ok even when no preview window exists.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[tauri::command]
|
||||
pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("transcription-preview") {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the transcript viewer window.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[tauri::command]
|
||||
|
||||
@@ -294,7 +294,6 @@ pub fn run() {
|
||||
app.manage(PreferencesScript(init_script));
|
||||
|
||||
app.manage(commands::hotkey::HotkeyState::new());
|
||||
app.manage(commands::audio::NativeCaptureState::new());
|
||||
app.manage(commands::live::LiveTranscriptionState::default());
|
||||
app.manage(commands::tts::TtsState::new());
|
||||
app.manage(commands::meeting::MeetingState::new());
|
||||
@@ -323,7 +322,6 @@ pub fn run() {
|
||||
save_preferences,
|
||||
// Whisper model management
|
||||
commands::models::download_model,
|
||||
commands::models::check_model,
|
||||
commands::models::list_models,
|
||||
commands::models::load_model,
|
||||
commands::models::prewarm_default_model_cmd,
|
||||
@@ -346,13 +344,8 @@ pub fn run() {
|
||||
commands::models::load_parakeet_model,
|
||||
commands::models::check_parakeet_engine,
|
||||
// Transcription
|
||||
commands::transcription::transcribe_pcm,
|
||||
commands::transcription::transcribe_file,
|
||||
commands::transcription::transcribe_pcm_parakeet,
|
||||
// Audio
|
||||
commands::audio::save_audio,
|
||||
commands::audio::start_native_capture,
|
||||
commands::audio::stop_native_capture,
|
||||
commands::audio::list_audio_devices,
|
||||
// Tasks (canonical SQLite-backed task CRUD)
|
||||
commands::tasks::create_task_cmd,
|
||||
@@ -369,7 +362,6 @@ pub fn run() {
|
||||
commands::tasks::list_recent_completions_cmd,
|
||||
// HITL feedback (Phase 2 roadmap)
|
||||
commands::feedback::record_feedback,
|
||||
commands::feedback::list_feedback_examples_cmd,
|
||||
// Read aloud (Phase 4 roadmap)
|
||||
commands::tts::tts_speak,
|
||||
commands::tts::tts_stop,
|
||||
@@ -387,7 +379,6 @@ pub fn run() {
|
||||
commands::intentions::delete_implementation_rule,
|
||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||
commands::profiles::list_profiles_cmd,
|
||||
commands::profiles::get_profile_cmd,
|
||||
commands::profiles::create_profile_cmd,
|
||||
commands::profiles::update_profile_cmd,
|
||||
commands::profiles::delete_profile_cmd,
|
||||
@@ -398,7 +389,6 @@ pub fn run() {
|
||||
// Transcripts (canonical SQLite-backed history) — Day 4
|
||||
commands::transcripts::add_transcript,
|
||||
commands::transcripts::list_transcripts,
|
||||
commands::transcripts::count_transcripts_command,
|
||||
commands::transcripts::get_transcript,
|
||||
commands::transcripts::update_transcript,
|
||||
commands::transcripts::update_transcript_meta_cmd,
|
||||
@@ -417,7 +407,6 @@ pub fn run() {
|
||||
commands::windows::open_task_window,
|
||||
commands::windows::open_viewer_window,
|
||||
commands::windows::open_preview_window,
|
||||
commands::windows::close_preview_window,
|
||||
// Clipboard
|
||||
commands::clipboard::copy_to_clipboard,
|
||||
// Filesystem (Phase 9 save-dialog path)
|
||||
@@ -441,7 +430,6 @@ pub fn run() {
|
||||
commands::hotkey::stop_evdev_hotkey,
|
||||
// Updater
|
||||
commands::update::check_for_update,
|
||||
commands::update::install_update,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Magnotia");
|
||||
|
||||
Reference in New Issue
Block a user