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
37 changes: 37 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ libc = "0.2"
libloading = "0.8"
ort = { version = "=2.0.0-rc.13", default-features = false, features = ["api-28", "copy-dylibs", "cuda", "download-binaries", "ndarray", "std"] }
parakeet-rs = { version = "=0.3.7", default-features = false, features = ["api-28", "cuda", "ort-defaults"] }
rusqlite = { version = "0.40.2", default-features = false, features = ["bundled"] }

[profile.release]
lto = "thin"
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,15 @@ The Sway wrapper sources this file and passes `LW_POST_PROCESS_MODEL` directly
to OpenAI. Leave it empty to disable remote cleanup. Cleanup uses the Responses
API with reasoning effort fixed at `none`; run `lw --help` for the equivalent
command-line options.

## Transcript history

Local Wisper saves each successful transcription in an on-device SQLite database.
Each row contains the speech-to-text output, the final processed text prepared
for delivery, the configured post-processing model (if any), and the processing
path that produced the final text. Audio is not retained.

The database is stored at `$XDG_DATA_HOME/local-wisper/transcripts.sqlite3`, or
`~/.local/share/local-wisper/transcripts.sqlite3` when `XDG_DATA_HOME` is unset.
Local Wisper restricts the directory to the current user, but the database
contents are not encrypted.
123 changes: 96 additions & 27 deletions baml_src/app.baml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ class AppOptions {
post_process_glossary_file: string?,
}

class TranscriptRecord {
raw_text: string,
final_text: string,
processing: TranscriptProcessing,
post_process_model: string?,
}

function invalid_argument(message: string) -> never {
throw baml.errors.InvalidArgument { message: message }
}
Expand Down Expand Up @@ -152,17 +159,60 @@ function parse_options(args: string[]) -> AppOptions {
options
}

