Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/nix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ permissions:
id-token: write
jobs:
check:
runs-on: ubuntu-slim
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-nix
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/swift.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions:
id-token: write
jobs:
check:
runs-on: ubuntu-slim
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-nix
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Wisp captures your microphone and system audio (the other side of a call) at the
- **On-device transcription** — Uses [`SpeechAnalyzer`](https://developer.apple.com/documentation/speech), the new API in Apple's Speech framework on macOS. Windows preview builds can use `Windows.Media.SpeechRecognition` or prepare a local model from setup.
- **System audio + microphone capture** — Uses macOS 14.4+ [Core Audio Process Taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) to tap meeting-app output without prompts, mixes it with your mic input, and merges both sides into a single transcript. Windows local-model work is structured around WASAPI mic + loopback capture.
- **Built in Rust with a GPU-rendered UI** — The UI is built on [GPUI](https://www.gpui.rs/), the framework that powers the [Zed](https://zed.dev/) editor. Native-feeling responsiveness and smooth scrolling.
- **Simple local storage** — Recordings are stored as WAV and metadata as SQLite under `~/Library/Application Support/dev.mokmok.wisp/`. Easy to export and analyze later.
- **Simple local storage** — Recordings are stored as Ogg/Opus and metadata as SQLite under `~/Library/Application Support/dev.mokmok.wisp/`. Easy to export and analyze later.

## Screenshots

Expand All @@ -43,7 +43,7 @@ Core Audio Process Tap ─┐
Microphone input ───────┘ │ ▲
└─► SpeechAnalyzer ────────────┘
└─► wisp-storage (SQLite + WAV)
└─► wisp-storage (SQLite + Ogg/Opus)
```

## Requirements
Expand Down Expand Up @@ -93,7 +93,7 @@ directory are stored. When unset, Wisp uses
`~/Library/Application Support/dev.mokmok.wisp`.

If a completed transcript cannot be committed to SQLite, Wisp writes an
atomic `transcript-recovery.json` beside that session's WAV files, blocks a
atomic `transcript-recovery.json` beside that session's Ogg files, blocks a
new recording, and retries reconciliation immediately or on the next launch.
Wisp exits before recording if the durable database cannot be opened; it never
treats an in-memory fallback as successful persistence.
Expand Down
6 changes: 3 additions & 3 deletions apps/wisp-desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ pub struct AppModel {
pub current_session_started_at: Option<DateTime<Utc>>,
pub current_session_dir_name: Option<String>,
/// Per-run audio directory. Retained with the transcript after a storage
/// failure so a durable recovery snapshot can be written beside the WAVs.
/// failure so a durable recovery snapshot can be written beside the Ogg files.
pub current_output_dir: Option<PathBuf>,
/// The session being viewed in `View::History`, kept around so the
/// header can render its title without re-querying.
Expand Down Expand Up @@ -647,8 +647,8 @@ mod tests {
started_at,
ended_at: Some(started_at),
title: format!("session {id}"),
mic_wav_path: format!("session-{id}/mic.wav"),
system_wav_path: format!("session-{id}/system.wav"),
mic_wav_path: format!("session-{id}/mic.ogg"),
system_wav_path: format!("session-{id}/system.ogg"),
notes: String::new(),
}
}
Expand Down
10 changes: 5 additions & 5 deletions apps/wisp-desktop/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ pub fn session_dir_name(started_at: DateTime<Utc>) -> String {
}

/// Create a new session row. `dir_name` is the per-session subdirectory
/// passed to the Swift audio kit beneath the `recordings` directory. WAV
/// passed to the Swift audio kit beneath the `recordings` directory. Ogg/Opus
/// paths are stored relative to the storage root, as required by
/// `wisp_core::Session`.
pub fn create_session(
storage: &Storage,
started_at: DateTime<Utc>,
dir_name: &str,
) -> Result<SessionId, StorageError> {
let mic_rel = format!("recordings/{dir_name}/mic.wav");
let sys_rel = format!("recordings/{dir_name}/system.wav");
let mic_rel = format!("recordings/{dir_name}/mic.ogg");
let sys_rel = format!("recordings/{dir_name}/system.ogg");
storage.sessions().create(&NewSession {
started_at,
title: default_title(started_at),
Expand Down Expand Up @@ -169,11 +169,11 @@ mod tests {
let output_dir = storage_root.join("recordings").join(&dir_name);
assert_eq!(
storage_root.join(&session.mic_wav_path),
output_dir.join("mic.wav")
output_dir.join("mic.ogg")
);
assert_eq!(
storage_root.join(&session.system_wav_path),
output_dir.join("system.wav")
output_dir.join("system.ogg")
);
assert_eq!(session.started_at, started_at);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/wisp-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ pub(crate) fn toggle_recording(
if !setup_complete {
return;
}
// Per-session subdirectory so each recording's WAVs stay
// Per-session subdirectory so each recording's Ogg files stay
// grouped and we can show them as a single library row.
let started_at = Utc::now();
let dir_name = library::session_dir_name(started_at);
Expand Down
18 changes: 9 additions & 9 deletions apps/wisp-desktop/src/session_updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,8 @@ fn resolve_session_id(
.get(session_id)
.map_err(|error| format!("could not validate the session row: {error}"))?
{
let expected_mic = format!("recordings/{dir_name}/mic.wav");
let expected_system = format!("recordings/{dir_name}/system.wav");
let expected_mic = format!("recordings/{dir_name}/mic.ogg");
let expected_system = format!("recordings/{dir_name}/system.ogg");
if session.mic_wav_path != expected_mic || session.system_wav_path != expected_system {
return Err("the retained session id belongs to a different recording".into());
}
Expand Down Expand Up @@ -242,7 +242,7 @@ fn delete_unstarted_session(
true
}

/// Write the in-memory transcript beside its WAV files using an atomic
/// Write the in-memory transcript beside its Ogg files using an atomic
/// replace. This is the durable fallback used before quit and whenever the
/// database transaction rolls back.
pub(crate) fn write_recovery_snapshot(model: &AppModel) -> io::Result<PathBuf> {
Expand Down Expand Up @@ -310,7 +310,7 @@ pub(crate) fn write_recovery_snapshot(model: &AppModel) -> io::Result<PathBuf> {
/// new recording can start. Valid snapshots are retried automatically against
/// `SQLite`. If storage is still unavailable, the first pending transcript is
/// restored into `AppModel`'s guarded Failed state so the existing Retry Save
/// action remains available; its sidecar stays beside the WAV files.
/// action remains available; its sidecar stays beside the Ogg files.
pub(crate) fn recover_pending_sessions(
model: &mut AppModel,
storage: &SharedStorage,
Expand Down Expand Up @@ -469,8 +469,8 @@ fn load_recovery_model(
.current_session_dir_name
.as_deref()
.expect("validated directory metadata");
let expected_mic = format!("recordings/{dir_name}/mic.wav");
let expected_system = format!("recordings/{dir_name}/system.wav");
let expected_mic = format!("recordings/{dir_name}/mic.ogg");
let expected_system = format!("recordings/{dir_name}/system.ogg");
if session.mic_wav_path != expected_mic
|| session.system_wav_path != expected_system
{
Expand Down Expand Up @@ -602,8 +602,8 @@ mod tests {
.create(&wisp_core::NewSession {
started_at,
title: library::default_title(started_at),
mic_wav_path: format!("recordings/{dir_name}/mic.wav"),
system_wav_path: format!("recordings/{dir_name}/system.wav"),
mic_wav_path: format!("recordings/{dir_name}/mic.ogg"),
system_wav_path: format!("recordings/{dir_name}/system.ogg"),
})
.expect("preallocate session")
}
Expand Down Expand Up @@ -652,7 +652,7 @@ mod tests {
assert_eq!(session.started_at, started_at);
assert_eq!(
session.mic_wav_path,
format!("recordings/{dir_name}/mic.wav")
format!("recordings/{dir_name}/mic.ogg")
);
}

Expand Down
6 changes: 3 additions & 3 deletions apps/wisp-desktop/src/transcript_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ struct TranscriptEnvelope<'a> {
#[serde(rename = "type")]
event_type: &'static str,
/// CloudEvents `source`: a stable, machine-independent producer id.
/// Deliberately no hostname, absolute paths, or WAV locations, to honour
/// Deliberately no hostname, absolute paths, or audio locations, to honour
/// Wisp's offline / privacy-first promise.
source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -335,8 +335,8 @@ mod tests {
.expect("valid end timestamp"),
),
title: title.to_string(),
mic_wav_path: "mic.wav".to_string(),
system_wav_path: "system.wav".to_string(),
mic_wav_path: "mic.ogg".to_string(),
system_wav_path: "system.ogg".to_string(),
notes: String::new(),
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/wisp-audiokit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ mod imp {
impl Session {
/// Construct a new session. Does no I/O — call [`Self::start`] next.
///
/// `output_dir` is the directory in which the per-session WAV files
/// `output_dir` is the directory in which the per-session Ogg files
/// will be written (created if needed). `locale` is a BCP-47
/// language tag passed to the Swift speech recognizer
/// (e.g. `"ja-JP"`).
Expand Down
6 changes: 5 additions & 1 deletion crates/wisp-core/src/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::{SegmentId, SessionId, SourceLabel};

/// One recording session = one meeting. Tracks lifecycle timestamps, the
/// user-editable title, and the on-disk paths to the captured WAV files.
/// user-editable title, and the on-disk paths to the captured Ogg/Opus files.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
Expand All @@ -14,7 +14,9 @@ pub struct Session {
pub title: String,
/// Path relative to the storage root, never absolute. Lets the user
/// move their library between machines without DB rewrites.
/// Ogg/Opus path. The field name is retained for database compatibility.
pub mic_wav_path: String,
/// Ogg/Opus path. The field name is retained for database compatibility.
pub system_wav_path: String,
pub notes: String,
}
Expand All @@ -25,7 +27,9 @@ pub struct Session {
pub struct NewSession {
pub started_at: DateTime<Utc>,
pub title: String,
/// Ogg/Opus path. The field name is retained for database compatibility.
pub mic_wav_path: String,
/// Ogg/Opus path. The field name is retained for database compatibility.
pub system_wav_path: String,
}

Expand Down
8 changes: 4 additions & 4 deletions crates/wisp-storage/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Persistence layer for Wisp sessions.
//!
//! Owns the `SQLite` schema for sessions, transcript segments, and the
//! filesystem layout that pairs each session with its source WAV files.
//! filesystem layout that pairs each session with its source Ogg/Opus files.
//!
//! Connection model: a single, owned `rusqlite::Connection` lives inside
//! [`Storage`]. `SQLite` serializes writers internally; the desktop app is
Expand All @@ -24,7 +24,7 @@ pub use crate::segments::Segments;
pub use crate::sessions::Sessions;

/// Owns the database connection and the on-disk root that holds the `SQLite`
/// file plus session WAV directories.
/// file plus session Ogg/Opus directories.
pub struct Storage {
conn: Connection,
root: PathBuf,
Expand Down Expand Up @@ -157,8 +157,8 @@ mod tests {
NewSession {
started_at: Utc.with_ymd_and_hms(2026, 7, 15, 10, 0, 0).unwrap(),
title: "transaction test".into(),
mic_wav_path: "transaction-test/mic.wav".into(),
system_wav_path: "transaction-test/system.wav".into(),
mic_wav_path: "transaction-test/mic.ogg".into(),
system_wav_path: "transaction-test/system.ogg".into(),
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/wisp-storage/src/segments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ mod tests {
.create(&NewSession {
started_at: Utc.with_ymd_and_hms(2026, 5, 28, 10, 0, 0).unwrap(),
title: "test".into(),
mic_wav_path: "s/mic.wav".into(),
system_wav_path: "s/system.wav".into(),
mic_wav_path: "s/mic.ogg".into(),
system_wav_path: "s/system.ogg".into(),
})
.expect("create session")
}
Expand Down
4 changes: 2 additions & 2 deletions crates/wisp-storage/src/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ mod tests {
NewSession {
started_at: Utc.with_ymd_and_hms(2026, 5, 28, 10, 30, 0).unwrap(),
title: title.into(),
mic_wav_path: "session-1/mic.wav".into(),
system_wav_path: "session-1/system.wav".into(),
mic_wav_path: "session-1/mic.ogg".into(),
system_wav_path: "session-1/system.ogg".into(),
}
}

Expand Down
2 changes: 1 addition & 1 deletion native/WispAudioKit/Sources/WispAudioKit/Bridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ private func unbox(_ p: OpaquePointer?) -> SessionHandle? {
///
/// On failure returns `nil`; the error is not stored because there is no
/// handle to hold it. Errors are limited to output-directory setup (including
/// refusing to overwrite an existing WAV file) and "input pointer was NULL".
/// refusing to overwrite an existing Ogg file) and "input pointer was NULL".
@_cdecl("wisp_session_new")
public func wisp_session_new(
output_dir: UnsafePointer<CChar>?,
Expand Down
Loading