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
118 changes: 17 additions & 101 deletions OpenWhisp/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1512,110 +1512,26 @@ class AppState: ObservableObject {
finishSessionUI(delay: 0.8)
}

/// - Parameter isFinalTranscript: true for the whole-utterance final text
/// (not per-chunk, not already-LLM-processed output). Only then do we strip
/// a trailing spoken translate/transcribe instruction, which is never
/// content but would be captured by whisper (esp. translate-to-English).
/// Local transcript cleanup. Delegates to TranscriptCleaner (in OpenWhispCore)
/// — the OS-independent post-processing pipeline — built from current settings.
/// `isFinalTranscript` enables the trailing meta-instruction strip (only on the
/// whole final utterance, never per chunk or on already-LLM-processed output).
private func postProcess(_ text: String, isFinalTranscript: Bool = false) -> String {
var normalized = removeNonSpeechMarkers(from: text)
.replacingOccurrences(of: "\n", with: " ")
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)

normalized = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "\"'` "))

// Drop ignorable transcripts BEFORE smart formatting so we never
// capitalize/punctuate a marker we're about to discard.
guard !isIgnorableTranscript(normalized) else { return "" }

// Apply vocabulary substitutions before formatting, so a corrected term
// (e.g. "claude code" -> "Claude Code") is then handled consistently by
// capitalization/spacing rules.
if customVocabularyEnabled, !vocabulary.substitutions.isEmpty {
normalized = VocabularySubstitutor(substitutions: vocabulary.substitutions).apply(to: normalized)
}

if smartFormattingEnabled {
normalized = smartFormatter.format(normalized, language: language)
}

// Strip a trailing "translate this into English" / "transcribe this" the
// user spoke as an instruction — only on the final whole transcript.
if isFinalTranscript {
normalized = MetaInstructionStripper.strip(normalized)
}

return normalized
TranscriptCleaner(config: transcriptCleanerConfig)
.clean(text, isFinalTranscript: isFinalTranscript)
}

/// Built from the current formatting settings on each call so toggles take
/// effect immediately without rewiring.
private var smartFormatter: SmartFormatter {
SmartFormatter(options: SmartFormatter.Options(
removeFillers: fillerRemovalEnabled,
applySpokenPunctuation: spokenPunctuationEnabled,
capitalizeSentences: true,
ensureTerminalPunctuation: false
))
}

private func isIgnorableTranscript(_ text: String) -> Bool {
let lowercased = text
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()

let ignorableTokens: Set<String> = [
"[blank_audio]",
"[silence]",
"(silence)",
"[no speech]",
"(no speech)",
"[music]",
"(music)",
"[video playback]",
"(video playback)",
"[background noise]",
"(background noise)",
"[noise]",
"(noise)",
"[applause]",
"(applause)",
"[laughter]",
"(laughter)"
]

return lowercased.isEmpty || ignorableTokens.contains(lowercased)
}

