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