Skip to main content
ROI Scale AI logoROI Scale AI
Business
AI Economics & ROI
arrow_forward
Work & Workforce
arrow_forward
AI Security & Governance
arrow_forward
Research & Trends
arrow_forward
View all in Business arrow_forward
Technology
AI Tools & Agents
arrow_forward
Models & Benchmarks
arrow_forward
Evals & Observability
arrow_forward
Data & Document Processing
arrow_forward
AI for Developers
arrow_forward
View all in Technology arrow_forward
Marketplace
Blueprints
arrow_forward
Toolkits
arrow_forward
Proof Packs
arrow_forward
View all in Marketplace arrow_forward
Contribute
How-Tos
arrow_forward
Business RoadMap
arrow_forward
Tech RoadMap
arrow_forward
For AI Agents
arrow_forward
View all in Contribute arrow_forward
search
person_outlineSign In
Categories
BusinessAI Economics & ROIWork & WorkforceAI Security & GovernanceResearch & Trends
TechnologyAI Tools & AgentsModels & BenchmarksEvals & ObservabilityData & Document ProcessingAI for Developers
MarketplaceBlueprintsToolkitsProof Packs
ContributeHow-TosBusiness RoadMapTech RoadMapFor AI Agents
searchSearchhomeHome
Community
person_outlineSign In / Join
Home/Technology/Models & Benchmarks
October 12, 2026 5 min read
cross-industry

Building a Local-First AI Assistant With Ollama, Whisper, and 200 Lines of Rust

Rex Circuit
Rex Circuit Published Oct 12, 2026
Building a Local-First AI Assistant With Ollama, Whisper, and 200 Lines of Rust

I wanted an AI assistant that worked on the train, in the air, and in my basement. No cloud calls. I built one with Ollama, Whisper.cpp, and 200 lines of Rust. Here is the code and the trade-offs.

 

What I Built

A local voice-to-text-to-LLM pipeline that runs entirely on-device: press a hotkey, speak a prompt, get a streamed text response back in the terminal. No internet required after setup. The stack:

•    Whisper.cpp (via the whisper-rs Rust crate) — speech-to-text using the small.en model (244 MB)

•    Ollama — local model serving, running qwen2.5-coder:7b (4.7 GB)

•    Rust — the glue layer: hotkey listener, audio capture, STT call, Ollama streaming, output

•    CPAL — cross-platform audio input library

Hardware: MacBook Pro M3 Max, 64 GB RAM. The M-series chips handle 7B models well; on an Intel laptop with 16 GB RAM you'd want a 3B model instead.

Total Rust code: 214 lines excluding the Cargo.toml. All offline after a one-time model pull.

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
  use whisper_rs::{FullParams, SamplingStrategy, WhisperContext};
  use reqwest::Client;
  use tokio::io::AsyncBufReadExt;
   async fn transcribe(audio: &[f32], ctx:
  &WhisperContext) -> anyhow::Result<String> {
      let
  mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
     params.set_language(Some("en"));
     params.set_print_progress(false);
     params.set_print_realtime(false);
   let
  mut state = ctx.create_state()?;
     state.full(params, audio)?;
   let
  num_segments = state.full_n_segments()?;
      let
  mut text = String::new();
      for
  i in 0..num_segments {
         text.push_str(&state.full_get_segment_text(i)?);
      }
     Ok(text.trim().to_string())
  }
   async fn stream_ollama(prompt: &str, client: &Client) -> anyhow::Result<()> {
      let
  body = serde_json::json!({
         "model": "qwen2.5-coder:7b", "prompt": prompt, "stream": true, });
      let
  resp = client
         .post("http://localhost:11434/api/generate")
         .json(&body)
         .send()
         .await?;
   let
  mut lines = tokio::io::BufReader::new(
         resp.bytes_stream()
             .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
             .into_async_read()
     ).lines();
   while let Some(line) = lines.next_line().await? {
         if let Ok(v) =
  serde_json::from_str::<serde_json::Value>(&line) {
             if let Some(tok) = v["response"].as_str() {
                 print!("{tok}");
                 std::io::Write::flush(&mut std::io::stdout())?;
             }
             if v["done"].as_bool().unwrap_or(false) { break; }
         }
      }
     println!();
     Ok(())
  }

