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

181 lines
8.2 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)
}
}
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)
}
}
}
}