diff --git a/.github/Dockerfile b/.github/Dockerfile
index 9ae630a8a..4d3d83618 100644
--- a/.github/Dockerfile
+++ b/.github/Dockerfile
@@ -5,6 +5,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# System deps for Tauri + mold linker + clang (for mold)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
+ cmake \
curl \
ca-certificates \
git \
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e7e63b860..8ebc7bb5b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -47,6 +47,9 @@ jobs:
if: steps.yarn-cache.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
+ - name: Install cmake (for whisper-rs)
+ run: apt-get update && apt-get install -y --no-install-recommends cmake && rm -rf /var/lib/apt/lists/*
+
- name: Build sidecar core binary
run: cargo build --profile ci --target x86_64-unknown-linux-gnu --bin openhuman-core
diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml
index 1bd9cae4c..ec99590fc 100644
--- a/.github/workflows/release-packages.yml
+++ b/.github/workflows/release-packages.yml
@@ -58,7 +58,7 @@ jobs:
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
- pkg-config libssl-dev build-essential
+ pkg-config libssl-dev build-essential cmake
- name: Build CLI binary and package tarball
env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4ce77e4cd..2d5cd7ffa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -230,7 +230,7 @@ jobs:
if: matrix.settings.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
- sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
+ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf cmake
# Skip first 7 lines of Cargo.lock (workspace package version bumps) so the key tracks dependency changes only
- name: Cargo.lock fingerprint (deps only)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 59f918b04..90d0b42fe 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -85,6 +85,9 @@ jobs:
app/src-tauri -> target
cache-on-failure: true
+ - name: Install cmake (for whisper-rs)
+ run: apt-get update && apt-get install -y --no-install-recommends cmake && rm -rf /var/lib/apt/lists/*
+
- name: Check formatting (cargo fmt)
run: cargo fmt --all -- --check
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index ce9e2ca95..365fdd064 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -77,6 +77,9 @@ jobs:
. -> target
cache-on-failure: true
+ - name: Install cmake (for whisper-rs)
+ run: apt-get update && apt-get install -y --no-install-recommends cmake && rm -rf /var/lib/apt/lists/*
+
- name: Check formatting (cargo fmt)
run: cargo fmt --all -- --check
diff --git a/Cargo.lock b/Cargo.lock
index c244627e1..7e780fc38 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -515,6 +515,26 @@ dependencies = [
"virtue",
]
+[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags 2.11.0",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.10.5",
+ "log",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn 2.0.117",
+]
+
[[package]]
name = "bitfield"
version = "0.19.4"
@@ -725,6 +745,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom 7.1.3",
+]
+
[[package]]
name = "cff-parser"
version = "0.1.0"
@@ -813,6 +842,17 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading 0.8.9",
+]
+
[[package]]
name = "clap"
version = "4.6.0"
@@ -3305,6 +3345,16 @@ version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
[[package]]
name = "libloading"
version = "0.9.0"
@@ -4612,6 +4662,7 @@ dependencies = [
"wa-rs-tokio-transport",
"wa-rs-ureq-http",
"webpki-roots 1.0.6",
+ "whisper-rs",
]
[[package]]
@@ -4740,7 +4791,7 @@ version = "2.0.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c"
dependencies = [
- "libloading",
+ "libloading 0.9.0",
"ndarray",
"ort-sys",
"smallvec",
@@ -8477,6 +8528,28 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+[[package]]
+name = "whisper-rs"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2088172d00f936c348d6a72f488dc2660ab3f507263a195df308a3c2383229f6"
+dependencies = [
+ "whisper-rs-sys",
+]
+
+[[package]]
+name = "whisper-rs-sys"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6986c0fe081241d391f09b9a071fbcbb59720c3563628c3c829057cf69f2a56f"
+dependencies = [
+ "bindgen",
+ "cfg-if",
+ "cmake",
+ "fs_extra",
+ "semver",
+]
+
[[package]]
name = "whoami"
version = "2.1.1"
diff --git a/Cargo.toml b/Cargo.toml
index 49ef87dfe..fd344194c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -86,6 +86,7 @@ sentry = { version = "0.47.0", default-features = false, features = ["backtrace"
tokio-stream = { version = "0.1.18", features = ["full"] }
url = "2"
socketioxide = { version = "0.15", features = ["extensions"] }
+whisper-rs = "0.16"
matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx
index c875a81b1..3cb412820 100644
--- a/app/src/components/settings/panels/LocalModelPanel.tsx
+++ b/app/src/components/settings/panels/LocalModelPanel.tsx
@@ -1129,9 +1129,12 @@ const LocalModelPanel = () => {
{isTranscribeLoading ? 'Running...' : 'Run Transcription Test'}
{transcribeOutput && (
-
+
Model: {transcribeOutput.model_id}
-
{transcribeOutput.text}
+
+
Transcript:
+
{transcribeOutput.text}
+
)}
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index d05aa6c73..db82bf14c 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -35,8 +35,9 @@ import {
openhumanAutocompleteAccept,
openhumanAutocompleteCurrent,
openhumanLocalAiChat,
- openhumanLocalAiTranscribeBytes,
- openhumanLocalAiTts,
+ openhumanVoiceStatus,
+ openhumanVoiceTranscribeBytes,
+ openhumanVoiceTts,
} from '../utils/tauriCommands';
const DEFAULT_THREAD_ID = 'default-thread';
@@ -282,6 +283,33 @@ const Conversations = () => {
}
}, [inputMode, isRecording]);
+ // Proactively check voice binary availability when switching to voice mode
+ useEffect(() => {
+ if (inputMode !== 'voice' || !rustChat) return;
+ let cancelled = false;
+ void (async () => {
+ try {
+ const resp = await openhumanVoiceStatus();
+ if (cancelled) return;
+ const status = resp.result;
+ if (!status.stt_available) {
+ setVoiceStatus(
+ 'Speech-to-text unavailable: whisper-cli binary or STT model not found. Check Settings > Local Models.'
+ );
+ } else {
+ setVoiceStatus('Ready — tap "Start Talking" to record.');
+ }
+ } catch {
+ if (!cancelled) {
+ setVoiceStatus('Could not check voice availability.');
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [inputMode, rustChat]);
+
useEffect(() => {
if (!rustChat || socketStatus !== 'connected') return;
@@ -588,7 +616,15 @@ const Conversations = () => {
const blob = new Blob(chunks, { type: mimeType || 'audio/webm' });
const audioBytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
const extension = getAudioExtension(mimeType || blob.type);
- const result = await openhumanLocalAiTranscribeBytes(audioBytes, extension);
+
+ // Build conversation context from recent messages for LLM cleanup.
+ const recentMessages = messages.slice(-10);
+ const context =
+ recentMessages.length > 0
+ ? recentMessages.map(m => `${m.sender}: ${m.content}`).join('\n')
+ : undefined;
+
+ const result = await openhumanVoiceTranscribeBytes(audioBytes, extension, context);
const transcript = result.result.text.trim();
if (!transcript) {
@@ -685,7 +721,7 @@ const Conversations = () => {
void (async () => {
try {
- const ttsResult = await openhumanLocalAiTts(latestAgentMessage.content);
+ const ttsResult = await openhumanVoiceTts(latestAgentMessage.content);
if (cancelled) return;
const audioSrc = convertFileSrc(ttsResult.result.output_path);
diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts
index b1cee307d..64df1704d 100644
--- a/app/src/utils/tauriCommands.ts
+++ b/app/src/utils/tauriCommands.ts
@@ -2057,3 +2057,75 @@ export async function runtimeSkillDataStats(skillId: string): Promise> {
+ return await callCoreRpc>({
+ method: 'openhuman.voice_status',
+ params: {},
+ });
+}
+
+export async function openhumanVoiceTranscribe(
+ audioPath: string,
+ context?: string,
+ skipCleanup?: boolean
+): Promise> {
+ return await callCoreRpc>({
+ method: 'openhuman.voice_transcribe',
+ params: { audio_path: audioPath, context, skip_cleanup: skipCleanup },
+ });
+}
+
+export async function openhumanVoiceTranscribeBytes(
+ audioBytes: number[],
+ extension?: string,
+ context?: string,
+ skipCleanup?: boolean
+): Promise> {
+ return await callCoreRpc>({
+ method: 'openhuman.voice_transcribe_bytes',
+ params: { audio_bytes: audioBytes, extension, context, skip_cleanup: skipCleanup },
+ });
+}
+
+export async function openhumanVoiceTts(
+ text: string,
+ outputPath?: string
+): Promise> {
+ return await callCoreRpc>({
+ method: 'openhuman.voice_tts',
+ params: { text, output_path: outputPath },
+ });
+}
diff --git a/app/test/e2e/specs/voice-mode.spec.ts b/app/test/e2e/specs/voice-mode.spec.ts
new file mode 100644
index 000000000..82bf8b126
--- /dev/null
+++ b/app/test/e2e/specs/voice-mode.spec.ts
@@ -0,0 +1,206 @@
+// @ts-nocheck
+/**
+ * E2E test: Voice mode integration
+ *
+ * Covers:
+ * - Navigating to conversations page
+ * - Switching to voice input mode
+ * - Voice status check fires and displays availability message
+ * - Voice input/reply mode toggle buttons render
+ * - Voice recording button renders in voice mode
+ * - Switching back to text mode restores text input
+ *
+ * The mock server runs on http://127.0.0.1:18473
+ */
+import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
+import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
+import {
+ clickText,
+ dumpAccessibilityTree,
+ textExists,
+ waitForWebView,
+ waitForWindowVisible,
+} from '../helpers/element-helpers';
+import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server';
+
+async function waitForRequest(method, urlFragment, timeout = 15_000) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ const log = getRequestLog();
+ const match = log.find(r => r.method === method && r.url.includes(urlFragment));
+ if (match) return match;
+ await browser.pause(500);
+ }
+ return undefined;
+}
+
+async function waitForTextToDisappear(text, timeout = 10_000) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ if (!(await textExists(text))) return true;
+ await browser.pause(400);
+ }
+ return false;
+}
+
+async function completeOnboardingIfVisible() {
+ if (await textExists('Skip for now')) {
+ await clickText('Skip for now', 10_000);
+ await waitForTextToDisappear('Skip for now', 8_000);
+ await browser.pause(1500);
+ }
+
+ if (await textExists('Looks Amazing')) {
+ await clickText('Looks Amazing', 10_000);
+ await browser.pause(1500);
+ } else if (await textExists('Bring It On')) {
+ await clickText('Bring It On', 10_000);
+ await browser.pause(1500);
+ }
+
+ if (await textExists('Got it')) {
+ await clickText('Got it', 10_000);
+ await browser.pause(1500);
+ } else if (await textExists('Continue')) {
+ await clickText('Continue', 10_000);
+ await browser.pause(1500);
+ }
+
+ if (await textExists("Let's Go")) {
+ await clickText("Let's Go", 10_000);
+ } else if (await textExists("I'm Ready")) {
+ await clickText("I'm Ready", 10_000);
+ }
+}
+
+async function waitForHome(timeout = 20_000) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ if (await textExists('Message OpenHuman')) return true;
+ await browser.pause(700);
+ }
+ return false;
+}
+
+async function waitForAnyText(candidates, timeout = 20_000) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ for (const t of candidates) {
+ if (await textExists(t)) return t;
+ }
+ await browser.pause(600);
+ }
+ return null;
+}
+
+describe('Voice mode integration', () => {
+ before(async () => {
+ await startMockServer();
+ await waitForApp();
+ clearRequestLog();
+ });
+
+ after(async () => {
+ await stopMockServer();
+ });
+
+ it('can switch to voice input mode, see status message, and switch back to text', async () => {
+ // --- Authenticate and reach conversations ---
+ await triggerAuthDeepLink('e2e-voice-token');
+ await waitForWindowVisible(25_000);
+ await waitForWebView(15_000);
+ await waitForAppReady(15_000);
+
+ const consume = await waitForRequest('POST', '/telegram/login-tokens/');
+ expect(consume).toBeDefined();
+
+ await completeOnboardingIfVisible();
+
+ const onHome = await waitForHome(20_000);
+ if (!onHome) {
+ const tree = await dumpAccessibilityTree();
+ console.log('[VoiceModeE2E] Home not reached. Tree:\n', tree.slice(0, 4000));
+ }
+ expect(onHome).toBe(true);
+
+ // --- Verify we see the text input area (default mode) ---
+ const hasTextInput = await waitForAnyText(['Message OpenHuman', 'Type a message'], 10_000);
+ expect(hasTextInput).not.toBeNull();
+
+ // --- Verify voice toggle buttons are visible ---
+ // The Input toggle group should show "Text" and "Voice" buttons
+ const hasInputLabel = await textExists('Input');
+ expect(hasInputLabel).toBe(true);
+
+ // --- Switch to voice input mode ---
+ // There are two "Voice" buttons (Input toggle and Reply toggle).
+ // We click the first one which is the Input mode toggle.
+ await clickText('Voice', 10_000);
+ await browser.pause(2_000);
+
+ // --- Voice status check should fire ---
+ // Since whisper-cli is not installed in the E2E environment,
+ // we expect the unavailability message or the ready message.
+ const voiceStatusMessage = await waitForAnyText(
+ [
+ 'Speech-to-text unavailable',
+ 'whisper-cli binary',
+ 'STT model not found',
+ 'Ready',
+ 'Start Talking',
+ 'Could not check voice availability',
+ ],
+ 15_000
+ );
+
+ if (!voiceStatusMessage) {
+ const tree = await dumpAccessibilityTree();
+ console.log('[VoiceModeE2E] No voice status message seen. Tree:\n', tree.slice(0, 5000));
+ }
+ expect(voiceStatusMessage).not.toBeNull();
+
+ // --- Verify the voice recording button or unavailability message is visible ---
+ const hasVoiceButton = await waitForAnyText(
+ ['Start Talking', 'Transcribing', 'Stop & Send'],
+ 10_000
+ );
+ if (!hasVoiceButton) {
+ const hasStatus = await textExists('Speech-to-text unavailable');
+ expect(hasStatus).toBe(true);
+ }
+
+ // --- Switch back to text mode ---
+ // Click the "Text" button in the Input toggle group
+ await clickText('Text', 10_000);
+ await browser.pause(1_500);
+
+ // --- Verify text input is restored ---
+ const textRestored = await waitForAnyText(['Message OpenHuman', 'Type a message'], 10_000);
+ expect(textRestored).not.toBeNull();
+ });
+
+ it('shows reply mode toggle with text and voice options', async () => {
+ // Ensure conversations page is loaded (re-authenticate if state was lost).
+ const onConversations = await waitForAnyText(
+ ['Message OpenHuman', 'Type a message', 'Reply'],
+ 5_000
+ );
+ if (!onConversations) {
+ await triggerAuthDeepLink('e2e-voice-token');
+ await waitForWindowVisible(25_000);
+ await waitForWebView(15_000);
+ await waitForAppReady(15_000);
+ await completeOnboardingIfVisible();
+ await waitForHome(20_000);
+ }
+
+ // The Reply toggle should be visible on the conversations page
+ const hasReplyLabel = await textExists('Reply');
+ expect(hasReplyLabel).toBe(true);
+
+ // Verify both reply mode options exist
+ // (There are multiple "Text" and "Voice" buttons — Input + Reply groups)
+ const hasText = await textExists('Text');
+ expect(hasText).toBe(true);
+ });
+});
diff --git a/src/core/all.rs b/src/core/all.rs
index c92dd0e12..89f1e49ff 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -65,6 +65,7 @@ fn build_registered_controllers() -> Vec {
controllers.extend(crate::openhuman::memory::all_memory_registered_controllers());
controllers.extend(crate::openhuman::billing::all_billing_registered_controllers());
controllers.extend(crate::openhuman::team::all_team_registered_controllers());
+ controllers.extend(crate::openhuman::voice::all_voice_registered_controllers());
controllers
}
@@ -95,6 +96,7 @@ fn build_declared_controller_schemas() -> Vec {
schemas.extend(crate::openhuman::memory::all_memory_controller_schemas());
schemas.extend(crate::openhuman::billing::all_billing_controller_schemas());
schemas.extend(crate::openhuman::team::all_team_controller_schemas());
+ schemas.extend(crate::openhuman::voice::all_voice_controller_schemas());
schemas
}
@@ -131,6 +133,7 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"memory" => Some("Document storage, vector search, key-value store, and knowledge graph."),
"billing" => Some("Subscription plan, payment links, and credit top-up via the backend."),
"team" => Some("Team member management, invites, and role changes via the backend."),
+ "voice" => Some("Speech-to-text and text-to-speech using local models."),
_ => None,
}
}
diff --git a/src/openhuman/config/schema/local_ai.rs b/src/openhuman/config/schema/local_ai.rs
index 31492ccca..c3c655c9f 100644
--- a/src/openhuman/config/schema/local_ai.rs
+++ b/src/openhuman/config/schema/local_ai.rs
@@ -54,6 +54,14 @@ pub struct LocalAiConfig {
/// Optional path to a manually-installed Ollama binary.
#[serde(default)]
pub ollama_binary_path: Option,
+ /// When true, load the whisper model in-process via whisper-rs instead of
+ /// shelling out to whisper-cli for each transcription call.
+ #[serde(default = "default_whisper_in_process")]
+ pub whisper_in_process: bool,
+ /// When true and Ollama is available, pass raw transcription through a
+ /// local LLM to fix grammar/punctuation using conversation context.
+ #[serde(default = "default_voice_llm_cleanup_enabled")]
+ pub voice_llm_cleanup_enabled: bool,
}
fn default_enabled() -> bool {
@@ -149,6 +157,14 @@ fn default_max_suggestions() -> usize {
5
}
+fn default_whisper_in_process() -> bool {
+ true
+}
+
+fn default_voice_llm_cleanup_enabled() -> bool {
+ true
+}
+
impl Default for LocalAiConfig {
fn default() -> Self {
Self {
@@ -176,6 +192,8 @@ impl Default for LocalAiConfig {
max_suggestions: default_max_suggestions(),
selected_tier: None,
ollama_binary_path: None,
+ whisper_in_process: default_whisper_in_process(),
+ voice_llm_cleanup_enabled: default_voice_llm_cleanup_enabled(),
}
}
}
diff --git a/src/openhuman/local_ai/mod.rs b/src/openhuman/local_ai/mod.rs
index 350297813..b18b6d729 100644
--- a/src/openhuman/local_ai/mod.rs
+++ b/src/openhuman/local_ai/mod.rs
@@ -7,10 +7,10 @@ pub mod presets;
mod schemas;
mod install;
-mod model_ids;
+pub(crate) mod model_ids;
mod ollama_api;
mod parse;
-mod paths;
+pub(crate) mod paths;
mod service;
mod types;
@@ -23,6 +23,7 @@ pub use schemas::{
all_controller_schemas as all_local_ai_controller_schemas,
all_registered_controllers as all_local_ai_registered_controllers,
};
+pub(crate) use service::whisper_engine;
pub use service::LocalAiService;
pub use types::{
LocalAiAssetStatus, LocalAiAssetsStatus, LocalAiDownloadProgressItem, LocalAiDownloadsProgress,
diff --git a/src/openhuman/local_ai/service/bootstrap.rs b/src/openhuman/local_ai/service/bootstrap.rs
index 22908970d..8ca7a3fa2 100644
--- a/src/openhuman/local_ai/service/bootstrap.rs
+++ b/src/openhuman/local_ai/service/bootstrap.rs
@@ -10,6 +10,7 @@ impl LocalAiService {
let vision_model_id = model_ids::effective_vision_model_id(config);
let embedding_model_id = model_ids::effective_embedding_model_id(config);
Self {
+ whisper: super::whisper_engine::new_handle(),
status: parking_lot::Mutex::new(LocalAiStatus {
state: "idle".to_string(),
model_id: model_id.clone(),
@@ -158,6 +159,35 @@ impl LocalAiService {
return;
}
+ // Attempt to load whisper model in-process if configured (blocking I/O).
+ if config.local_ai.whisper_in_process {
+ if let Ok(model_path) =
+ crate::openhuman::local_ai::paths::resolve_stt_model_path(config)
+ {
+ let model = std::path::PathBuf::from(&model_path);
+ let handle = self.whisper.clone();
+ let load_result = tokio::task::spawn_blocking(move || {
+ super::whisper_engine::load_engine(&handle, &model)
+ })
+ .await;
+ match load_result {
+ Ok(Ok(())) => {
+ log::info!("[local_ai] whisper engine loaded in-process: {model_path}");
+ }
+ Ok(Err(e)) => {
+ log::warn!(
+ "[local_ai] whisper in-process load failed, will fall back to CLI: {e}"
+ );
+ }
+ Err(e) => {
+ log::warn!("[local_ai] whisper load task panicked: {e}");
+ }
+ }
+ } else {
+ log::debug!("[local_ai] STT model not found, whisper in-process not loaded");
+ }
+ }
+
let mut status = self.status.lock();
status.state = "ready".to_string();
status.vision_state = if config.local_ai.preload_vision_model {
diff --git a/src/openhuman/local_ai/service/mod.rs b/src/openhuman/local_ai/service/mod.rs
index 7508618d9..13263e8c2 100644
--- a/src/openhuman/local_ai/service/mod.rs
+++ b/src/openhuman/local_ai/service/mod.rs
@@ -6,6 +6,7 @@ mod ollama_admin;
mod public_infer;
mod speech;
mod vision_embed;
+pub(crate) mod whisper_engine;
use crate::openhuman::local_ai::types::LocalAiStatus;
use parking_lot::Mutex;
@@ -15,4 +16,6 @@ pub struct LocalAiService {
pub(crate) bootstrap_lock: tokio::sync::Mutex<()>,
pub(crate) last_memory_summary_at: Mutex