function clean_if_present(transcript: string, options: AppOptions) -> string {
if (transcript.trim() == "") {
function prepare_transcript(raw_text: string, options: AppOptions) -> TranscriptRecord? {
if (raw_text.trim() == "") {
baml.io.eprintln("No speech detected.");
""
null
} else {
process_transcript(
transcript,
let processed = process_transcript(
raw_text,
options.post_process_model,
options.post_process_timeout,
options.post_process_glossary_file,
)
);
TranscriptRecord {
raw_text: raw_text,
final_text: processed.text,
processing: processed.processing,
post_process_model: options.post_process_model,
}
}
}

function store_transcript(
transcript: TranscriptRecord,
native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable,
) -> null {
native_store_transcript(transcript) catch_all (error) {
_ => {
baml.io.eprintln(`Warning: Could not save transcript history: ${error.to_string()}`);
null
},
}
}

function delivery_failure_message(mode: DeliveryMode) -> string {
match (mode) {
DeliveryMode.Copy => "Could not copy transcript to the clipboard.",
DeliveryMode.Type => "Could not type transcript into the focused application.",
}
}

function complete_transcription(
raw_text: string,
options: AppOptions,
mode: DeliveryMode,
native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable,
) -> null {
if let transcript: TranscriptRecord = prepare_transcript(raw_text, options) {
baml.io.println(transcript.final_text);
if (!deliver_text(transcript.final_text, mode)) {
baml.io.eprintln(`Warning: ${delivery_failure_message(mode)}`)
}
//# Persistence is best effort and must not delay delivery.
store_transcript(transcript, native_store_transcript)
} else {
null
}
}

Expand All @@ -183,6 +233,7 @@ function run_workflow(
process: NativeRecorder,
backend: RecorderBackend,
) -> null throws baml.errors.HostCallable,
native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable,
) -> null {
match (options.command) {
AppCommand.Record => {
Expand All @@ -191,16 +242,12 @@ function run_workflow(
ensure_model_daemon(runtime_dir, options.device, native_spawn_daemon);
let audio = record_interactively(runtime_dir, native_spawn_recorder, native_recorder_exists, native_stop_recorder);
defer { cleanup_audio(audio) }
let transcript = clean_if_present(
complete_transcription(
transcribe_with_daemon(runtime_dir, audio.path, options.device, native_spawn_daemon),
options,
DeliveryMode.Copy,
native_store_transcript,
);
if (transcript != "") {
baml.io.println(transcript);
if (!deliver_text(transcript, DeliveryMode.Copy)) {
baml.io.eprintln("Warning: Could not copy transcript to the clipboard.")
}
}
null
},
AppCommand.Preload => {
Expand All @@ -215,23 +262,17 @@ function run_workflow(
let runtime_dir = native_runtime_dir();
let audio = sway_stop_recording(runtime_dir, native_recorder_exists, native_stop_recorder);
defer { cleanup_audio(audio) }
let transcript = clean_if_present(
let mode = if (options.type_output) {
DeliveryMode.Type
} else {
DeliveryMode.Copy
};
complete_transcription(
transcribe_with_daemon(runtime_dir, audio.path, options.device, native_spawn_daemon),
options,
mode,
native_store_transcript,
);
if (transcript != "") {
baml.io.println(transcript);
let mode = if (options.type_output) {
DeliveryMode.Type
} else {
DeliveryMode.Copy
};
if (!deliver_text(transcript, mode)) {
baml.io.eprintln(
"Warning: Could not deliver transcript to the focused application.",
)
}
}
null
},
AppCommand.SwayCancel => {
Expand All @@ -257,6 +298,7 @@ function run_app(
process: NativeRecorder,
backend: RecorderBackend,
) -> null throws baml.errors.HostCallable,
native_store_transcript: (transcript: TranscriptRecord) -> null throws baml.errors.HostCallable,
) -> int {
run_workflow(
parse_options(args),
Expand All @@ -265,6 +307,7 @@ function run_app(
native_spawn_recorder,
native_recorder_exists,
native_stop_recorder,
native_store_transcript,
) catch_all (error) {
_ => {
baml.io.eprintln(error.to_string());
Expand Down Expand Up @@ -303,3 +346,29 @@ test "post-processing model is user configurable" {
let options = parse_options(["--post-process-model", "gpt-5.6-luna-next"]);
assert.equal(options.post_process_model, "gpt-5.6-luna-next")
}

test "history receives the raw and final transcript" {
let saved: string[] = [];
store_transcript(TranscriptRecord { raw_text: "raw words", final_text: "Final words.", processing: TranscriptProcessing.Model, post_process_model: "gpt-5.6-luna" }, (
transcript,
) -> {
saved.push(
`${transcript.raw_text}|${transcript.final_text}|${transcript.processing}|${transcript.post_process_model ?? "none"}`,
);
null
});
assert.equal(saved, ["raw words|Final words.|Model|gpt-5.6-luna"])
}

test "local processing records the raw and final transcript" {
let transcript = prepare_transcript("version zero point one.", parse_options([]));
assert.equal(
transcript,
TranscriptRecord {
raw_text: "version zero point one.",
final_text: "version 0.1",
processing: TranscriptProcessing.Local,
post_process_model: null,
},
)
}
34 changes: 23 additions & 11 deletions baml_src/cleanup.baml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ class ModelCleanAttempt {
timed_out: bool,
}

enum TranscriptProcessing {
Local,
Model,
ModelTimeoutFallback,
ModelErrorFallback,
ModelRejectedFallback,
}

class ProcessedTranscript {
text: string,
processing: TranscriptProcessing,
}

function empty_glossary() -> Glossary {
Glossary { always: [], likely: [], contextual: [], terms: [], legacy: null }
}
Expand Down Expand Up @@ -626,7 +639,7 @@ function process_transcript(
model: string?,
timeout_seconds: float,
glossary_file: string?,
) -> string {
) -> ProcessedTranscript {
let raw_word_count = word_count(text);
let glossary = load_glossary(glossary_file) catch_all (error) {
_ => {
Expand All @@ -636,32 +649,31 @@ function process_transcript(
};
let prepared = apply_guaranteed_corrections(normalize_spoken_numerics(text), glossary.always);
let local = normalize_short_statement_style(prepared);
if (!should_clean_with_model(raw_word_count, model)) {
return local;
}

let model_name = model
?? return local;
let model_name = model_for_cleanup(raw_word_count, model)
?? return ProcessedTranscript { text: local, processing: TranscriptProcessing.Local };
let attempt = clean_with_timeout(prepared, glossary_prompt(glossary), model_name, timeout_seconds);
if (attempt.timed_out) {
baml.io.eprintln(
`Warning: transcript post-processing timed out after ${timeout_seconds}s; using local cleanup.`,
);
return local;
return ProcessedTranscript { text: local, processing: TranscriptProcessing.ModelTimeoutFallback };
}
let result = attempt.result ?? CleanResult { text: null, error: "missing model result" };
let cleaned = result.text ?? "";
if (cleaned.trim() == "") {
baml.io.eprintln(
`Warning: transcript post-processing failed: ${result.error ?? "empty model output"}; using local cleanup.`,
);
return local;
return ProcessedTranscript { text: local, processing: TranscriptProcessing.ModelErrorFallback };
}
if (looks_like_unwanted_non_latin_translation(prepared, cleaned)) {
baml.io.eprintln("Warning: transcript cleanup changed the language; using local cleanup.");
return local;
return ProcessedTranscript { text: local, processing: TranscriptProcessing.ModelRejectedFallback };
}
ProcessedTranscript {
text: normalize_final_transcript(apply_guaranteed_corrections(cleaned, glossary.always)),
processing: TranscriptProcessing.Model,
}
normalize_final_transcript(apply_guaranteed_corrections(cleaned, glossary.always))
}

test "BAML normalizes spoken numbers" {
Expand Down
14 changes: 9 additions & 5 deletions baml_src/main.baml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ class CleanResult {

// Short utterances stay local. They rarely benefit from a network round trip,
// and this matches the established six-word threshold.
function should_clean_with_model(word_count: int, model: string?) -> bool {
model != null && word_count >= 6
function model_for_cleanup(word_count: int, model: string?) -> string? {
if (word_count >= 6) {
model
} else {
null
}
}

function transcript_cleaner(model: string) -> openai.ResponsesClient {
Expand Down Expand Up @@ -63,9 +67,9 @@ function clean_transcript(transcript: string, glossary: string, model: string) -
}

test "model cleanup threshold" {
assert.equal(should_clean_with_model(5, "gpt-5.6-luna"), false);
assert.equal(should_clean_with_model(6, "gpt-5.6-luna"), true);
assert.equal(should_clean_with_model(12, null), false)
assert.equal(model_for_cleanup(5, "gpt-5.6-luna"), null);
assert.equal(model_for_cleanup(6, "gpt-5.6-luna"), "gpt-5.6-luna");
assert.equal(model_for_cleanup(12, null), null)
}

test "transcript cleaner uses the selected model without reasoning" {
Expand Down
Loading