Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ pub async fn start_session(
on_segment: Channel<transcription::TranscriptEvent>,
audio_source: Option<String>,
preferred_mic_device: Option<String>,
asr_engine: Option<String>,
) -> Result<i64, String> {
let session_handle = app.state::<SessionHandle>().inner().clone();
let session_handle_for_start = session_handle.clone();
Expand All @@ -286,6 +287,7 @@ pub async fn start_session(
on_segment,
audio_source,
preferred_mic_device,
asr_engine,
})
})
.await
Expand Down Expand Up @@ -523,6 +525,7 @@ pub async fn start_transcription(
db_pool: None,
meeting_id: None,
on_worker_disconnected: None,
asr_engine_preference: None,
})?;

let _ = app.emit("transcribing-active", ());
Expand Down Expand Up @@ -583,6 +586,15 @@ pub async fn check_model_ready(
Ok(transcription::model::check_model_ready(data_dir.inner().0.as_path()))
}

#[tauri::command]
pub async fn check_parakeet_model_ready(
data_dir: tauri::State<'_, DataDir>,
) -> Result<bool, String> {
Ok(transcription::model::check_parakeet_model_ready(
data_dir.inner().0.as_path(),
))
}

#[tauri::command]
pub async fn check_diarization_model_ready(
data_dir: tauri::State<'_, DataDir>,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ pub fn run() {
commands::start_transcription,
commands::stop_transcription,
commands::check_model_ready,
commands::check_parakeet_model_ready,
commands::download_model,
commands::start_diarization,
commands::rename_speaker,
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub struct SessionStartArgs {
pub on_segment: Channel<TranscriptEvent>,
pub audio_source: Option<String>,
pub preferred_mic_device: Option<String>,
pub asr_engine: Option<String>,
}

impl SessionCoordinator {
Expand Down Expand Up @@ -95,6 +96,7 @@ impl SessionCoordinator {
on_segment,
audio_source,
preferred_mic_device,
asr_engine,
} = args;

if !matches!(self.phase, SessionPhase::Idle | SessionPhase::Processing) {
Expand Down Expand Up @@ -174,6 +176,7 @@ impl SessionCoordinator {
db_pool: Some(pool.clone()),
meeting_id: Some(meeting_id),
on_worker_disconnected: Some(degraded_callback),
asr_engine_preference: asr_engine,
})
};

Expand Down
44 changes: 31 additions & 13 deletions src-tauri/src/transcription/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub struct StartWorkerArgs {
pub db_pool: Option<SqlitePool>,
pub meeting_id: Option<i64>,
pub on_worker_disconnected: Option<Arc<dyn Fn() + Send + Sync>>,
pub asr_engine_preference: Option<String>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -80,9 +81,11 @@ fn join_with_timeout(handle: JoinHandle<()>, timeout: Duration) -> bool {
joined_rx.recv_timeout(timeout).is_ok()
}

fn resolve_asr_engine(data_dir: &Path) -> (String, PathBuf) {
// Prefer Parakeet when its model is present; otherwise fall back to Whisper.
if model::check_parakeet_assets_ready(data_dir) {
fn resolve_asr_engine(preference: Option<&str>, data_dir: &Path) -> (String, PathBuf) {
// Explicit Whisper always wins; otherwise prefer Parakeet when its model is present.
let use_parakeet =
preference != Some("whisper") && model::check_parakeet_assets_ready(data_dir);
if use_parakeet {
("parakeet".to_string(), model::parakeet_model_dir(data_dir))
} else {
("whisper".to_string(), model::whisper_turbo_model_dir(data_dir))
Expand All @@ -101,9 +104,11 @@ pub fn start_transcription_worker(
db_pool,
meeting_id,
on_worker_disconnected,
asr_engine_preference,
} = args;

let (asr_engine, model_dir) = resolve_asr_engine(data_dir.as_path());
let (asr_engine, model_dir) =
resolve_asr_engine(asr_engine_preference.as_deref(), data_dir.as_path());

let assets_ready = match asr_engine.as_str() {
"parakeet" => model::check_parakeet_assets_ready(data_dir.as_path()),
Expand Down Expand Up @@ -318,28 +323,41 @@ mod tests {
use super::*;

#[test]
fn resolve_prefers_parakeet_when_present_else_whisper() {
fn resolve_honors_explicit_preference_then_availability() {
let tmp = std::env::temp_dir().join(format!("on-engine-sel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);

let (engine, dir) = resolve_asr_engine(&tmp);
assert_eq!(engine, "whisper");
assert_eq!(dir, model::whisper_turbo_model_dir(&tmp));
let whisper_dir = model::whisper_turbo_model_dir(&tmp);
let parakeet_dir = model::parakeet_model_dir(&tmp);

// Nothing downloaded: availability and explicit whisper both pick whisper;
// explicit parakeet falls back to availability (whisper) since its model is absent.
assert_eq!(resolve_asr_engine(None, &tmp).0, "whisper");
assert_eq!(resolve_asr_engine(Some("whisper"), &tmp).0, "whisper");
assert_eq!(resolve_asr_engine(Some("parakeet"), &tmp).0, "whisper");

let pdir = model::parakeet_model_dir(&tmp);
std::fs::create_dir_all(&pdir).unwrap();
std::fs::create_dir_all(&parakeet_dir).unwrap();
for f in [
"encoder.int8.onnx",
"decoder.int8.onnx",
"joiner.int8.onnx",
"tokens.txt",
] {
std::fs::write(pdir.join(f), b"x").unwrap();
std::fs::write(parakeet_dir.join(f), b"x").unwrap();
}

let (engine, dir) = resolve_asr_engine(&tmp);
// No preference -> availability prefers parakeet when present.
let (engine, dir) = resolve_asr_engine(None, &tmp);
assert_eq!(engine, "parakeet");
assert_eq!(dir, model::parakeet_model_dir(&tmp));
assert_eq!(dir, parakeet_dir);

// Explicit whisper wins even though parakeet is present.
let (engine, dir) = resolve_asr_engine(Some("whisper"), &tmp);
assert_eq!(engine, "whisper");
assert_eq!(dir, whisper_dir);

// Explicit parakeet with its model present.
assert_eq!(resolve_asr_engine(Some("parakeet"), &tmp).0, "parakeet");

std::fs::remove_dir_all(&tmp).ok();
}
Expand Down
30 changes: 30 additions & 0 deletions src-tauri/src/transcription/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ pub fn check_model_ready(data_dir: &Path) -> bool {
check_transcription_assets_ready(data_dir) && vad_model_path(data_dir).exists()
}

pub fn check_parakeet_model_ready(data_dir: &Path) -> bool {
check_parakeet_assets_ready(data_dir) && vad_model_path(data_dir).exists()
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -73,4 +77,30 @@ mod tests {

std::fs::remove_dir_all(&tmp).ok();
}

#[test]
fn parakeet_model_ready_requires_assets_and_vad() {
let tmp =
std::env::temp_dir().join(format!("on-parakeet-model-ready-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);

let dir = parakeet_model_dir(&tmp);
std::fs::create_dir_all(&dir).unwrap();
for name in [
"encoder.int8.onnx",
"decoder.int8.onnx",
"joiner.int8.onnx",
"tokens.txt",
] {
std::fs::write(dir.join(name), b"x").unwrap();
}

// Assets present but VAD missing -> not ready.
assert!(!check_parakeet_model_ready(&tmp));

std::fs::write(vad_model_path(&tmp), b"x").unwrap();
assert!(check_parakeet_model_ready(&tmp));

std::fs::remove_dir_all(&tmp).ok();
}
}
104 changes: 104 additions & 0 deletions src/components/settings/TranscriptionSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';

// --- Mocks (must precede importing the component under test)

const mockInvoke = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvoke(...args),
Channel: class MockChannel {
onmessage: ((e: unknown) => void) | null = null;
},
}));

vi.mock('@tauri-apps/api/path', () => ({
join: (...parts: string[]) => Promise.resolve(parts.join('/')),
appLocalDataDir: () => Promise.resolve('/data'),
}));

const mockRemove = vi.fn();
vi.mock('@tauri-apps/plugin-fs', () => ({
readDir: vi.fn().mockResolvedValue([]),
remove: (...args: unknown[]) => mockRemove(...args),
}));

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? key,
i18n: { language: 'en' },
}),
}));

let mockEngine: 'whisper' | 'parakeet' = 'whisper';
const mockUpdateEngine = vi.fn();
vi.mock('../../hooks/useSettings', () => ({
useSetting: () => [mockEngine, mockUpdateEngine],
}));

import { TranscriptionSection } from './TranscriptionSection';

describe('TranscriptionSection ASR picker', () => {
beforeEach(() => {
mockInvoke.mockReset();
mockUpdateEngine.mockReset();
mockRemove.mockReset();
mockRemove.mockResolvedValue(undefined);
mockEngine = 'whisper';
mockInvoke.mockResolvedValue(false);
});

afterEach(() => {
cleanup();
});

it('checks parakeet readiness when the parakeet engine is selected', async () => {
mockEngine = 'parakeet';
render(<TranscriptionSection />);

await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('check_parakeet_model_ready');
});
});

it('persists the chosen engine when picked from the dropdown', async () => {
mockEngine = 'whisper';
render(<TranscriptionSection />);

fireEvent.click(await screen.findByText('Whisper', { exact: true }));
fireEvent.click(await screen.findByText('Parakeet', { exact: true }));

await waitFor(() => {
expect(mockUpdateEngine).toHaveBeenCalledWith('parakeet');
});
});

it('downloads the parakeet model via the parakeet command', async () => {
mockEngine = 'parakeet';
render(<TranscriptionSection />);

fireEvent.click(await screen.findByText('transModel_btnDownload', { exact: true }));

await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith(
'download_parakeet_model',
expect.objectContaining({ onEvent: expect.anything() }),
);
});
});

it('deletes only the selected engine model directory, preserving shared VAD', async () => {
mockEngine = 'parakeet';
mockInvoke.mockResolvedValue(true); // model ready -> delete button shows
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<TranscriptionSection />);

fireEvent.click(await screen.findByText('transModel_btnDelete', { exact: true }));

await waitFor(() => {
expect(mockRemove).toHaveBeenCalledWith(
'/data/models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
{ recursive: true },
);
});
});
});
Loading
Loading