Skip to content

Commit fc6084a

Browse files
feat(audio): save recordings as Ogg Opus (#99)
Store microphone and system recordings as incrementally written Ogg/Opus streams with bounded background encoding and crash-recoverable pages.
1 parent c60660e commit fc6084a

19 files changed

Lines changed: 640 additions & 90 deletions

File tree

.github/workflows/nix.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ permissions:
1313
id-token: write
1414
jobs:
1515
check:
16-
runs-on: ubuntu-slim
16+
runs-on: ubuntu-latest
1717
steps:
1818
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1919
- uses: ./.github/actions/setup-nix

.github/workflows/swift.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ permissions:
1616
id-token: write
1717
jobs:
1818
check:
19-
runs-on: ubuntu-slim
19+
runs-on: ubuntu-latest
2020
steps:
2121
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2222
- uses: ./.github/actions/setup-nix

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Wisp captures your microphone and system audio (the other side of a call) at the
1616
- **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.
1717
- **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.
1818
- **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.
19-
- **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.
19+
- **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.
2020

2121
## Screenshots
2222

@@ -43,7 +43,7 @@ Core Audio Process Tap ─┐
4343
Microphone input ───────┘ │ ▲
4444
└─► SpeechAnalyzer ────────────┘
4545
46-
└─► wisp-storage (SQLite + WAV)
46+
└─► wisp-storage (SQLite + Ogg/Opus)
4747
```
4848

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

9595
If a completed transcript cannot be committed to SQLite, Wisp writes an
96-
atomic `transcript-recovery.json` beside that session's WAV files, blocks a
96+
atomic `transcript-recovery.json` beside that session's Ogg files, blocks a
9797
new recording, and retries reconciliation immediately or on the next launch.
9898
Wisp exits before recording if the durable database cannot be opened; it never
9999
treats an in-memory fallback as successful persistence.

apps/wisp-desktop/src/app.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ pub struct AppModel {
316316
pub current_session_started_at: Option<DateTime<Utc>>,
317317
pub current_session_dir_name: Option<String>,
318318
/// Per-run audio directory. Retained with the transcript after a storage
319-
/// failure so a durable recovery snapshot can be written beside the WAVs.
319+
/// failure so a durable recovery snapshot can be written beside the Ogg files.
320320
pub current_output_dir: Option<PathBuf>,
321321
/// The session being viewed in `View::History`, kept around so the
322322
/// header can render its title without re-querying.
@@ -721,8 +721,8 @@ mod tests {
721721
started_at,
722722
ended_at: Some(started_at),
723723
title: format!("session {id}"),
724-
mic_wav_path: format!("session-{id}/mic.wav"),
725-
system_wav_path: format!("session-{id}/system.wav"),
724+
mic_wav_path: format!("session-{id}/mic.ogg"),
725+
system_wav_path: format!("session-{id}/system.ogg"),
726726
notes: String::new(),
727727
}
728728
}

apps/wisp-desktop/src/library.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,16 @@ pub fn session_dir_name(started_at: DateTime<Utc>) -> String {
4545
}
4646

4747
/// Create a new session row. `dir_name` is the per-session subdirectory
48-
/// passed to the Swift audio kit beneath the `recordings` directory. WAV
48+
/// passed to the Swift audio kit beneath the `recordings` directory. Ogg/Opus
4949
/// paths are stored relative to the storage root, as required by
5050
/// `wisp_core::Session`.
5151
pub fn create_session(
5252
storage: &Storage,
5353
started_at: DateTime<Utc>,
5454
dir_name: &str,
5555
) -> Result<SessionId, StorageError> {
56-
let mic_rel = format!("recordings/{dir_name}/mic.wav");
57-
let sys_rel = format!("recordings/{dir_name}/system.wav");
56+
let mic_rel = format!("recordings/{dir_name}/mic.ogg");
57+
let sys_rel = format!("recordings/{dir_name}/system.ogg");
5858
storage.sessions().create(&NewSession {
5959
started_at,
6060
title: default_title(started_at),
@@ -169,11 +169,11 @@ mod tests {
169169
let output_dir = storage_root.join("recordings").join(&dir_name);
170170
assert_eq!(
171171
storage_root.join(&session.mic_wav_path),
172-
output_dir.join("mic.wav")
172+
output_dir.join("mic.ogg")
173173
);
174174
assert_eq!(
175175
storage_root.join(&session.system_wav_path),
176-
output_dir.join("system.wav")
176+
output_dir.join("system.ogg")
177177
);
178178
assert_eq!(session.started_at, started_at);
179179
}

apps/wisp-desktop/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ pub(crate) fn toggle_recording(
503503
if !setup_complete {
504504
return;
505505
}
506-
// Per-session subdirectory so each recording's WAVs stay
506+
// Per-session subdirectory so each recording's Ogg files stay
507507
// grouped and we can show them as a single library row.
508508
let started_at = Utc::now();
509509
let dir_name = library::session_dir_name(started_at);

apps/wisp-desktop/src/session_updates.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ fn resolve_session_id(
184184
.get(session_id)
185185
.map_err(|error| format!("could not validate the session row: {error}"))?
186186
{
187-
let expected_mic = format!("recordings/{dir_name}/mic.wav");
188-
let expected_system = format!("recordings/{dir_name}/system.wav");
187+
let expected_mic = format!("recordings/{dir_name}/mic.ogg");
188+
let expected_system = format!("recordings/{dir_name}/system.ogg");
189189
if session.mic_wav_path != expected_mic || session.system_wav_path != expected_system {
190190
return Err("the retained session id belongs to a different recording".into());
191191
}
@@ -243,7 +243,7 @@ fn delete_unstarted_session(
243243
true
244244
}
245245

246-
/// Write the in-memory transcript beside its WAV files using an atomic
246+
/// Write the in-memory transcript beside its Ogg files using an atomic
247247
/// replace. This is the durable fallback used before quit and whenever the
248248
/// database transaction rolls back.
249249
pub(crate) fn write_recovery_snapshot(model: &AppModel) -> io::Result<PathBuf> {
@@ -311,7 +311,7 @@ pub(crate) fn write_recovery_snapshot(model: &AppModel) -> io::Result<PathBuf> {
311311
/// new recording can start. Valid snapshots are retried automatically against
312312
/// `SQLite`. If storage is still unavailable, the first pending transcript is
313313
/// restored into `AppModel`'s guarded Failed state so the existing Retry Save
314-
/// action remains available; its sidecar stays beside the WAV files.
314+
/// action remains available; its sidecar stays beside the Ogg files.
315315
pub(crate) fn recover_pending_sessions(
316316
model: &mut AppModel,
317317
storage: &SharedStorage,
@@ -470,8 +470,8 @@ fn load_recovery_model(
470470
.current_session_dir_name
471471
.as_deref()
472472
.expect("validated directory metadata");
473-
let expected_mic = format!("recordings/{dir_name}/mic.wav");
474-
let expected_system = format!("recordings/{dir_name}/system.wav");
473+
let expected_mic = format!("recordings/{dir_name}/mic.ogg");
474+
let expected_system = format!("recordings/{dir_name}/system.ogg");
475475
if session.mic_wav_path != expected_mic
476476
|| session.system_wav_path != expected_system
477477
{
@@ -603,8 +603,8 @@ mod tests {
603603
.create(&wisp_core::NewSession {
604604
started_at,
605605
title: library::default_title(started_at),
606-
mic_wav_path: format!("recordings/{dir_name}/mic.wav"),
607-
system_wav_path: format!("recordings/{dir_name}/system.wav"),
606+
mic_wav_path: format!("recordings/{dir_name}/mic.ogg"),
607+
system_wav_path: format!("recordings/{dir_name}/system.ogg"),
608608
})
609609
.expect("preallocate session")
610610
}
@@ -653,7 +653,7 @@ mod tests {
653653
assert_eq!(session.started_at, started_at);
654654
assert_eq!(
655655
session.mic_wav_path,
656-
format!("recordings/{dir_name}/mic.wav")
656+
format!("recordings/{dir_name}/mic.ogg")
657657
);
658658
}
659659

apps/wisp-desktop/src/transcript_export.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ struct TranscriptEnvelope<'a> {
7777
#[serde(rename = "type")]
7878
event_type: &'static str,
7979
/// CloudEvents `source`: a stable, machine-independent producer id.
80-
/// Deliberately no hostname, absolute paths, or WAV locations, to honour
80+
/// Deliberately no hostname, absolute paths, or audio locations, to honour
8181
/// Wisp's offline / privacy-first promise.
8282
source: &'static str,
8383
#[serde(skip_serializing_if = "Option::is_none")]
@@ -335,8 +335,8 @@ mod tests {
335335
.expect("valid end timestamp"),
336336
),
337337
title: title.to_string(),
338-
mic_wav_path: "mic.wav".to_string(),
339-
system_wav_path: "system.wav".to_string(),
338+
mic_wav_path: "mic.ogg".to_string(),
339+
system_wav_path: "system.ogg".to_string(),
340340
notes: String::new(),
341341
}
342342
}

crates/wisp-audiokit/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ mod imp {
391391
impl Session {
392392
/// Construct a new session. Does no I/O — call [`Self::start`] next.
393393
///
394-
/// `output_dir` is the directory in which the per-session WAV files
394+
/// `output_dir` is the directory in which the per-session Ogg files
395395
/// will be written (created if needed). `locale` is a BCP-47
396396
/// language tag passed to the Swift speech recognizer
397397
/// (e.g. `"ja-JP"`).

crates/wisp-core/src/transcript.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
44
use crate::{SegmentId, SessionId, SourceLabel};
55

66
/// One recording session = one meeting. Tracks lifecycle timestamps, the
7-
/// user-editable title, and the on-disk paths to the captured WAV files.
7+
/// user-editable title, and the on-disk paths to the captured Ogg/Opus files.
88
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99
pub struct Session {
1010
pub id: SessionId,
@@ -14,7 +14,9 @@ pub struct Session {
1414
pub title: String,
1515
/// Path relative to the storage root, never absolute. Lets the user
1616
/// move their library between machines without DB rewrites.
17+
/// Ogg/Opus path. The field name is retained for database compatibility.
1718
pub mic_wav_path: String,
19+
/// Ogg/Opus path. The field name is retained for database compatibility.
1820
pub system_wav_path: String,
1921
pub notes: String,
2022
}
@@ -25,7 +27,9 @@ pub struct Session {
2527
pub struct NewSession {
2628
pub started_at: DateTime<Utc>,
2729
pub title: String,
30+
/// Ogg/Opus path. The field name is retained for database compatibility.
2831
pub mic_wav_path: String,
32+
/// Ogg/Opus path. The field name is retained for database compatibility.
2933
pub system_wav_path: String,
3034
}
3135

0 commit comments

Comments
 (0)