private func removeNonSpeechMarkers(from text: String) -> String {
let markerTerms = [
"blank_audio",
"silence",
"no speech",
"music",
"video playback",
"background noise",
"noise",
"static",
"applause",
"laughter",
"laughing",
"cough",
"coughing",
"sigh",
"breath",
"breathing",
"inaudible",
"unintelligible"
]
var cleaned = text
for term in markerTerms {
cleaned = cleaned.replacingOccurrences(of: "[\(term)]", with: "", options: [.caseInsensitive])
cleaned = cleaned.replacingOccurrences(of: "(\(term))", with: "", options: [.caseInsensitive])
}
return cleaned
/// Snapshot of the formatting/vocabulary settings the cleaner needs, built on
/// each call so toggles take effect immediately.
private var transcriptCleanerConfig: TranscriptCleaner.Config {
TranscriptCleaner.Config(
language: language,
customVocabularyEnabled: customVocabularyEnabled,
substitutions: vocabulary.substitutions,
smartFormattingEnabled: smartFormattingEnabled,
fillerRemovalEnabled: fillerRemovalEnabled,
spokenPunctuationEnabled: spokenPunctuationEnabled
)
}

private func startElapsedTimer() {
Expand Down
160 changes: 160 additions & 0 deletions OpenWhisp/Services/TranscriptCleaner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import Foundation

/// The local, on-device transcript post-processing pipeline, assembled as a
/// `PostProcessorChain`. This is the OS-independent core of what used to live as
/// hardcoded steps in `AppState.postProcess(...)`: normalize → drop non-speech
/// markers → (vocabulary substitutions) → (smart formatting) → (meta-instruction
/// strip). Pure and Foundation-only, so it lives in `OpenWhispCore` and is unit-
/// tested directly.
///
/// The async LLM pass (rephrase / improve-translation / voice commands) is NOT
/// part of this chain — it has its own control flow (session guards, status,
/// fallback-on-failure) in AppState. It can be added as a `PostProcessor` stage
/// later; this stage covers exactly the prior synchronous behavior.
struct TranscriptCleaner {
struct Config {
var language: String
var customVocabularyEnabled: Bool
var substitutions: [Vocabulary.Substitution]
var smartFormattingEnabled: Bool
var fillerRemovalEnabled: Bool
var spokenPunctuationEnabled: Bool
}

let config: Config

init(config: Config) {
self.config = config
}

/// Clean a transcript. `isFinalTranscript` enables the trailing
/// meta-instruction strip (only meaningful on the whole final utterance, not
/// per live chunk). Returns "" when the transcript is empty/ignorable.
func clean(_ text: String, isFinalTranscript: Bool) -> String {
// 1) Normalize whitespace and strip whisper's leading space / stray quotes,
// after removing non-speech markers like [music] / (laughter).
var normalized = Self.removeNonSpeechMarkers(from: text)
.replacingOccurrences(of: "\n", with: " ")
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
normalized = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "\"'` "))

// 2) Drop ignorable transcripts BEFORE formatting so we never
// capitalize/punctuate a marker we're about to discard.
guard !Self.isIgnorable(normalized) else { return "" }

// 3) Vocabulary substitutions before formatting, so a corrected term
// (e.g. "claude code" -> "Claude Code") is then cased/spaced consistently.
if let sub = vocabularyStage {
normalized = sub.apply(to: normalized)
}

// 4) Smart formatting (caps / punctuation / fillers / spoken punctuation).
if let fmt = smartFormatterStage {
normalized = fmt.format(normalized, language: config.language)
}

// 5) Strip a trailing "translate this into English" / "transcribe this"
// the user spoke as an instruction — only on the whole final transcript.
if isFinalTranscript {
normalized = MetaInstructionStripper.strip(normalized)
}

return normalized
}

/// The same steps expressed as a composable `PostProcessorChain`, so plugins
/// and future stages (e.g. an LLM stage) can extend the pipeline uniformly.
/// `clean(_:isFinalTranscript:)` is the synchronous fast path used today; this
/// is the extensible form the rest of the roadmap builds on.
func makeChain(isFinalTranscript: Bool) -> PostProcessorChain {
var stages: [PostProcessor] = [NonSpeechMarkerStage(), NormalizeStage(), IgnorableGuardStage()]
if let sub = vocabularyStage { stages.append(sub) }
if let fmt = smartFormatterStage { stages.append(fmt) }
if isFinalTranscript { stages.append(MetaInstructionStage()) }
return PostProcessorChain(stages)
}

// Single source of truth for the optional stages, so clean() and makeChain()
// can never disagree on gating or formatter options.
private var vocabularyStage: VocabularySubstitutor? {
guard config.customVocabularyEnabled, !config.substitutions.isEmpty else { return nil }
return VocabularySubstitutor(substitutions: config.substitutions)
}

private var smartFormatterStage: SmartFormatter? {
guard config.smartFormattingEnabled else { return nil }
return SmartFormatter(options: SmartFormatter.Options(
removeFillers: config.fillerRemovalEnabled,
applySpokenPunctuation: config.spokenPunctuationEnabled,
capitalizeSentences: true,
ensureTerminalPunctuation: false
))
}

// MARK: - Pure helpers (moved out of AppState)

static func removeNonSpeechMarkers(from text: String) -> String {
var cleaned = text
for term in markerTerms {
cleaned = cleaned.replacingOccurrences(of: "[\(term)]", with: "", options: [.caseInsensitive])
cleaned = cleaned.replacingOccurrences(of: "(\(term))", with: "", options: [.caseInsensitive])
}
return cleaned
}

static func isIgnorable(_ text: String) -> Bool {
let lowercased = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return lowercased.isEmpty || ignorableTokens.contains(lowercased)
}

private static let markerTerms = [
"blank_audio", "silence", "no speech", "music", "video playback",
"background noise", "noise", "static", "applause", "laughter", "laughing",
"cough", "coughing", "sigh", "breath", "breathing", "inaudible", "unintelligible"
]

private static let ignorableTokens: Set<String> = [
"[blank_audio]", "[silence]", "(silence)", "[no speech]", "(no speech)",
"[music]", "(music)", "[video playback]", "(video playback)",
"[background noise]", "(background noise)", "[noise]", "(noise)",
"[applause]", "(applause)", "[laughter]", "(laughter)"
]
}

// MARK: - PostProcessor stage adapters

/// Removes non-speech markers ([music], (laughter), …).
struct NonSpeechMarkerStage: PostProcessor {
func process(_ text: String, context: PostProcessContext) async throws -> String {
TranscriptCleaner.removeNonSpeechMarkers(from: text)
}
}

