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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,14 @@ supported recognition errors, and applies the selected Natural, Casual
Message, Formal, Technical, or Verbatim Style. It creates validated paragraph
and list blocks, then preserves or flattens structure for the target. It
also applies exact spoken commands such as **scratch that**, **delete that
sentence**, **new paragraph**, and numbered-list boundaries before formatting;
sentence**, **new paragraph**, **start a bullet list**, **bullet**, **next
item**, and numbered-list boundaries before formatting;
say **literal** immediately before a command phrase to keep the phrase. It
normalizes conservative grocery, shopping, packing, task, explicit-marker,
and sequential-ordinal list cues before formatting. Typed casing policy can
retain the selected Style, lowercase prose while preserving source-signaled
names, or enforce strict lowercase while protecting operational tokens. The
same casing and spoken-list rules run on macOS and iOS. It
validates protected numbers, URLs, email addresses, paths, code-like tokens,
quotations, and dictionary terms. A provider error, invalid output, or
three-second deadline delivers the deterministic Edited transcript once when
Expand Down
4 changes: 2 additions & 2 deletions Sources/HardwareControllerApp/application_preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct PreferredMicrophone: Codable, Equatable, Identifiable, Sendable {

/// Stores versioned application presentation preferences.
struct ApplicationPreferences: Codable, Equatable, Sendable {
static let currentSchemaVersion = 6
static let currentSchemaVersion = 7

var appearance: ApplicationAppearance
var sidebarVisibility: SidebarVisibilityPreference
Expand Down Expand Up @@ -306,7 +306,7 @@ struct ApplicationPreferencesStore:
_ preferences: ApplicationPreferences
) throws -> ApplicationPreferences {
switch preferences.schemaVersion {
case 1, 2, 3, 4, 5:
case 1, 2, 3, 4, 5, 6:
var migrated = preferences
migrated.schemaVersion = ApplicationPreferences.currentSchemaVersion
if preferences.schemaVersion == 1 {
Expand Down
74 changes: 36 additions & 38 deletions Sources/HardwareControllerCore/local_ai_dictation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,42 +45,6 @@ public struct LocalAIModelSelection: Codable, Equatable, Sendable {
}
}

public struct PersonalDictionaryReplacement:
Codable,
Equatable,
Identifiable,
Sendable
{
public let id: UUID
public var spokenForm: String
public var replacement: String

public init(
id: UUID = UUID(),
spokenForm: String,
replacement: String
) {
self.id = id
self.spokenForm = spokenForm
self.replacement = replacement
}
}

public struct PersonalDictionary: Codable, Equatable, Sendable {
public var vocabulary: [String]
public var replacements: [PersonalDictionaryReplacement]

public init(
vocabulary: [String] = [],
replacements: [PersonalDictionaryReplacement] = []
) {
self.vocabulary = vocabulary
self.replacements = replacements
}

public static let empty = PersonalDictionary()
}

public struct LocalAISettings: Codable, Equatable, Sendable {
public static let defaultRecommendedModelName = "qwen3.5:4b"

Expand All @@ -91,6 +55,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
public var dictionary: PersonalDictionary
public var additionalInstructions: String
public var style: VoiceStyle
public var casingPolicy: VoiceCasingPolicy

public init(
provider: LocalAIProviderKind = .appleOnDevice,
Expand All @@ -101,7 +66,8 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
includeNearbyText: Bool = false,
dictionary: PersonalDictionary = .empty,
additionalInstructions: String = "",
style: VoiceStyle = .natural
style: VoiceStyle = .natural,
casingPolicy: VoiceCasingPolicy = .styleDefault
) {
self.provider = provider
self.ollamaModel = ollamaModel
Expand All @@ -110,6 +76,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
self.dictionary = dictionary
self.additionalInstructions = additionalInstructions
self.style = style
self.casingPolicy = casingPolicy
}

private enum CodingKeys: String, CodingKey {
Expand All @@ -120,6 +87,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
case dictionary
case additionalInstructions
case style
case casingPolicy
}

public init(from decoder: any Decoder) throws {
Expand All @@ -140,6 +108,11 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
forKey: .additionalInstructions
)
style = try container.decodeIfPresent(VoiceStyle.self, forKey: .style) ?? .natural
casingPolicy =
try container.decodeIfPresent(
VoiceCasingPolicy.self,
forKey: .casingPolicy
) ?? .styleDefault
}

public func encode(to encoder: any Encoder) throws {
Expand All @@ -151,6 +124,7 @@ public struct LocalAISettings: Codable, Equatable, Sendable {
try container.encode(dictionary, forKey: .dictionary)
try container.encode(additionalInstructions, forKey: .additionalInstructions)
try container.encode(style, forKey: .style)
try container.encode(casingPolicy, forKey: .casingPolicy)
}

public static let `default` = LocalAISettings()
Expand All @@ -170,6 +144,23 @@ public enum LocalAISettingsValidationError: Error, Equatable, Sendable {
}

extension LocalAISettings {
public var effectiveCasingPolicy: VoiceCasingPolicy {
guard casingPolicy == .styleDefault else {
return casingPolicy
}
let instruction =
additionalInstructions
.split(whereSeparator: { $0.isWhitespace })
.joined(separator: " ")
.lowercased()
let requestsOnlyLowercase =
instruction.contains("only provide text in lowercase")
|| instruction.contains("only use lowercase")
|| instruction.contains("all lowercase")
|| instruction.contains("lowercase only")
return requestsOnlyLowercase ? .strictLowercase : .styleDefault
}

public func validate() throws {
guard style.revision == VoiceStyle.currentRevision else {
throw LocalAISettingsValidationError.unsupportedStyleRevision(
Expand Down Expand Up @@ -257,21 +248,28 @@ public struct LocalAIRefinementRequest: Equatable, Sendable {
public let dictionary: PersonalDictionary
public let additionalInstructions: String
public let style: VoiceStyle
public let casingPolicy: VoiceCasingPolicy
public let listIntent: VoiceListIntent

public init(
sessionID: UUID,
transcript: String,
context: LocalAITargetContext,
dictionary: PersonalDictionary,
additionalInstructions: String,
style: VoiceStyle = .natural
style: VoiceStyle = .natural,
casingPolicy: VoiceCasingPolicy = .styleDefault,
listIntent: VoiceListIntent? = nil
) {
self.sessionID = sessionID
self.transcript = transcript
self.context = context
self.dictionary = dictionary
self.additionalInstructions = additionalInstructions
self.style = style
self.casingPolicy = casingPolicy
self.listIntent =
listIntent ?? VoiceListIntentDetector().detect(in: transcript)
}
}

Expand Down
37 changes: 37 additions & 0 deletions Sources/HardwareControllerCore/personal_dictionary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

public struct PersonalDictionaryReplacement:
Codable,
Equatable,
Identifiable,
Sendable
{
public let id: UUID
public var spokenForm: String
public var replacement: String

public init(
id: UUID = UUID(),
spokenForm: String,
replacement: String
) {
self.id = id
self.spokenForm = spokenForm
self.replacement = replacement
}
}

public struct PersonalDictionary: Codable, Equatable, Sendable {
public var vocabulary: [String]
public var replacements: [PersonalDictionaryReplacement]

public init(
vocabulary: [String] = [],
replacements: [PersonalDictionaryReplacement] = []
) {
self.vocabulary = vocabulary
self.replacements = replacements
}

public static let empty = PersonalDictionary()
}
153 changes: 153 additions & 0 deletions Sources/HardwareControllerCore/voice_casing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import Foundation

public enum VoiceCasingPolicy:
String,
CaseIterable,
Codable,
Equatable,
Sendable
{
case styleDefault
case lowercaseProse
case strictLowercase
}

public struct VoiceCasingTransformer: Sendable {
public init() {}

public func apply(
_ policy: VoiceCasingPolicy,
to text: String,
preserving source: String,
dictionary: PersonalDictionary
) -> String {
guard policy != .styleDefault else {
return text
}
let protectedTokens = intentionalTokens(
in: source,
dictionary: dictionary,
preserveProseCasing: policy == .lowercaseProse
)
let ranges = nonoverlappingRanges(
of: protectedTokens,
in: text
)
var result = ""
var cursor = text.startIndex
for range in ranges {
result += text[cursor..<range.lowerBound].lowercased()
result += text[range]
cursor = range.upperBound
}
result += text[cursor...].lowercased()
return result
}

private func intentionalTokens(
in source: String,
dictionary: PersonalDictionary,
preserveProseCasing: Bool
) -> [String] {
let patterns = [
#"https?://[^\s]+"#,
#"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"#,
#"(?:/[^\s/]+){2,}"#,
#"\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b"#,
#"\b[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*\b"#,
#"\b[A-Z][a-z]+(?:[A-Z][A-Za-z0-9]*)+\b"#,
#"[\"“][^\"”]+[\"”]"#,
]
var tokens = Set(
patterns.flatMap { matches(pattern: $0, in: source) }
)
tokens.formUnion(dictionary.vocabulary)
tokens.formUnion(dictionary.replacements.map(\.replacement))
if preserveProseCasing {
tokens.formUnion(matches(pattern: #"\b[A-Z]{2,}\b"#, in: source))
tokens.formUnion(sourceSignaledNames(in: source))
}
return tokens.filter { !$0.isEmpty }.sorted {
if $0.count != $1.count {
return $0.count > $1.count
}
return $0 < $1
}
}

private func sourceSignaledNames(in source: String) -> [String] {
guard
let expression = try? NSRegularExpression(
pattern: #"\b[A-Z][a-z]+\b"#
)
else {
return []
}
let range = NSRange(source.startIndex..., in: source)
return expression.matches(in: source, range: range).compactMap { match in
guard let wordRange = Range(match.range, in: source) else {
return nil
}
let prefix = source[..<wordRange.lowerBound]
guard let preceding = prefix.last(where: { !$0.isWhitespace }) else {
return nil
}
guard !".!?".contains(preceding) else {
return nil
}
return String(source[wordRange])
}
}

private func matches(
pattern: String,
in source: String
) -> [String] {
guard let expression = try? NSRegularExpression(pattern: pattern) else {
return []
}
let range = NSRange(source.startIndex..., in: source)
return expression.matches(in: source, range: range).compactMap { match in
Range(match.range, in: source).map { String(source[$0]) }
}
}

private func nonoverlappingRanges(
of tokens: [String],
in text: String
) -> [Range<String.Index>] {
let candidates = tokens.flatMap { token in
ranges(of: token, in: text)
}.sorted {
if $0.lowerBound != $1.lowerBound {
return $0.lowerBound < $1.lowerBound
}
return text.distance(from: $0.lowerBound, to: $0.upperBound)
> text.distance(from: $1.lowerBound, to: $1.upperBound)
}
var selected: [Range<String.Index>] = []
for candidate in candidates
where selected.last?.upperBound ?? text.startIndex <= candidate.lowerBound {
selected.append(candidate)
}
return selected
}

private func ranges(
of token: String,
in text: String
) -> [Range<String.Index>] {
var ranges: [Range<String.Index>] = []
var searchStart = text.startIndex
while searchStart < text.endIndex,
let range = text.range(
of: token,
range: searchStart..<text.endIndex
)
{
ranges.append(range)
searchStart = range.upperBound
}
return ranges
}
}
Loading