Files
wisper-key/Sources/WhisperKey/TextPipeline.swift
T

301 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import ApplicationServices
enum TextCleaner {
static func clean(_ input: String, removeFillers: Bool, glossary: String) -> String {
var text = input
.replacingOccurrences(of: "[BLANK_AUDIO]", with: "")
.replacingOccurrences(of: "(тишина)", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if isSilenceHallucination(text) { return "" }
text = stripTrailingSilenceHallucination(from: text)
text = stripEmbeddedSubtitleCredits(from: text)
if removeFillers {
let fillers = [
#"(?i)(?<![\p{L}])(?:э+|эм+|мм+)(?![\p{L}])[, ]*"#,
#"(?i)(?<![\p{L}])(?:ну\s+вот|как\s+бы|короче)(?![\p{L}])[, ]*"#,
]
for pattern in fillers {
text = text.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
}
}
text = applyGlossary(to: text, glossary: glossary)
return normalize(text)
}
private static func isSilenceHallucination(_ input: String) -> Bool {
let value = input
.lowercased()
.replacingOccurrences(of: #"[\s.…!?,;:\-–—]+"#, with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let known = [
"продолжение следует",
"спасибо за просмотр",
"спасибо за внимание",
"подписывайтесь на канал",
"субтитры сделал",
"редактор субтитров",
]
guard !value.isEmpty else { return false }
let words = value.split(separator: " ")
return (value.hasPrefix("субтитры ") && words.count <= 12) ||
known.contains(where: { phrase in
value == phrase ||
(value.hasPrefix(phrase + " ") && words.count <= phrase.split(separator: " ").count * 3)
})
}
private static func applyGlossary(to input: String, glossary: String) -> String {
var text = input
let aliases: [(String, String)] = [
(#"(?i)\b(?:compu\s*segro|campus\s*agro|кампус\s*агро|комп\s*агро)\b"#, "CampusAgro"),
(#"(?i)\b(?:pocket\s*base|покет\s*бейс|покетбейс)\b"#, "PocketBase"),
(#"(?i)\b(?:постричь|постгре|постгрес)\s*\.?\s*(?:sql|эс\s*кью\s*эль)\b"#, "PostgreSQL"),
(#"(?i)\b(?:кэдди|кэди)\b"#, "Caddy"),
(#"(?i)\bsvelte\s*kit\b"#, "SvelteKit"),
(#"(?i)\btime\s*web\b"#, "Timeweb"),
(#"(?i)\bopen\s*ai\b"#, "OpenAI"),
(#"(?i)\bэлектро\s+энцефалография\b"#, "электроэнцефалография"),
(#"(?i)\bдоступ\s+примечател(?:ь|ьность)?\b"#, "достопримечательность"),
(#"(?i)\bэкзист(?:\s+компотенциальный)?\b"#, "экзистенциальный"),
]
for (pattern, replacement) in aliases {
text = text.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression)
}
let terms = glossary.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
for term in terms {
text = text.replacingOccurrences(
of: #"(?i)(?<![\p{L}\p{N}])\#(NSRegularExpression.escapedPattern(for: term))(?![\p{L}\p{N}])"#,
with: term,
options: .regularExpression
)
}
return text
}
private static func stripTrailingSilenceHallucination(from input: String) -> String {
input.replacingOccurrences(
of: #"(?i)(?:[.!?]\s*)?(?:спасибо(?:\s+за\s+(?:просмотр|внимание))?|продолжение\s+следует|подпишись(?:\s+на\s+канал)?|субтитры\s+(?:создавал[аи]?|сделал[аи]?|подготовил[аи]?|редактировал[аи]?|редактор)\s+[\p{L}\p{N}_-]+)[.!?…\s]*$"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func stripEmbeddedSubtitleCredits(from input: String) -> String {
input.replacingOccurrences(
of: #"(?i)(?:редактор\s+субтитров|корректор)\s+[A-ZА-ЯЁ]\.?\s*[\p{L}-]+[.!?…,\s]*"#,
with: "",
options: .regularExpression
)
}
private static func normalize(_ value: String) -> String {
let cleaned = value
.replacingOccurrences(of: #"[ \t]{2,}"#, with: " ", options: .regularExpression)
.replacingOccurrences(of: #" +([,.!?;:])"#, with: "$1", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let first = cleaned.first else { return cleaned }
return first.uppercased() + cleaned.dropFirst()
}
}
enum TranscriptJoiner {
static func prepare(
_ input: String,
continuesPrevious: Bool,
continuesNext: Bool,
glossary: String
) -> String {
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
if continuesPrevious {
text = lowercaseLeadingWord(in: text, glossary: glossary)
}
if continuesNext {
text = text.replacingOccurrences(
of: #"[.!?…]+$"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespacesAndNewlines)
}
return text
}
private static func lowercaseLeadingWord(in input: String, glossary: String) -> String {
guard let range = input.range(
of: #"^[\p{L}\p{N}_+#.-]+"#,
options: .regularExpression
) else { return input }
let word = String(input[range])
let protectedTerms = glossary
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
if protectedTerms.contains(word.lowercased()) { return input }
let letters = word.filter(\.isLetter)
guard let first = letters.first,
first.isUppercase,
letters.dropFirst().contains(where: \.isLowercase) else {
return input
}
return word.prefix(1).lowercased() + input.dropFirst(word.prefix(1).count)
}
}
struct StableTranscriptUpdate: Equatable {
let text: String
let continuesPrevious: Bool
}
struct StableTranscript {
private struct Token: Equatable {
let surface: String
let key: String
}
private var committed: [Token] = []
private var previousPending: [Token] = []
mutating func ingest(_ hypothesis: String, final: Bool = false) -> StableTranscriptUpdate? {
let current = tokenize(hypothesis)
if current.isEmpty {
guard final, !previousPending.isEmpty else { return nil }
let pending = previousPending
previousPending.removeAll()
return commit(pending)
}
let anchored = uncommittedTokens(in: current)
if !committed.isEmpty, !anchored.foundAnchor {
// A rolling window without the recent committed anchor is
// ambiguous. Keeping the previous tail is safer than duplicating
// text that is already in the target field.
if final, !previousPending.isEmpty {
let pending = previousPending
previousPending.removeAll()
return commit(pending)
}
return nil
}
let pending = anchored.tokens
if final {
guard !pending.isEmpty else { return nil }
previousPending.removeAll()
return commit(pending)
}
guard !previousPending.isEmpty else {
previousPending = pending
return nil
}
let stableCount = commonPrefixCount(previousPending, pending)
guard stableCount > 0 else {
previousPending = pending
return nil
}
let stable = Array(pending.prefix(stableCount))
previousPending = Array(pending.dropFirst(stableCount))
return commit(stable)
}
private mutating func commit(_ tokens: [Token]) -> StableTranscriptUpdate? {
guard !tokens.isEmpty else { return nil }
let continuesPrevious = committed.last.map {
$0.surface.range(
of: #"[.!?…][”"'»)]*$"#,
options: .regularExpression
) == nil
} ?? false
committed.append(contentsOf: tokens)
return StableTranscriptUpdate(
text: tokens.map(\.surface).joined(separator: " "),
continuesPrevious: continuesPrevious
)
}
private func uncommittedTokens(in current: [Token]) -> (tokens: [Token], foundAnchor: Bool) {
guard !committed.isEmpty else { return (current, true) }
let maximumAnchor = min(8, committed.count, current.count)
if maximumAnchor >= 2 {
for length in stride(from: maximumAnchor, through: 2, by: -1) {
let anchor = committed.suffix(length).map(\.key)
for start in 0...(current.count - length) {
if current[start..<(start + length)].map(\.key) == anchor {
return (Array(current.dropFirst(start + length)), true)
}
}
}
}
let lastKey = committed.last?.key
for start in 0..<min(3, current.count) where current[start].key == lastKey {
return (Array(current.dropFirst(start + 1)), true)
}
return (current, false)
}
private func commonPrefixCount(_ lhs: [Token], _ rhs: [Token]) -> Int {
var count = 0
while count < lhs.count, count < rhs.count, lhs[count].key == rhs[count].key {
count += 1
}
return count
}
private func tokenize(_ text: String) -> [Token] {
text.split(whereSeparator: \.isWhitespace).compactMap { value in
let surface = String(value)
let key = surface
.lowercased()
.replacingOccurrences(
of: #"^[^\p{L}\p{N}+#]+|[^\p{L}\p{N}+#]+$"#,
with: "",
options: .regularExpression
)
guard !key.isEmpty else { return nil }
return Token(surface: surface, key: key)
}
}
}
enum TextInserter {
static func insert(_ text: String) throws {
guard AXIsProcessTrusted() else {
throw WhisperKeyError.processFailed("Разрешите WhisperKey управлять компьютером: Системные настройки → Конфиденциальность и безопасность → Универсальный доступ.")
}
let pasteboard = NSPasteboard.general
let oldItems = pasteboard.pasteboardItems?.compactMap { item -> [NSPasteboard.PasteboardType: Data]? in
var values: [NSPasteboard.PasteboardType: Data] = [:]
for type in item.types {
if let data = item.data(forType: type) { values[type] = data }
}
return values
}
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
let source = CGEventSource(stateID: .hidSystemState)
let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true)
let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false)
down?.flags = .maskCommand
up?.flags = .maskCommand
down?.post(tap: .cghidEventTap)
up?.post(tap: .cghidEventTap)
if let oldItems {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
let restored = oldItems.map { values -> NSPasteboardItem in
let item = NSPasteboardItem()
for (type, data) in values { item.setData(data, forType: type) }
return item
}
pasteboard.clearContents()
pasteboard.writeObjects(restored)
}
}
}
}