/// Collapses whitespace / newlines and trims stray quotes.
struct NormalizeStage: PostProcessor {
func process(_ text: String, context: PostProcessContext) async throws -> String {
text.replacingOccurrences(of: "\n", with: " ")
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'` "))
}
}

/// Collapses an ignorable transcript to "" so downstream stages no-op.
struct IgnorableGuardStage: PostProcessor {
func process(_ text: String, context: PostProcessContext) async throws -> String {
TranscriptCleaner.isIgnorable(text) ? "" : text
}
}

/// Strips a trailing translate/transcribe meta-instruction.
struct MetaInstructionStage: PostProcessor {
func process(_ text: String, context: PostProcessContext) async throws -> String {
text.isEmpty ? text : MetaInstructionStripper.strip(text)
}
}
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ let package = Package(
"TranscriptionHistory.swift",
"SecureFieldPolicy.swift",
"DownloadProgressFormatter.swift",
"PrivacyStatus.swift"
"PrivacyStatus.swift",
"TranscriptCleaner.swift"
]
),
.testTarget(
Expand Down
90 changes: 90 additions & 0 deletions Tests/OpenWhispCoreTests/TranscriptCleanerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import XCTest
@testable import OpenWhispCore

final class TranscriptCleanerTests: XCTestCase {

private func cleaner(
vocab: [Vocabulary.Substitution] = [],
vocabEnabled: Bool = true,
formatting: Bool = true,
fillers: Bool = true,
spoken: Bool = true,
language: String = "en"
) -> TranscriptCleaner {
TranscriptCleaner(config: .init(
language: language,
customVocabularyEnabled: vocabEnabled,
substitutions: vocab,
smartFormattingEnabled: formatting,
fillerRemovalEnabled: fillers,
spokenPunctuationEnabled: spoken
))
}

// MARK: Normalization + markers + ignorable

func testTrimsLeadingSpaceAndCapitalizes() {
XCTAssertEqual(cleaner().clean(" hello world", isFinalTranscript: false), "Hello world")
}

func testRemovesNonSpeechMarkers() {
XCTAssertEqual(cleaner().clean("hello [music] world", isFinalTranscript: false), "Hello world")
}

func testIgnorableBecomesEmpty() {
XCTAssertEqual(cleaner().clean("[BLANK_AUDIO]", isFinalTranscript: false), "")
XCTAssertEqual(cleaner().clean("(silence)", isFinalTranscript: false), "")
XCTAssertEqual(cleaner().clean(" ", isFinalTranscript: false), "")
}

func testCollapsesWhitespace() {
XCTAssertEqual(cleaner().clean("hello world", isFinalTranscript: false), "Hello world")
}

// MARK: Vocabulary + formatting ordering (sub before formatting)

func testVocabSubstitutionThenCasing() {
let c = cleaner(vocab: [.init(from: "clod code", to: "Claude Code")])
XCTAssertEqual(c.clean("i use clod code daily", isFinalTranscript: false), "I use Claude Code daily")
}

func testVocabDisabledLeavesText() {
let c = cleaner(vocab: [.init(from: "clod code", to: "Claude Code")], vocabEnabled: false)
XCTAssertEqual(c.clean("i use clod code daily", isFinalTranscript: false), "I use clod code daily")
}

func testFormattingOffIsNearPassthrough() {
let c = cleaner(formatting: false)
XCTAssertEqual(c.clean("um hello world", isFinalTranscript: false), "um hello world")
}

// MARK: Meta-instruction strip only on final

func testMetaStripOnlyWhenFinal() {
let input = "Hello, how are you? Please translate this into English."
XCTAssertEqual(cleaner().clean(input, isFinalTranscript: true), "Hello, how are you?")
// Not final → the trailing instruction is NOT stripped (still formatted).
XCTAssertNotEqual(cleaner().clean(input, isFinalTranscript: false), "Hello, how are you?")
}

// MARK: clean() and the PostProcessorChain form agree

func testCleanMatchesChain() async throws {
let cases = [
" hello world",
"hello [music] world",
"um i think comma therefore i am period done",
"i use clod code daily",
"Wrap up the report. translate this to English",
]
let c = cleaner(vocab: [.init(from: "clod code", to: "Claude Code")])
for (isFinal, _) in [(true, ()), (false, ())] {
for text in cases {
let direct = c.clean(text, isFinalTranscript: isFinal)
let chain = try await c.makeChain(isFinalTranscript: isFinal)
.process(text, context: .init(language: "en", targetBundleID: nil, isLiveChunk: !isFinal))
XCTAssertEqual(direct, chain, "chain disagreed with clean() on \"\(text)\" (final=\(isFinal))")
}
}
}
}
Loading