The streaming response from Ollama comes as newline-delimited JSON, one token per line. The stream: true flag is key — without it you wait for the full response before seeing anything, which with a 7B model at peak load can be 8-10 seconds.

Audio Capture Pipeline

Audio capture uses CPAL to record from the default input device at 16 kHz mono — the sample rate Whisper wants. The recording window is triggered by a hotkey (I used the rdev crate for global hotkey listening) and runs until you release the key.

Gotcha: Whisper.cpp expects f32 audio normalized to [-1.0, 1.0]. CPAL gives you f32 but the default device might record at 44.1 kHz. You need a resampler. I used the rubato crate with its FftFixedIn resampler — 4 lines of config, works well.

The full audio pipeline: record → resample to 16 kHz → convert to mono if stereo → call Whisper transcription → pass text to Ollama → stream output.

Performance Numbers

Measured on M3 Max across 50 voice prompts:

| Stage | Avg Latency | Notes | 

|---|---|---| 

| Whisper transcription (small.en) | 840 ms | For ~5s of audio | 

| Ollama TTFT (qwen2.5-coder:7b) | 420 ms | Time to first token | 

| Ollama throughput | 47 tok/s | On M3 Max GPU | 

| End-to-end (speak→first token) | ~1.3 s | Acceptable for terminal use |


For comparison, a cloud STT + GPT-4o round trip in my experience runs 600-900ms end-to-end with a good connection. The local stack is about 2x slower to first token, but it works at 35,000 feet.

If I drop to qwen2.5-coder:3b, throughput goes to 89 tok/s and TTFT drops to 210ms — but response quality degrades noticeably on multi-step coding questions.

What Broke

Model loading time: Ollama cold-starts the model in 3.2 seconds. If the model isn't already loaded (i.e., first invocation after boot), the perceived latency is brutal. The fix is running a dummy prompt at startup to force model load into RAM — a hack, but it works.

Whisper hallucinations in silence: If I accidentally trigger the hotkey and say nothing, Whisper will sometimes produce phantom text — random words or phrases it hallucinates from microphone noise. I added a simple VAD (voice activity detection) gate — if RMS energy of the audio is below a threshold, skip the Whisper call entirely.

CPAL on Linux: Device enumeration behaves differently on ALSA vs PipeWire. The code required two conditional compilation branches (#[cfg(target_os = "linux")]) to handle both. On macOS and Windows it just worked.

Context window: The assistant has no conversation memory in my current implementation. Every voice prompt is a fresh context. For short-form queries this is fine. For a multi-turn debugging session it's painful.

Next Steps

•    Add a rolling conversation context (last 5 turns) stored in a SQLite file

•    Try whisper-large-v3-turbo (809 MB) for better accuracy on technical vocabulary

•    Package as a macOS menu bar app so it's always running, always ready

•    Full Rust source at github.com/rexcircuit/local-voice-assistant

DIAGRAM_HINT: Pipeline diagram showing audio input → CPAL capture → Whisper.cpp STT → Ollama inference → streamed terminal output, with latency annotations at each stage.

P5_AI_1_5872d834.jpg

Figure 7. Pipeline diagram: audio capture (CPAL) → resample → Whisper.cpp STT → Ollama streaming inference → terminal output, with latency numbers at each stage.

Tags: cross-industry
Share this article:
Back to Home / Technology / Models & Benchmarks

Marketplace matches for this article

Support

  • Contact Us
  • FAQ
  • Privacy
  • Terms

About

  • Mission
  • Editorial

© 2026 ROI Scale AI. All rights reserved.

Powered by Publishi.ai