diff --git a/BRANDING.md b/BRANDING.md index 07df114..c41dda3 100644 --- a/BRANDING.md +++ b/BRANDING.md @@ -4,7 +4,7 @@ The Apache License 2.0 covers the repository's copyrightable source, documentation, and assets. It does not grant trademark rights beyond the uses described in Section 6. -Hardware Controller, the Longdevity product namespace, and the Signal Bridge +Hardware Controller, the Longdevity product namespace, and the Center Signal visual identity identify the canonical project maintained by Marcus John Rice Lee. You may accurately describe a derivative as based on Hardware Controller. Do not present a modified build as an official build or imply endorsement by diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d26e089..a736846 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,8 @@ without a timeout, over budget, or on macOS without `needs: changes` or push | `Sources/hardware_controller_voice_ffi/`, `Sources/voice_ffi_bridge/` | Typed Swift over the Rust C ABI. | | `Sources/voice_asr_bridge/`, `Sources/voice_asr_runtime/` | C bridge over transcribe.cpp and its typed Swift owner. | | `Sources/hardware_controller_asr_*` | macOS ASR XPC service, its kit, and the app-to-service protocol. | +| `Sources/hardware_controller_model_download/` | Pinned speech-model catalog, HTTPS fetcher, and verifying assembler for both apps. | +| `tools/app_icons/` | Code that renders both app icons. | | `crates/` | Portable Rust: `voice_core`, `voice_models`, `voice_archive`, `voice_ffi`. | | `apps/ios/voice_input/` | iOS app, keyboard, widgets, and their tests. | | `schemas/` | Versioned Model-package and archive contracts. | diff --git a/Package.swift b/Package.swift index d5fdf63..8e62679 100644 --- a/Package.swift +++ b/Package.swift @@ -103,12 +103,17 @@ let package = Package( dependencies: ["HardwareControllerASRServiceKit"], path: "Sources/hardware_controller_asr_service" ), + .target( + name: "HardwareControllerModelDownload", + path: "Sources/hardware_controller_model_download" + ), .target( name: "HardwareControllerMac", dependencies: [ "HardwareControllerASRProtocol", "HardwareControllerAudioBoundary", "HardwareControllerCore", + "HardwareControllerModelDownload", "HardwareControllerVoiceFFI", ], linkerSettings: [ @@ -127,6 +132,7 @@ let package = Package( dependencies: [ "HardwareControllerCore", "HardwareControllerMac", + "HardwareControllerModelDownload", ] ), .testTarget( @@ -149,6 +155,7 @@ let package = Package( dependencies: [ "HardwareControllerCore", "HardwareControllerMac", + "HardwareControllerModelDownload", "HardwareControllerVoiceFFI", ] ), @@ -177,8 +184,14 @@ let package = Package( dependencies: [ "HardwareControllerApp", "HardwareControllerCore", + "HardwareControllerModelDownload", ] ), + .testTarget( + name: "HardwareControllerModelDownloadTests", + dependencies: ["HardwareControllerModelDownload"], + path: "Tests/hardware_controller_model_download_tests" + ), ], swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md index 8087f3b..41efd53 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ are listed in [CONTRIBUTING.md](CONTRIBUTING.md#opt-in-system-checks). Calm, tactile, precise: a studio utility, not a settings form. Neutral surfaces, system typography, no decorative borders, and one strong action per workflow. Color appears only for active state, required attention, or errors. -The icon, Signal Bridge, is three generic nodes joined by one amber signal -path ([source](packaging/app_icon_source.png)); iOS Voice Input crops the same -art full-bleed and paints amber only while recording. Details: +The icons, Center Signal, are rounded capsules on graphite with only the +center lit amber: three pedals for Hardware Controller, a voice waveform for +Voice Input (`swift tools/app_icons/render_app_icons.swift`). Details: [UX specification](docs/ux_spec.md). ## Documentation diff --git a/Sources/HardwareControllerApp/speech_engine_model.swift b/Sources/HardwareControllerApp/speech_engine_model.swift index 6a18dd4..147ff97 100644 --- a/Sources/HardwareControllerApp/speech_engine_model.swift +++ b/Sources/HardwareControllerApp/speech_engine_model.swift @@ -1,6 +1,7 @@ import Foundation import HardwareControllerCore import HardwareControllerMac +import HardwareControllerModelDownload import Observation /// Owns the speech engine choice, its Model package, and the download. diff --git a/Sources/HardwareControllerMac/asr_model_downloader.swift b/Sources/HardwareControllerMac/asr_model_downloader.swift index 27cd2da..300bf85 100644 --- a/Sources/HardwareControllerMac/asr_model_downloader.swift +++ b/Sources/HardwareControllerMac/asr_model_downloader.swift @@ -1,208 +1,8 @@ -import CryptoKit import Foundation +import HardwareControllerModelDownload -/// One pinned file the downloader fetches and verifies. -public struct ASRModelCatalogFile: Equatable, Sendable { - public let name: String - public let url: URL - public let sha256: String - public let bytes: Int64 - /// The manifest role, or nil for license text folded into the notice. - public let role: String? -} - -/// The pinned Model package the app offers to download (decision 0060). -public struct ASRModelCatalogEntry: Equatable, Sendable { - public let packageID: String - public let version: String - public let displayName: String - public let languages: [String] - public let spdxExpression: String - public let sourceURL: URL - public let minimumMemoryBytes: Int64 - public let recommendedMemoryBytes: Int64 - public let files: [ASRModelCatalogFile] - - public var downloadBytes: Int64 { - files.reduce(0) { $0 + $1.bytes } - } -} - -public enum ASRModelCatalog { - // Revisions are immutable Hugging Face commits; digests are verified before - // any file reaches the Rust validator. Updating a pin is a reviewed change. - private static let parakeetRevision = URL( - string: - "https://huggingface.co/handy-computer/parakeet-tdt-0.6b-v3-gguf/resolve/d8d8c32cb2017c57e5307767343e97870f8a7f53" - ) - private static let speechRevision = URL( - string: - "https://huggingface.co/handy-computer/silero-vad-v6.2-gguf/resolve/a9b2d61c20ae4ddf3644edb34969e8185a485448" - ) - - /// Parakeet TDT 0.6B v3 Q8_0 with Silero speech detection. - public static let parakeetV3: ASRModelCatalogEntry? = { - guard let parakeetRevision, let speechRevision else { - return nil - } - return ASRModelCatalogEntry( - packageID: "com.longdevity.asr.parakeet_tdt_v3", - version: "d8d8c32-q8_0", - displayName: "Parakeet TDT 0.6B v3", - // The 25 European languages Parakeet v3 was trained on. - languages: [ - "bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr", "hr", "hu", "it", - "lt", "lv", "mt", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sv", "uk", - ], - spdxExpression: "CC-BY-4.0 AND MIT", - sourceURL: parakeetRevision.deletingLastPathComponent().deletingLastPathComponent(), - minimumMemoryBytes: 1_073_741_824, - recommendedMemoryBytes: 2_147_483_648, - files: [ - ASRModelCatalogFile( - name: "parakeet-tdt-0.6b-v3-Q8_0.gguf", - url: parakeetRevision.appending(path: "parakeet-tdt-0.6b-v3-Q8_0.gguf"), - sha256: "5859f77944efcd8eafa23a6350731960b2b55b2203df51f319665c807d802cc7", - bytes: 739_508_576, - role: "model" - ), - ASRModelCatalogFile( - name: "silero-vad-v6.2-F32.gguf", - url: speechRevision.appending(path: "silero-vad-v6.2-F32.gguf"), - sha256: "0d871a800c420fdd5d1b17a3aea4296d1c39608f0ff4a9295eb76457eb1c88e6", - bytes: 1_241_056, - role: "speech_detection" - ), - ASRModelCatalogFile( - name: "parakeet-LICENSE.txt", - url: parakeetRevision.appending(path: "LICENSE"), - sha256: "9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411", - bytes: 18_657, - role: nil - ), - ASRModelCatalogFile( - name: "parakeet-NOTICE.txt", - url: parakeetRevision.appending(path: "NOTICE"), - sha256: "6f02c515f3deed71bb0e8909c8d8449faeb365ae507f67cd026c677b1bddcec8", - bytes: 400, - role: nil - ), - ASRModelCatalogFile( - name: "silero-LICENSE.txt", - url: speechRevision.appending(path: "LICENSE"), - sha256: "2e63e9a38b6e8fc0c7bc37ce174caca1862870856c6daf5697cfb785e925520b", - bytes: 1_075, - role: nil - ), - ASRModelCatalogFile( - name: "silero-NOTICE.txt", - url: speechRevision.appending(path: "NOTICE"), - sha256: "f1ea43096a23b40d4b14fcf131586710035444e69e35051c5d83c60725b50555", - bytes: 293, - role: nil - ), - ] - ) - }() -} - -public enum ASRModelDownloadError: Error, Equatable, Sendable { - case catalogUnavailable - case transferFailed(String) - /// A file's size or digest differed from its pin; nothing was installed. - case integrityMismatch(String) - case installFailed(String) - case cancelled -} - -/// Fetches one URL to a local file; the only network code on macOS besides Ollama. -public protocol ASRModelFileFetching: Sendable { - func fetch( - _ url: URL, - to destination: URL, - progress: @escaping @Sendable (Int64) -> Void - ) async throws(ASRModelDownloadError) -} - -/// Downloads over HTTPS with no cookies, cache, or credentials. -public struct URLSessionModelFileFetcher: ASRModelFileFetching { - public init() {} - - public func fetch( - _ url: URL, - to destination: URL, - progress: @escaping @Sendable (Int64) -> Void - ) async throws(ASRModelDownloadError) { - guard url.scheme == "https" else { - throw .transferFailed("Model downloads must use HTTPS.") - } - let configuration = URLSessionConfiguration.ephemeral - configuration.httpCookieStorage = nil - configuration.urlCache = nil - configuration.urlCredentialStorage = nil - let session = URLSession( - configuration: configuration, delegate: HTTPSOnlyRedirects(), delegateQueue: nil) - defer { session.finishTasksAndInvalidate() } - do { - let (bytes, response) = try await session.bytes(from: url) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw ASRModelDownloadError.transferFailed("The model server refused the download.") - } - FileManager.default.createFile(atPath: destination.path, contents: nil) - let handle = try FileHandle(forWritingTo: destination) - defer { try? handle.close() } - var buffer = Data() - buffer.reserveCapacity(1 << 20) - var written: Int64 = 0 - for try await byte in bytes { - buffer.append(byte) - if buffer.count >= 1 << 20 { - try handle.write(contentsOf: buffer) - written += Int64(buffer.count) - buffer.removeAll(keepingCapacity: true) - progress(written) - } - } - try handle.write(contentsOf: buffer) - progress(written + Int64(buffer.count)) - } catch is CancellationError { - throw .cancelled - } catch let error as ASRModelDownloadError { - throw error - } catch { - throw .transferFailed(error.localizedDescription) - } - } -} - -/// Refuses any redirect that leaves HTTPS. -private final class HTTPSOnlyRedirects: NSObject, URLSessionTaskDelegate { - func urlSession( - _ session: URLSession, - task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest - ) async -> URLRequest? { - request.url?.scheme == "https" ? request : nil - } -} - -/// Bytes received so far out of the package total. -public struct ASRModelDownloadProgress: Equatable, Sendable { - public let receivedBytes: Int64 - public let totalBytes: Int64 - - public init(receivedBytes: Int64, totalBytes: Int64) { - self.receivedBytes = receivedBytes - self.totalBytes = totalBytes - } - - public var fraction: Double { - totalBytes > 0 ? min(1, Double(receivedBytes) / Double(totalBytes)) : 0 - } -} - -/// Downloads, verifies, assembles, and installs one catalog package. +/// Downloads one catalog package and installs it as the Mac's single +/// transcribe.cpp package. public struct ASRModelDownloader: Sendable { private let entry: ASRModelCatalogEntry private let store: ASRModelPackageStore @@ -230,141 +30,12 @@ public struct ASRModelDownloader: Sendable { } catch { throw .installFailed(error.localizedDescription) } - let total = entry.downloadBytes - var completed: Int64 = 0 - for file in entry.files { - try Task.checkCancellation(or: .cancelled) - let destination = staging.appending(path: file.name) - let base = completed - try await fetcher.fetch(file.url, to: destination) { received in - progress(ASRModelDownloadProgress(receivedBytes: base + received, totalBytes: total)) - } - guard - let attributes = try? fileManager.attributesOfItem(atPath: destination.path), - (attributes[.size] as? NSNumber)?.int64Value == file.bytes, - (try? Self.sha256(of: destination)) == file.sha256 - else { - throw .integrityMismatch(file.name) - } - completed += file.bytes - } - try writeNotice(in: staging) - try writeManifest(in: staging) + _ = try await ASRModelPackageAssembler(entry: entry, fetcher: fetcher) + .assemble(in: staging, progress: progress) do { return try store.install(movingFrom: staging) } catch { throw .installFailed("\(error)") } } - - /// Folds every license into one notice file, as the package schema requires. - private func writeNotice(in staging: URL) throws(ASRModelDownloadError) { - var notice = "\(entry.displayName) with Silero speech detection for local Voice.\n\n" - notice += "Runtime source: https://github.com/handy-computer/transcribe.cpp\n" - for file in entry.files where file.role == nil { - guard let text = try? String(contentsOf: staging.appending(path: file.name), encoding: .utf8) - else { - throw .integrityMismatch(file.name) - } - notice += "\n== \(file.name) ==\n\(text)\n" - try? FileManager.default.removeItem(at: staging.appending(path: file.name)) - } - do { - try Data(notice.utf8).write(to: staging.appending(path: "NOTICE.txt"), options: .atomic) - } catch { - throw .installFailed(error.localizedDescription) - } - } - - private func writeManifest(in staging: URL) throws(ASRModelDownloadError) { - struct Manifest: Encodable { - struct License: Encodable { - let spdxExpression: String - let noticeFile: String - let sourceUrl: String - } - struct Resources: Encodable { - let minimumMemoryBytes: Int64 - let recommendedMemoryBytes: Int64 - } - struct File: Encodable { - let path: String - let role: String - let bytes: Int64 - let sha256: String - } - let schemaVersion = 1 - let packageId: String - let version: String - let displayName: String - let runtime = "transcribe_cpp" - let stage = "asr" - let capabilities = ["file_asr"] - let languages: [String] - let license: License - let resources: Resources - let files: [File] - } - let noticeURL = staging.appending(path: "NOTICE.txt") - let noticeBytes: Int64 - let noticeDigest: String - do { - noticeBytes = Int64(try Data(contentsOf: noticeURL).count) - noticeDigest = try Self.sha256(of: noticeURL) - } catch { - throw .installFailed(error.localizedDescription) - } - let manifest = Manifest( - packageId: entry.packageID, - version: entry.version, - displayName: entry.displayName, - languages: entry.languages, - license: Manifest.License( - spdxExpression: entry.spdxExpression, - noticeFile: "NOTICE.txt", - sourceUrl: entry.sourceURL.absoluteString - ), - resources: Manifest.Resources( - minimumMemoryBytes: entry.minimumMemoryBytes, - recommendedMemoryBytes: entry.recommendedMemoryBytes - ), - files: entry.files.compactMap { file in - file.role.map { - Manifest.File(path: file.name, role: $0, bytes: file.bytes, sha256: file.sha256) - } - } + [ - Manifest.File(path: "NOTICE.txt", role: "notice", bytes: noticeBytes, sha256: noticeDigest) - ] - ) - do { - let encoder = JSONEncoder() - // The schema's snake_case keys come from camelCase Swift properties. - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(manifest).write( - to: staging.appending(path: "manifest.json"), options: .atomic) - } catch { - throw .installFailed(error.localizedDescription) - } - } - - static func sha256(of url: URL) throws -> String { - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - var hasher = SHA256() - while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { - hasher.update(data: chunk) - } - return hasher.finalize().map { String(format: "%02x", $0) }.joined() - } -} - -extension Task where Success == Never, Failure == Never { - fileprivate static func checkCancellation( - or error: ASRModelDownloadError - ) throws(ASRModelDownloadError) { - if Task.isCancelled { - throw error - } - } } diff --git a/Sources/hardware_controller_model_download/asr_model_catalog.swift b/Sources/hardware_controller_model_download/asr_model_catalog.swift new file mode 100644 index 0000000..0f1bd74 --- /dev/null +++ b/Sources/hardware_controller_model_download/asr_model_catalog.swift @@ -0,0 +1,222 @@ +import Foundation + +/// One pinned file the downloader fetches and verifies. +public struct ASRModelCatalogFile: Equatable, Sendable { + public let name: String + public let url: URL + public let sha256: String + public let bytes: Int64 + /// The manifest role, or nil for license text folded into the notice. + public let role: String? + + public init(name: String, url: URL, sha256: String, bytes: Int64, role: String?) { + self.name = name + self.url = url + self.sha256 = sha256 + self.bytes = bytes + self.role = role + } +} + +/// A pinned transcribe.cpp Model package either app offers to download. +public struct ASRModelCatalogEntry: Equatable, Sendable { + public let packageID: String + public let version: String + public let displayName: String + public let languages: [String] + public let spdxExpression: String + public let sourceURL: URL + public let minimumMemoryBytes: Int64 + public let recommendedMemoryBytes: Int64 + public let files: [ASRModelCatalogFile] + + public init( + packageID: String, + version: String, + displayName: String, + languages: [String], + spdxExpression: String, + sourceURL: URL, + minimumMemoryBytes: Int64, + recommendedMemoryBytes: Int64, + files: [ASRModelCatalogFile] + ) { + self.packageID = packageID + self.version = version + self.displayName = displayName + self.languages = languages + self.spdxExpression = spdxExpression + self.sourceURL = sourceURL + self.minimumMemoryBytes = minimumMemoryBytes + self.recommendedMemoryBytes = recommendedMemoryBytes + self.files = files + } + + public var downloadBytes: Int64 { + files.reduce(0) { $0 + $1.bytes } + } +} + +public enum ASRModelCatalog { + // Revisions are immutable Hugging Face commits; digests are verified before + // any file reaches the Rust validator. Updating a pin is a reviewed change. + private static let hub = "https://huggingface.co/handy-computer" + + private static func revision(_ repository: String, _ commit: String) -> URL? { + URL(string: "\(hub)/\(repository)/resolve/\(commit)") + } + + /// Silero VAD v6.2, shared by every package. + private static func speechFiles() -> [ASRModelCatalogFile]? { + guard + let speech = revision("silero-vad-v6.2-gguf", "a9b2d61c20ae4ddf3644edb34969e8185a485448") + else { + return nil + } + return [ + ASRModelCatalogFile( + name: "silero-vad-v6.2-F32.gguf", + url: speech.appending(path: "silero-vad-v6.2-F32.gguf"), + sha256: "0d871a800c420fdd5d1b17a3aea4296d1c39608f0ff4a9295eb76457eb1c88e6", + bytes: 1_241_056, + role: "speech_detection" + ), + ASRModelCatalogFile( + name: "silero-LICENSE.txt", + url: speech.appending(path: "LICENSE"), + sha256: "2e63e9a38b6e8fc0c7bc37ce174caca1862870856c6daf5697cfb785e925520b", + bytes: 1_075, + role: nil + ), + ASRModelCatalogFile( + name: "silero-NOTICE.txt", + url: speech.appending(path: "NOTICE"), + sha256: "f1ea43096a23b40d4b14fcf131586710035444e69e35051c5d83c60725b50555", + bytes: 293, + role: nil + ), + ] + } + + /// One converted Whisper English model at Q8_0 plus its Apache-2.0 texts. + private static func whisperEnglish( + size: String, + displayName: String, + commit: String, + model: (sha256: String, bytes: Int64), + notice: (sha256: String, bytes: Int64), + memory: (minimum: Int64, recommended: Int64) + ) -> ASRModelCatalogEntry? { + let repository = "whisper-\(size).en-gguf" + guard let root = revision(repository, commit), let speech = speechFiles() else { + return nil + } + let modelName = "whisper-\(size).en-Q8_0.gguf" + return ASRModelCatalogEntry( + packageID: "com.longdevity.asr.whisper_\(size)_en", + version: "\(commit.prefix(7))-q8_0", + displayName: displayName, + languages: ["en-US"], + spdxExpression: "Apache-2.0 AND MIT", + sourceURL: root.deletingLastPathComponent().deletingLastPathComponent(), + minimumMemoryBytes: memory.minimum, + recommendedMemoryBytes: memory.recommended, + files: [ + ASRModelCatalogFile( + name: modelName, + url: root.appending(path: modelName), + sha256: model.sha256, + bytes: model.bytes, + role: "model" + ), + ASRModelCatalogFile( + name: "whisper-LICENSE.txt", + url: root.appending(path: "LICENSE"), + sha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + bytes: 11_358, + role: nil + ), + ASRModelCatalogFile( + name: "whisper-NOTICE.txt", + url: root.appending(path: "NOTICE"), + sha256: notice.sha256, + bytes: notice.bytes, + role: nil + ), + ] + speech + ) + } + + /// Parakeet TDT 0.6B v3 Q8_0 with Silero speech detection. + public static let parakeetV3: ASRModelCatalogEntry? = { + guard + let parakeet = revision( + "parakeet-tdt-0.6b-v3-gguf", "d8d8c32cb2017c57e5307767343e97870f8a7f53"), + let speech = speechFiles() + else { + return nil + } + return ASRModelCatalogEntry( + packageID: "com.longdevity.asr.parakeet_tdt_v3", + version: "d8d8c32-q8_0", + displayName: "Parakeet TDT 0.6B v3", + // The 25 European languages Parakeet v3 was trained on. + languages: [ + "bg", "cs", "da", "de", "el", "en", "es", "et", "fi", "fr", "hr", "hu", "it", + "lt", "lv", "mt", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sv", "uk", + ], + spdxExpression: "CC-BY-4.0 AND MIT", + sourceURL: parakeet.deletingLastPathComponent().deletingLastPathComponent(), + minimumMemoryBytes: 1_073_741_824, + recommendedMemoryBytes: 2_147_483_648, + files: [ + ASRModelCatalogFile( + name: "parakeet-tdt-0.6b-v3-Q8_0.gguf", + url: parakeet.appending(path: "parakeet-tdt-0.6b-v3-Q8_0.gguf"), + sha256: "5859f77944efcd8eafa23a6350731960b2b55b2203df51f319665c807d802cc7", + bytes: 739_508_576, + role: "model" + ), + speech[0], + ASRModelCatalogFile( + name: "parakeet-LICENSE.txt", + url: parakeet.appending(path: "LICENSE"), + sha256: "9ba9550ad48438d0836ddab3da480b3b69ffa0aac7b7878b5a0039e7ab429411", + bytes: 18_657, + role: nil + ), + ASRModelCatalogFile( + name: "parakeet-NOTICE.txt", + url: parakeet.appending(path: "NOTICE"), + sha256: "6f02c515f3deed71bb0e8909c8d8449faeb365ae507f67cd026c677b1bddcec8", + bytes: 400, + role: nil + ), + ] + speech.dropFirst() + ) + }() + + public static let whisperBaseEnglish = whisperEnglish( + size: "base", + displayName: "Whisper Base English", + commit: "0dd374130837de419035c77d76440aad61ca135c", + model: ("3b46ca40bccbf7609c68d88a36d96077a04ca7c87f2060ede06f129fac3e7652", 84_886_208), + notice: ("96e56d6de15361b9728e8463d8d5571717b86c29a338839a9cb7a9c1b58c81fd", 395), + memory: (268_435_456, 536_870_912) + ) + + public static let whisperSmallEnglish = whisperEnglish( + size: "small", + displayName: "Whisper Small English", + commit: "ab86448a84fcc15a96a9a2216d93a8dac8427da3", + model: ("9614e6b7fda2d26018e4f268aece8ca25a83296ea0b534169a585b740bfd71ef", 269_674_144), + notice: ("9ea2fa3f2d7b6b8b5f672b4a3491eb938d3d7ba033e06b6b02c2b8c6df7d30d8", 396), + memory: (536_870_912, 1_073_741_824) + ) + + /// Downloads Voice Input offers beside its bundled Whisper Tiny English, + /// smallest first. + public static let voiceInputDownloads: [ASRModelCatalogEntry] = [ + whisperBaseEnglish, whisperSmallEnglish, parakeetV3, + ].compactMap { $0 } +} diff --git a/Sources/hardware_controller_model_download/asr_model_file_fetcher.swift b/Sources/hardware_controller_model_download/asr_model_file_fetcher.swift new file mode 100644 index 0000000..ba05b65 --- /dev/null +++ b/Sources/hardware_controller_model_download/asr_model_file_fetcher.swift @@ -0,0 +1,83 @@ +import Foundation + +public enum ASRModelDownloadError: Error, Equatable, Sendable { + case catalogUnavailable + case transferFailed(String) + /// A file's size or digest differed from its pin; nothing was installed. + case integrityMismatch(String) + case installFailed(String) + case cancelled +} + +/// Fetches one URL to a local file: the only network code in Voice Input and, +/// besides Ollama, in the Mac app. +public protocol ASRModelFileFetching: Sendable { + func fetch( + _ url: URL, + to destination: URL, + progress: @escaping @Sendable (Int64) -> Void + ) async throws(ASRModelDownloadError) +} + +/// Downloads over HTTPS with no cookies, cache, or credentials. +public struct URLSessionModelFileFetcher: ASRModelFileFetching { + public init() {} + + public func fetch( + _ url: URL, + to destination: URL, + progress: @escaping @Sendable (Int64) -> Void + ) async throws(ASRModelDownloadError) { + guard url.scheme == "https" else { + throw .transferFailed("Model downloads must use HTTPS.") + } + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.urlCache = nil + configuration.urlCredentialStorage = nil + let session = URLSession( + configuration: configuration, delegate: HTTPSOnlyRedirects(), delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + do { + let (bytes, response) = try await session.bytes(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw ASRModelDownloadError.transferFailed("The model server refused the download.") + } + FileManager.default.createFile(atPath: destination.path, contents: nil) + let handle = try FileHandle(forWritingTo: destination) + defer { try? handle.close() } + var buffer = Data() + buffer.reserveCapacity(1 << 20) + var written: Int64 = 0 + for try await byte in bytes { + buffer.append(byte) + if buffer.count >= 1 << 20 { + try handle.write(contentsOf: buffer) + written += Int64(buffer.count) + buffer.removeAll(keepingCapacity: true) + progress(written) + } + } + try handle.write(contentsOf: buffer) + progress(written + Int64(buffer.count)) + } catch is CancellationError { + throw .cancelled + } catch let error as ASRModelDownloadError { + throw error + } catch { + throw .transferFailed(error.localizedDescription) + } + } +} + +/// Refuses any redirect that leaves HTTPS. +private final class HTTPSOnlyRedirects: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest + ) async -> URLRequest? { + request.url?.scheme == "https" ? request : nil + } +} diff --git a/Sources/hardware_controller_model_download/asr_model_package_assembler.swift b/Sources/hardware_controller_model_download/asr_model_package_assembler.swift new file mode 100644 index 0000000..af926c4 --- /dev/null +++ b/Sources/hardware_controller_model_download/asr_model_package_assembler.swift @@ -0,0 +1,179 @@ +import CryptoKit +import Foundation + +/// Bytes received so far out of the package total. +public struct ASRModelDownloadProgress: Equatable, Sendable { + public let receivedBytes: Int64 + public let totalBytes: Int64 + + public init(receivedBytes: Int64, totalBytes: Int64) { + self.receivedBytes = receivedBytes + self.totalBytes = totalBytes + } + + public var fraction: Double { + totalBytes > 0 ? min(1, Double(receivedBytes) / Double(totalBytes)) : 0 + } +} + +/// Downloads, verifies, and assembles one catalog package in a staging +/// directory the caller owns; each app then installs it through its own +/// Rust-validated store. +public struct ASRModelPackageAssembler: Sendable { + private let entry: ASRModelCatalogEntry + private let fetcher: any ASRModelFileFetching + + public init( + entry: ASRModelCatalogEntry, + fetcher: any ASRModelFileFetching = URLSessionModelFileFetcher() + ) { + self.entry = entry + self.fetcher = fetcher + } + + /// Fills `staging`, an existing empty directory, with a complete package and + /// returns its manifest's SHA-256. Every file matched its pinned size and + /// digest, so the generated manifest is pinned transitively. + public func assemble( + in staging: URL, + progress: @escaping @Sendable (ASRModelDownloadProgress) -> Void + ) async throws(ASRModelDownloadError) -> Data { + let fileManager = FileManager.default + let total = entry.downloadBytes + var completed: Int64 = 0 + for file in entry.files { + try Task.checkCancellation(or: .cancelled) + let destination = staging.appending(path: file.name) + let base = completed + try await fetcher.fetch(file.url, to: destination) { received in + progress(ASRModelDownloadProgress(receivedBytes: base + received, totalBytes: total)) + } + guard + let attributes = try? fileManager.attributesOfItem(atPath: destination.path), + (attributes[.size] as? NSNumber)?.int64Value == file.bytes, + (try? Self.sha256(of: destination)) == file.sha256 + else { + throw .integrityMismatch(file.name) + } + completed += file.bytes + } + try writeNotice(in: staging) + try writeManifest(in: staging) + do { + return Data(SHA256.hash(data: try Data(contentsOf: staging.appending(path: "manifest.json")))) + } catch { + throw .installFailed(error.localizedDescription) + } + } + + /// Folds every license into one notice file, as the package schema requires. + private func writeNotice(in staging: URL) throws(ASRModelDownloadError) { + var notice = "\(entry.displayName) with Silero speech detection for local Voice.\n\n" + notice += "Runtime source: https://github.com/handy-computer/transcribe.cpp\n" + for file in entry.files where file.role == nil { + guard let text = try? String(contentsOf: staging.appending(path: file.name), encoding: .utf8) + else { + throw .integrityMismatch(file.name) + } + notice += "\n== \(file.name) ==\n\(text)\n" + try? FileManager.default.removeItem(at: staging.appending(path: file.name)) + } + do { + try Data(notice.utf8).write(to: staging.appending(path: "NOTICE.txt"), options: .atomic) + } catch { + throw .installFailed(error.localizedDescription) + } + } + + private func writeManifest(in staging: URL) throws(ASRModelDownloadError) { + struct Manifest: Encodable { + struct License: Encodable { + let spdxExpression: String + let noticeFile: String + let sourceUrl: String + } + struct Resources: Encodable { + let minimumMemoryBytes: Int64 + let recommendedMemoryBytes: Int64 + } + struct File: Encodable { + let path: String + let role: String + let bytes: Int64 + let sha256: String + } + let schemaVersion = 1 + let packageId: String + let version: String + let displayName: String + let runtime = "transcribe_cpp" + let stage = "asr" + let capabilities = ["file_asr"] + let languages: [String] + let license: License + let resources: Resources + let files: [File] + } + let noticeURL = staging.appending(path: "NOTICE.txt") + let noticeBytes: Int64 + let noticeDigest: String + do { + noticeBytes = Int64(try Data(contentsOf: noticeURL).count) + noticeDigest = try Self.sha256(of: noticeURL) + } catch { + throw .installFailed(error.localizedDescription) + } + let manifest = Manifest( + packageId: entry.packageID, + version: entry.version, + displayName: entry.displayName, + languages: entry.languages, + license: Manifest.License( + spdxExpression: entry.spdxExpression, + noticeFile: "NOTICE.txt", + sourceUrl: entry.sourceURL.absoluteString + ), + resources: Manifest.Resources( + minimumMemoryBytes: entry.minimumMemoryBytes, + recommendedMemoryBytes: entry.recommendedMemoryBytes + ), + files: entry.files.compactMap { file in + file.role.map { + Manifest.File(path: file.name, role: $0, bytes: file.bytes, sha256: file.sha256) + } + } + [ + Manifest.File(path: "NOTICE.txt", role: "notice", bytes: noticeBytes, sha256: noticeDigest) + ] + ) + do { + let encoder = JSONEncoder() + // The schema's snake_case keys come from camelCase Swift properties. + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(manifest).write( + to: staging.appending(path: "manifest.json"), options: .atomic) + } catch { + throw .installFailed(error.localizedDescription) + } + } + + static func sha256(of url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} + +extension Task where Success == Never, Failure == Never { + fileprivate static func checkCancellation( + or error: ASRModelDownloadError + ) throws(ASRModelDownloadError) { + if Task.isCancelled { + throw error + } + } +} diff --git a/Tests/HardwareControllerAppTests/speech_engine_model_test.swift b/Tests/HardwareControllerAppTests/speech_engine_model_test.swift index b727dc3..8d88956 100644 --- a/Tests/HardwareControllerAppTests/speech_engine_model_test.swift +++ b/Tests/HardwareControllerAppTests/speech_engine_model_test.swift @@ -1,6 +1,7 @@ import CryptoKit import Foundation import HardwareControllerCore +import HardwareControllerModelDownload import Testing @testable import HardwareControllerApp diff --git a/Tests/HardwareControllerMacTests/asr_model_downloader_test.swift b/Tests/HardwareControllerMacTests/asr_model_downloader_test.swift index 0ef9d3f..1654c3b 100644 --- a/Tests/HardwareControllerMacTests/asr_model_downloader_test.swift +++ b/Tests/HardwareControllerMacTests/asr_model_downloader_test.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import HardwareControllerModelDownload import Testing @testable import HardwareControllerMac @@ -105,15 +106,6 @@ struct ASRModelDownloaderTest { } #expect(store.installed() == nil) } - - @Test - func pinnedCatalogIsComplete() throws { - let entry = try #require(ASRModelCatalog.parakeetV3) - #expect( - entry.files.allSatisfy { $0.url.scheme == "https" && $0.sha256.count == 64 && $0.bytes > 0 }) - #expect(entry.files.compactMap(\.role).sorted() == ["model", "speech_detection"]) - #expect(entry.downloadBytes > 700_000_000) - } } private final class ProgressLog: @unchecked Sendable { diff --git a/Tests/hardware_controller_model_download_tests/asr_model_catalog_test.swift b/Tests/hardware_controller_model_download_tests/asr_model_catalog_test.swift new file mode 100644 index 0000000..cff3fca --- /dev/null +++ b/Tests/hardware_controller_model_download_tests/asr_model_catalog_test.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing + +@testable import HardwareControllerModelDownload + +struct ASRModelCatalogTest { + private static let entries = [ + ASRModelCatalog.parakeetV3, ASRModelCatalog.whisperBaseEnglish, + ASRModelCatalog.whisperSmallEnglish, + ] + + @Test + func everyEntryResolves() { + #expect(Self.entries.allSatisfy { $0 != nil }) + } + + /// Every file is HTTPS, pinned to an immutable 40-hex commit, and digested. + @Test(arguments: entries.compactMap { $0 }) + func entryIsFullyPinned(_ entry: ASRModelCatalogEntry) throws { + let commit = try Regex("/resolve/[0-9a-f]{40}/") + let digest = try Regex("^[0-9a-f]{64}$") + for file in entry.files { + #expect(file.url.scheme == "https", "\(file.name)") + #expect(file.url.absoluteString.contains(commit), "\(file.name)") + #expect(file.sha256.wholeMatch(of: digest) != nil, "\(file.name)") + #expect(file.bytes > 0, "\(file.name)") + } + #expect(entry.files.compactMap(\.role).sorted() == ["model", "speech_detection"]) + #expect(Set(entry.files.map(\.name)).count == entry.files.count) + } + + @Test + func voiceInputOffersDistinctPackagesSmallestFirst() { + let downloads = ASRModelCatalog.voiceInputDownloads + #expect(downloads.count == 3) + #expect(Set(downloads.map(\.packageID)).count == downloads.count) + #expect(downloads.map(\.downloadBytes) == downloads.map(\.downloadBytes).sorted()) + #expect(downloads.last?.packageID == "com.longdevity.asr.parakeet_tdt_v3") + } +} diff --git a/Tests/hardware_controller_model_download_tests/asr_model_file_fetcher_test.swift b/Tests/hardware_controller_model_download_tests/asr_model_file_fetcher_test.swift new file mode 100644 index 0000000..fbbde1c --- /dev/null +++ b/Tests/hardware_controller_model_download_tests/asr_model_file_fetcher_test.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing + +@testable import HardwareControllerModelDownload + +struct ASRModelFileFetcherTest { + /// Plain HTTP is refused before any connection is opened. + @Test + func refusesPlainHTTP() async throws { + let url = try #require(URL(string: "http://example.invalid/model.gguf")) + let destination = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + await #expect(throws: ASRModelDownloadError.transferFailed("Model downloads must use HTTPS.")) { + try await URLSessionModelFileFetcher().fetch(url, to: destination) { _ in } + } + #expect(!FileManager.default.fileExists(atPath: destination.path)) + } +} diff --git a/Tests/hardware_controller_model_download_tests/asr_model_package_assembler_test.swift b/Tests/hardware_controller_model_download_tests/asr_model_package_assembler_test.swift new file mode 100644 index 0000000..966b2cd --- /dev/null +++ b/Tests/hardware_controller_model_download_tests/asr_model_package_assembler_test.swift @@ -0,0 +1,97 @@ +import CryptoKit +import Foundation +import Testing + +@testable import HardwareControllerModelDownload + +struct ASRModelPackageAssemblerTest { + /// Serves fixture bytes by file name, optionally corrupting one. + private struct Fetcher: ASRModelFileFetching { + var corrupt: String? + + func fetch( + _ url: URL, + to destination: URL, + progress: @escaping @Sendable (Int64) -> Void + ) async throws(ASRModelDownloadError) { + var data = ASRModelPackageAssemblerTest.contents[url.lastPathComponent] ?? Data() + if url.lastPathComponent == corrupt { + data[0] ^= 0xff + } + do { + try data.write(to: destination) + } catch { + throw .transferFailed("fixture") + } + progress(Int64(data.count)) + } + } + + fileprivate static let contents: [String: Data] = [ + "model.gguf": Data(repeating: 1, count: 4_096), + "vad.gguf": Data(repeating: 2, count: 512), + "LICENSE": Data("Apache License 2.0".utf8), + ] + + private static func entry() throws -> ASRModelCatalogEntry { + let base = try #require(URL(string: "https://example.invalid/resolve/abc")) + func file(_ name: String, _ role: String?) -> ASRModelCatalogFile { + let data = contents[name] ?? Data() + return ASRModelCatalogFile( + name: name == "LICENSE" ? "model-LICENSE.txt" : name, + url: base.appending(path: name), + sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), + bytes: Int64(data.count), + role: role + ) + } + return ASRModelCatalogEntry( + packageID: "com.longdevity.asr.fixture", + version: "1", + displayName: "Fixture", + languages: ["en-US"], + spdxExpression: "Apache-2.0", + sourceURL: base, + minimumMemoryBytes: 1, + recommendedMemoryBytes: 1, + files: [ + file("model.gguf", "model"), file("vad.gguf", "speech_detection"), file("LICENSE", nil), + ] + ) + } + + private static func staging() throws -> URL { + let url = FileManager.default.temporaryDirectory.appending( + path: "assembler_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test + func assemblesAPinnedPackageAndReturnsItsManifestDigest() async throws { + let staging = try Self.staging() + defer { try? FileManager.default.removeItem(at: staging) } + + let digest = try await ASRModelPackageAssembler(entry: try Self.entry(), fetcher: Fetcher()) + .assemble(in: staging) { _ in } + + let manifest = try Data(contentsOf: staging.appending(path: "manifest.json")) + #expect(digest == Data(SHA256.hash(data: manifest))) + let names = try FileManager.default.contentsOfDirectory(atPath: staging.path).sorted() + #expect(names == ["NOTICE.txt", "manifest.json", "model.gguf", "vad.gguf"]) + let notice = try String(contentsOf: staging.appending(path: "NOTICE.txt"), encoding: .utf8) + #expect(notice.contains("Apache License 2.0")) + } + + @Test + func aCorruptedFileStopsAssembly() async throws { + let staging = try Self.staging() + defer { try? FileManager.default.removeItem(at: staging) } + await #expect(throws: ASRModelDownloadError.integrityMismatch("vad.gguf")) { + _ = try await ASRModelPackageAssembler( + entry: try Self.entry(), fetcher: Fetcher(corrupt: "vad.gguf") + ).assemble(in: staging) { _ in } + } + #expect(!FileManager.default.fileExists(atPath: staging.appending(path: "manifest.json").path)) + } +} diff --git a/Tests/untested_sources.txt b/Tests/untested_sources.txt index e9277ae..0954663 100644 --- a/Tests/untested_sources.txt +++ b/Tests/untested_sources.txt @@ -19,10 +19,11 @@ apps/ios/voice_input/app/voice_input_history_view.swift | View; state in `voice_ apps/ios/voice_input/app/voice_input_dictate_view.swift | View; state in `voice_input_dictation_presentation_test`, driven by `voice_input_ui_test`. apps/ios/voice_input/app/voice_input_root_view.swift | Tab container; driven by `voice_input_ui_test`. apps/ios/voice_input/app/voice_input_settings_view.swift | View; state in `voice_input_history_model_test`, driven by `voice_input_ui_test`. -apps/ios/voice_input/app/voice_input_theme.swift | Style constant. +apps/ios/voice_input/shared/voice_input_theme.swift | Style constant. apps/ios/voice_input/app/voice_input_model_library_view.swift | View; state in `voice_input_model_library_model_test`, driven by `voice_input_ui_test`. apps/ios/voice_input/app/voice_input_onboarding_view.swift | View; policy in `voice_input_onboarding_policy_test`. -apps/ios/voice_input/keyboard/keyboard_view_controller.swift | UIKit host; policy in `voice_input_keyboard_policy_test`. +apps/ios/voice_input/keyboard/keyboard_view_controller.swift | UIKit host; policy in `voice_input_keyboard_policy_test`, layout in `voice_input_keyboard_layout_test`. +apps/ios/voice_input/keyboard/keyboard_keys_view.swift | UIKit key rendering; rows and widths in `voice_input_keyboard_layout_test`. apps/ios/voice_input/widgets/voice_input_widgets.swift | WidgetKit declarations. # Composition roots and system boundaries, exercised through their seams. diff --git a/apps/ios/voice_input/README.md b/apps/ios/voice_input/README.md index e35daa5..3038aa2 100644 --- a/apps/ios/voice_input/README.md +++ b/apps/ios/voice_input/README.md @@ -13,11 +13,11 @@ the UI tests with `jfk.wav` in place of the microphone (Debug builds only). | Path | Owns | | --- | --- | -| `app/` | Capture, transcribe.cpp ASR, formatting, History, Model library, onboarding. | -| `keyboard/` | QWERTY keyboard, voice status, one insertion attempt, recovery. | +| `app/` | Capture, transcribe.cpp ASR, formatting, History, model downloads, onboarding. | +| `keyboard/` | Letter, number, and symbol layers; voice bar; one insertion attempt, recovery. | | `system_capture/`, `widgets/` | Control Center control, App Intents, Live Activity. | | `shared/` | Bounded Keychain handoff shared by app and extensions. | -| `resources/` | App icon (Signal Bridge, cropped full-bleed) and notices. | +| `resources/` | App icon (rendered by `tools/app_icons/`) and notices. | | `tests/`, `ui_tests/` | Unit tests; UI tests that dictate a recorded clip end to end. | | `project.yml` | XcodeGen source; regenerate with `scripts/generate_ios_project.sh`. | diff --git a/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj b/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj index a5b1bc8..4b2a46d 100644 --- a/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj +++ b/apps/ios/voice_input/VoiceInput.xcodeproj/project.pbxproj @@ -29,17 +29,20 @@ 270E6555BB1489D7D34DE3ED /* voice_input_session_finalizer_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8121E61C52377107D508F8 /* voice_input_session_finalizer_test.swift */; }; 2784394584546AAEECE78B95 /* voice_input_ui_test_hooks_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAA81614D08BC8D9B4A3E9B /* voice_input_ui_test_hooks_test.swift */; }; 2C028748C4B8D074577F130F /* voice_input_history_audio_player_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC72B88EC8E75C1607A6E164 /* voice_input_history_audio_player_model.swift */; }; + 2DAC7BC233A05C703752C650 /* HardwareControllerModelDownload.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB5E951ED570B5C58267E86E /* HardwareControllerModelDownload.framework */; }; 35440F5AD76F3BD99A03E69F /* voice_input_capture_service_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12CE8032228EE5BB97E4FCE0 /* voice_input_capture_service_test.swift */; }; 3765B4DC9A8E0AE682C5D795 /* voice_formatted_document_builder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FC7415074EA515761E3F82D /* voice_formatted_document_builder.swift */; }; 37D39F173867AD372946A783 /* voice_input_history_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90BEFBB956AD1AC6913E534D /* voice_input_history_model.swift */; }; 38C56874E286A15B2464F4BD /* HardwareControllerCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 692A77389CA17AB7CF7F04C4 /* HardwareControllerCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 39B5ED77194467F901FBD4FA /* voice_input_style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5730264B057CBE4C0B33BB /* voice_input_style.swift */; }; + 3C7CDCE4B8DE54F0CCB400E4 /* voice_input_model_choice_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7D30D754D18735AC99FCB3 /* voice_input_model_choice_test.swift */; }; 403192A59E0FC2748AC21CFF /* voice_input_lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8344AF8ACDAB066D7290B6 /* voice_input_lifecycle.swift */; }; 453F44F50EEAF923A612DC44 /* voice_input_live_audio_tap_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E80C3A99EB8CBD76D72CA99 /* voice_input_live_audio_tap_test.swift */; }; 46449AD40F4C280C255AD9CC /* assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6698685ACF5BAECC5D940D0E /* assets.xcassets */; }; 4A1D19C1F59B97D2B32A516F /* voice_input_app_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA699775DF46AC81E02D6271 /* voice_input_app_model.swift */; }; 4C9DC40969E1C9ACDB780066 /* voice_input_activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C842A307B0A0140A0178DEA0 /* voice_input_activity.swift */; }; 5048EB9AF1894E72B098BDEA /* voice_input_asr_model_registry_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE5854528F7B9366D6CE8AA /* voice_input_asr_model_registry_test.swift */; }; + 50D97C701384B50B636B65F4 /* voice_input_keyboard_layout_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD75495E3A781A28EAEEEF6 /* voice_input_keyboard_layout_test.swift */; }; 50DE4567233BF190641F64DB /* HardwareControllerVoiceFFI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D6CF255C19A75FF98CAA350 /* HardwareControllerVoiceFFI.framework */; }; 528E6DF8FA11984EFC0D8A69 /* voice_input_style_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DFDF7EBFC4891F604797237 /* voice_input_style_test.swift */; }; 538FE3950E1BC05C4022D873 /* voice_spoken_edit_replayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4E6158219BA1EF466A8CDD /* voice_spoken_edit_replayer.swift */; }; @@ -51,8 +54,10 @@ 5D695AB424A5ED265EA34A62 /* VoiceInputShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; }; 5D70C45A7359F4D4C7F642D8 /* VoiceInputWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A9711C0C615DA66AEBDFC0E8 /* VoiceInputWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 601A8A59075842D2B2D336B0 /* voice_input_model_library_model_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83300748E2DEF79D228F4A66 /* voice_input_model_library_model_test.swift */; }; + 626507302A5E638C6AA32D76 /* asr_model_catalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2404E197C794C26AAE6AE5D9 /* asr_model_catalog.swift */; }; 6426FA38E102684A8909FB1B /* voice_input_ui_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC1D9F56D811C3B343F7854 /* voice_input_ui_test.swift */; }; 647E4437DA0895BC893E0108 /* voice_formatted_text_renderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E74E0CA5543420625918CB3 /* voice_formatted_text_renderer.swift */; }; + 6761CEC7FBFF23DCC353F2D2 /* voice_input_theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1315D4A6CFC9791286BDB555 /* voice_input_theme.swift */; }; 68B9D04F5D4D77708DA1ACD4 /* voice_input_onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 442CB63700ABD3B2397BE1F5 /* voice_input_onboarding.swift */; }; 6987C49422E0CF3395F217AF /* voice_input_local_copy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E594DE72AD43B775B93413 /* voice_input_local_copy.swift */; }; 69B7D66F8B4F0C8FE52555C5 /* voice_input_insertion_recovery_policy_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = B428772494717A951306D51A /* voice_input_insertion_recovery_policy_test.swift */; }; @@ -68,6 +73,7 @@ 7EC357F014207286C47ECAA4 /* voice_input_lifecycle_notification_mapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CCB83B95650A36ED0CB4A4 /* voice_input_lifecycle_notification_mapper.swift */; }; 7FACC86CD0EA5EFB6B53F922 /* keyboard_view_controller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DE7C7AF8A1766381BEF3F4 /* keyboard_view_controller.swift */; }; 80912E4129B0B3D4AA16150B /* voice_input_model_library_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2761F315707E81BC58E239D8 /* voice_input_model_library_model.swift */; }; + 88216DC0EF9B06AA980CFE97 /* voice_input_model_downloader_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D2D1CC00DC195714DF4BF5 /* voice_input_model_downloader_test.swift */; }; 88E4A13989E6A1DD1929A51F /* voice_input_store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB1D8B9506FDB924CFE42AF /* voice_input_store.swift */; }; 8B4875D8100EDA752E0B2099 /* VoiceInputShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 913145A6E0C0E5FBE9D69581 /* voice_formatting_draft_normalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B6E93C6B9BE1E48673B3AB /* voice_formatting_draft_normalizer.swift */; }; @@ -82,9 +88,10 @@ 9FAAE9B6C46841D53A29847E /* voice_input_local_transcriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA1E80BDAEC9549CBFAAB49 /* voice_input_local_transcriber.swift */; }; 9FBC75AA92E1CB05DBF073D1 /* voice_input_history_audio_player_model_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7123485BE89DAC67B2C3451 /* voice_input_history_audio_player_model_test.swift */; }; A644DD8B9766C5E6137FC1F9 /* voice_input_asr_workflow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5768072590C38554047CC0F /* voice_input_asr_workflow.swift */; }; + A8A8184598B4A8E3FCDCE64D /* asr_model_file_fetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12B3D8871B70101F8036CE8 /* asr_model_file_fetcher.swift */; }; + AA9A1B822D4018FA1D142B5A /* asr_model_package_assembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5E18A8885A9B92F464E9C9 /* asr_model_package_assembler.swift */; }; ABE23293CA06AEE1FB74ECCE /* VoiceInputShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; }; ACF8F70673D62E07224D147A /* voice_input_model_package_stager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3760E9B21A975A8A98C93D /* voice_input_model_package_stager.swift */; }; - AE365A316BD3AFEFBBDD75FE /* voice_input_theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A09F9722E196FE3137A439D7 /* voice_input_theme.swift */; }; B1B82228A41A3412B882982C /* voice_input_system_capture_intents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E06E252FCFBB5CBDEDA1BD /* voice_input_system_capture_intents.swift */; }; B38520F5D89D0EC038D8A8DD /* voice_input_capture_service.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B91A570D429EEB46217A80C /* voice_input_capture_service.swift */; }; B517F4ECE303FBF6054DBF34 /* voice_input_starter_model.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42CE3568045B6705385AA62 /* voice_input_starter_model.swift */; }; @@ -96,6 +103,7 @@ BD1D8F99CD1C7F8D6E6818F4 /* voice_history_retention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D025266467687E8B7FC7E78 /* voice_history_retention.swift */; }; BD3872A886B0362F2568FC88 /* VoiceInputShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; }; BEDD46DD9704D6713B0D6F1F /* voice_list_intent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34139F6C135D8EB64229FA38 /* voice_list_intent.swift */; }; + C071448C5633A8C8479E2207 /* keyboard_keys_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CE4AAD6B9819DB6E341139 /* keyboard_keys_view.swift */; }; C0C1A2D27B1F77673F8ABCD8 /* voice_input_session_finalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6CFBF1348B03A8F045AB741 /* voice_input_session_finalizer.swift */; }; C88188E79A53C59A17188BA0 /* voice_input_lifecycle_notification_mapper_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 659DA20B14ABA097FDD7322B /* voice_input_lifecycle_notification_mapper_test.swift */; }; C9CD662D7B1BA9E8264B26F2 /* VoiceInputShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0123BE2D32E45C9A1A736155 /* VoiceInputShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -103,6 +111,7 @@ CC64B99825542AD17E27CE9E /* voice_input_lifecycle_policy_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = E73827118DA96919FED1F547 /* voice_input_lifecycle_policy_test.swift */; }; CDC6C2A0560AFDF985DE21A9 /* voice_input_ui_test_hooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB986919BBFA21E33388B38 /* voice_input_ui_test_hooks.swift */; }; CFE52B091FD3A71C14E34A97 /* voice_input_environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC71BC68D1A1B8B71EDD8A8 /* voice_input_environment.swift */; }; + D097C2AEB25F587F16EE45D1 /* voice_input_keyboard_layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1598CF1B78A01FAFA125DFA5 /* voice_input_keyboard_layout.swift */; }; D5959E7ADC42529F67A2ABED /* voice_spoken_edit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FB32FBAE2A5D044818C786 /* voice_spoken_edit.swift */; }; D5B92E0B0A8743726CCB4BA5 /* voice_history_retention_planner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3D153AB72B493B089597C4 /* voice_history_retention_planner.swift */; }; D6EEA7DF2832E285455D62DF /* voice_input_history_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E9ADA862D5509C651719B52 /* voice_input_history_view.swift */; }; @@ -122,14 +131,17 @@ EEC2EE8844654D1BE3187E95 /* HardwareControllerCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 692A77389CA17AB7CF7F04C4 /* HardwareControllerCore.framework */; }; F0B221E9C8D6539465ACB680 /* voice_input_model_library_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4D086A77C159F47E36FA8B /* voice_input_model_library_view.swift */; }; F3D2A8D85C4CD7421F02E700 /* voice_casing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86752435C202AF492298C9A4 /* voice_casing.swift */; }; + F5979822F759DD034F97F045 /* voice_input_model_choice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F48C9449F2D5147FE806AF /* voice_input_model_choice.swift */; }; F6791D02D2B8E619A2B94C7C /* voice_input_widgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9EDA822613D13F8604F58F /* voice_input_widgets.swift */; }; F6B022DE0F836E72C99FC1EF /* voice_input_history_retention_preferences_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E50A5416BA4B684E5EE13EE /* voice_input_history_retention_preferences_test.swift */; }; F6C56F90353A839C7C830400 /* voice_input_host_field_policy_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = E59D05A6E89992CBAB7EB9E0 /* voice_input_host_field_policy_test.swift */; }; FA0DB71BF6F5068E9994C35E /* valid in Resources */ = {isa = PBXBuildFile; fileRef = 70AD4B72B184FCE0C2B0075E /* valid */; }; + FB00E14F390B0DFF8BCDACB4 /* voice_input_model_downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75D0AB54BB57795258F109F /* voice_input_model_downloader.swift */; }; FC6171908D53F02641CFC69A /* voice_input_live_audio_tap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91AD0DD823EE4AD0F2966C32 /* voice_input_live_audio_tap.swift */; }; FD2AC3182FE99231B97918A1 /* voice_input_app_model_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24351BDC6ACA4CA02189AE53 /* voice_input_app_model_test.swift */; }; FD8B54810DE4272D541B39A0 /* HardwareControllerVoiceFFI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D6CF255C19A75FF98CAA350 /* HardwareControllerVoiceFFI.framework */; }; FEC66FC78A64BD691A1F8309 /* voice_input_model_package_installer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55CC42AC07A3DC0E467CB0B5 /* voice_input_model_package_installer.swift */; }; + FF0694ABA89CEB7E0E5435A1 /* HardwareControllerModelDownload.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB5E951ED570B5C58267E86E /* HardwareControllerModelDownload.framework */; }; FFB39ADEB1D09C4A5EB54F7E /* voice_input_style_preference_test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06EB9098ABABEC3A4D678776 /* voice_input_style_preference_test.swift */; }; /* End PBXBuildFile section */ @@ -183,6 +195,13 @@ remoteGlobalIDString = F4EC3DB9B4E3A533DB5C14CC; remoteInfo = VoiceInputKeyboard; }; + 712E009225B689EFCAC32C4A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B79158C72F4ABF96A4ADB0B0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7C949B6C8A224AEFD7A4ACD1; + remoteInfo = HardwareControllerModelDownload; + }; 78C247BC2011642A20D40D31 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = B79158C72F4ABF96A4ADB0B0 /* Project object */; @@ -232,6 +251,13 @@ remoteGlobalIDString = 053FA83E762BA74C46F67809; remoteInfo = HardwareControllerCore; }; + F13F710DC03E4B11243621EA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B79158C72F4ABF96A4ADB0B0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7C949B6C8A224AEFD7A4ACD1; + remoteInfo = HardwareControllerModelDownload; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -289,12 +315,15 @@ 11E594DE72AD43B775B93413 /* voice_input_local_copy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_local_copy.swift; sourceTree = ""; }; 11E9AB745A5A0B0A29495FB7 /* voice_input_app_shortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_app_shortcuts.swift; sourceTree = ""; }; 12CE8032228EE5BB97E4FCE0 /* voice_input_capture_service_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_capture_service_test.swift; sourceTree = ""; }; + 1315D4A6CFC9791286BDB555 /* voice_input_theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_theme.swift; sourceTree = ""; }; + 1598CF1B78A01FAFA125DFA5 /* voice_input_keyboard_layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_keyboard_layout.swift; sourceTree = ""; }; 1708ECC55A0234D0EF342089 /* voice_input_delivery_target.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_delivery_target.swift; sourceTree = ""; }; 188696B6B6F97B2623965D45 /* voice_input_settings_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_settings_view.swift; sourceTree = ""; }; 1CAA81614D08BC8D9B4A3E9B /* voice_input_ui_test_hooks_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_ui_test_hooks_test.swift; sourceTree = ""; }; 1CCC0C83D88B8C773AAF1690 /* voice_input_document_pipeline_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_document_pipeline_test.swift; sourceTree = ""; }; 1D6CF255C19A75FF98CAA350 /* HardwareControllerVoiceFFI.framework */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.framework; path = HardwareControllerVoiceFFI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 23BB17096DE2EC193AB541C9 /* voice_input_local_transcriber_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_local_transcriber_test.swift; sourceTree = ""; }; + 2404E197C794C26AAE6AE5D9 /* asr_model_catalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = asr_model_catalog.swift; sourceTree = ""; }; 24351BDC6ACA4CA02189AE53 /* voice_input_app_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_app_model_test.swift; sourceTree = ""; }; 2761F315707E81BC58E239D8 /* voice_input_model_library_model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_library_model.swift; sourceTree = ""; }; 2CB986919BBFA21E33388B38 /* voice_input_ui_test_hooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_ui_test_hooks.swift; sourceTree = ""; }; @@ -312,8 +341,10 @@ 4C4D086A77C159F47E36FA8B /* voice_input_model_library_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_library_view.swift; sourceTree = ""; }; 4D3D153AB72B493B089597C4 /* voice_history_retention_planner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_history_retention_planner.swift; sourceTree = ""; }; 50091BF1A07BAF46B4583FE6 /* voice_input_starter_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_starter_model_test.swift; sourceTree = ""; }; + 53CE4AAD6B9819DB6E341139 /* keyboard_keys_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = keyboard_keys_view.swift; sourceTree = ""; }; 544F3B0559B14EA6685C26A6 /* voice_input_style_mapping_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_style_mapping_test.swift; sourceTree = ""; }; 55CC42AC07A3DC0E467CB0B5 /* voice_input_model_package_installer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_package_installer.swift; sourceTree = ""; }; + 55F48C9449F2D5147FE806AF /* voice_input_model_choice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_choice.swift; sourceTree = ""; }; 5606DA40395C7E4F69D32F4E /* voice_asr_bridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = voice_asr_bridge.h; path = ../../../Sources/voice_asr_bridge/include/voice_asr_bridge.h; sourceTree = ""; }; 5D74E1DEDF0ABA766350BF40 /* voice_input_keychain_store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_keychain_store.swift; sourceTree = ""; }; 659DA20B14ABA097FDD7322B /* voice_input_lifecycle_notification_mapper_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_lifecycle_notification_mapper_test.swift; sourceTree = ""; }; @@ -328,6 +359,7 @@ 76B49DD809855BED87F883A1 /* VoiceFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = VoiceFFI.xcframework; path = ../../../.build/ios_voice_ffi/VoiceFFI.xcframework; sourceTree = ""; }; 7BC7E40456DA743C56A2E637 /* voice_input_history_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_model_test.swift; sourceTree = ""; }; 7C0027EDFC4A041191FBAAF9 /* VoiceASRBridge.framework */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.framework; path = VoiceASRBridge.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7C7D30D754D18735AC99FCB3 /* voice_input_model_choice_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_choice_test.swift; sourceTree = ""; }; 7DFDF7EBFC4891F604797237 /* voice_input_style_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_style_test.swift; sourceTree = ""; }; 7FC7415074EA515761E3F82D /* voice_formatted_document_builder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_formatted_document_builder.swift; path = ../../../Sources/HardwareControllerCore/voice_formatted_document_builder.swift; sourceTree = ""; }; 83300748E2DEF79D228F4A66 /* voice_input_model_library_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_library_model_test.swift; sourceTree = ""; }; @@ -335,6 +367,7 @@ 870E8658F0A5428F729940C7 /* VoiceInputKeyboard.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = VoiceInputKeyboard.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 8710132CC58D01E271258655 /* voice_input_asr_workflow_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_asr_workflow_test.swift; sourceTree = ""; }; 87C14BD07CFF04C81A0DCF4B /* voice_spoken_edit_engine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_spoken_edit_engine.swift; path = ../../../Sources/HardwareControllerCore/voice_spoken_edit_engine.swift; sourceTree = ""; }; + 87D2D1CC00DC195714DF4BF5 /* voice_input_model_downloader_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_downloader_test.swift; sourceTree = ""; }; 8B3C9A5A9EC9E2680D1095E8 /* voice_input_asr_model_registry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_asr_model_registry.swift; sourceTree = ""; }; 8E74E0CA5543420625918CB3 /* voice_formatted_text_renderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_formatted_text_renderer.swift; path = ../../../Sources/HardwareControllerCore/voice_formatted_text_renderer.swift; sourceTree = ""; }; 9024518541EBF4EC74DEF0B0 /* voice_input_shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_shared.swift; sourceTree = ""; }; @@ -346,13 +379,14 @@ 9A8344AF8ACDAB066D7290B6 /* voice_input_lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_lifecycle.swift; sourceTree = ""; }; 9AFEE79BD3448950EB170D03 /* voice_input_root_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_root_view.swift; sourceTree = ""; }; 9B9EDA822613D13F8604F58F /* voice_input_widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_widgets.swift; sourceTree = ""; }; + 9D5E18A8885A9B92F464E9C9 /* asr_model_package_assembler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = asr_model_package_assembler.swift; sourceTree = ""; }; 9E9ADA862D5509C651719B52 /* voice_input_history_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_view.swift; sourceTree = ""; }; - A09F9722E196FE3137A439D7 /* voice_input_theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_theme.swift; sourceTree = ""; }; A32BB963BE2685D5364B725F /* VoiceInput.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = VoiceInput.app; sourceTree = BUILT_PRODUCTS_DIR; }; A657531BE5596A20AB1D1FAE /* voice_input_onboarding_view.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_onboarding_view.swift; sourceTree = ""; }; A7123485BE89DAC67B2C3451 /* voice_input_history_audio_player_model_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_audio_player_model_test.swift; sourceTree = ""; }; A9711C0C615DA66AEBDFC0E8 /* VoiceInputWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = VoiceInputWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; A9B6E93C6B9BE1E48673B3AB /* voice_formatting_draft_normalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_formatting_draft_normalizer.swift; path = ../../../Sources/HardwareControllerCore/voice_formatting_draft_normalizer.swift; sourceTree = ""; }; + ACD75495E3A781A28EAEEEF6 /* voice_input_keyboard_layout_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_keyboard_layout_test.swift; sourceTree = ""; }; ACFCF5B6116626A6EB019379 /* voice_input_document_pipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_document_pipeline.swift; sourceTree = ""; }; B0CE5735FCAEC0C76CA140B3 /* VoiceInputUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VoiceInputUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B28A1A631095C6D76FEDD356 /* voice_input_history_session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_session.swift; sourceTree = ""; }; @@ -365,6 +399,7 @@ C553B7934DAA688A399765CF /* voice_input_model_package_validator_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_package_validator_test.swift; sourceTree = ""; }; C5768072590C38554047CC0F /* voice_input_asr_workflow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_asr_workflow.swift; sourceTree = ""; }; C6EADFB3B5D6639DCD2B7ECF /* voice_input_system_control_reloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_system_control_reloader.swift; sourceTree = ""; }; + C75D0AB54BB57795258F109F /* voice_input_model_downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_model_downloader.swift; sourceTree = ""; }; C842A307B0A0140A0178DEA0 /* voice_input_activity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_activity.swift; sourceTree = ""; }; CA8121E61C52377107D508F8 /* voice_input_session_finalizer_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_session_finalizer_test.swift; sourceTree = ""; }; CC3E8041320574E94F37ABA5 /* voice_input_keyboard_policy_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_keyboard_policy_test.swift; sourceTree = ""; }; @@ -385,9 +420,11 @@ E98488951407A36CC1D61A03 /* voice_input_history_session_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_session_test.swift; sourceTree = ""; }; EA699775DF46AC81E02D6271 /* voice_input_app_model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_app_model.swift; sourceTree = ""; }; EC966D05F9C0C75AA86BE4B0 /* voice_input_history_repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_history_repository.swift; sourceTree = ""; }; + F12B3D8871B70101F8036CE8 /* asr_model_file_fetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = asr_model_file_fetcher.swift; sourceTree = ""; }; F6CFBF1348B03A8F045AB741 /* voice_input_session_finalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_session_finalizer.swift; sourceTree = ""; }; F6D2F5553D90902529BEC4D8 /* starter_asr_model */ = {isa = PBXFileReference; lastKnownFileType = folder; name = starter_asr_model; path = ../../../.build/ios_voice_input/starter_asr_model; sourceTree = SOURCE_ROOT; }; FAF6AB2B01EEBB45EC9487B0 /* voice_input_dictation_presentation_test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = voice_input_dictation_presentation_test.swift; sourceTree = ""; }; + FB5E951ED570B5C58267E86E /* HardwareControllerModelDownload.framework */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.framework; path = HardwareControllerModelDownload.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FF4E6158219BA1EF466A8CDD /* voice_spoken_edit_replayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = voice_spoken_edit_replayer.swift; path = ../../../Sources/HardwareControllerCore/voice_spoken_edit_replayer.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -399,6 +436,7 @@ BD3872A886B0362F2568FC88 /* VoiceInputShared.framework in Frameworks */, EEC2EE8844654D1BE3187E95 /* HardwareControllerCore.framework in Frameworks */, FD8B54810DE4272D541B39A0 /* HardwareControllerVoiceFFI.framework in Frameworks */, + 2DAC7BC233A05C703752C650 /* HardwareControllerModelDownload.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -425,6 +463,7 @@ 962FE71C705DFB150F79845A /* VoiceInputShared.framework in Frameworks */, 20048EFD42C1BB5DB859C5C5 /* HardwareControllerCore.framework in Frameworks */, 50DE4567233BF190641F64DB /* HardwareControllerVoiceFFI.framework in Frameworks */, + FF0694ABA89CEB7E0E5435A1 /* HardwareControllerModelDownload.framework in Frameworks */, E260A9446CD7BB8988EB1B10 /* VoiceASRBridge.framework in Frameworks */, 01D82CFB7ADFF9CD2031B935 /* VoiceFFI.xcframework in Frameworks */, 996CF0A127206234A16E81E1 /* TranscribeCpp.xcframework in Frameworks */, @@ -462,6 +501,7 @@ isa = PBXGroup; children = ( 692A77389CA17AB7CF7F04C4 /* HardwareControllerCore.framework */, + FB5E951ED570B5C58267E86E /* HardwareControllerModelDownload.framework */, 1D6CF255C19A75FF98CAA350 /* HardwareControllerVoiceFFI.framework */, 7C0027EDFC4A041191FBAAF9 /* VoiceASRBridge.framework */, A32BB963BE2685D5364B725F /* VoiceInput.app */, @@ -487,6 +527,7 @@ children = ( BC8E456D0C0E3557DFDD75B7 /* voice_asr_bridge.c */, 5606DA40395C7E4F69D32F4E /* voice_asr_bridge.h */, + CA7C230F02DE40AE4CC6E820 /* hardware_controller_model_download */, C95E68B405D6D290E1E31962 /* hardware_controller_voice_ffi */, ); name = portable; @@ -524,6 +565,7 @@ 3AC71BC68D1A1B8B71EDD8A8 /* voice_input_environment.swift */, 094BDC550872DC0563812EE4 /* voice_input_host_field.swift */, 2E55D9A1E5484487CECB7774 /* voice_input_insertion_recovery.swift */, + 1598CF1B78A01FAFA125DFA5 /* voice_input_keyboard_layout.swift */, 5D74E1DEDF0ABA766350BF40 /* voice_input_keychain_store.swift */, 11E594DE72AD43B775B93413 /* voice_input_local_copy.swift */, 442CB63700ABD3B2397BE1F5 /* voice_input_onboarding.swift */, @@ -532,6 +574,7 @@ B2F4842EA73A1B5F266FBEC7 /* voice_input_style_preference.swift */, 0F5730264B057CBE4C0B33BB /* voice_input_style.swift */, 91E71B1E4A8DBE0DB21EEF8E /* voice_input_system_capture.swift */, + 1315D4A6CFC9791286BDB555 /* voice_input_theme.swift */, ); path = shared; sourceTree = ""; @@ -539,6 +582,7 @@ 74E58174B1B6693A3967EF5A /* keyboard */ = { isa = PBXGroup; children = ( + 53CE4AAD6B9819DB6E341139 /* keyboard_keys_view.swift */, 02DE7C7AF8A1766381BEF3F4 /* keyboard_view_controller.swift */, ); path = keyboard; @@ -587,6 +631,8 @@ 9A8344AF8ACDAB066D7290B6 /* voice_input_lifecycle.swift */, 91AD0DD823EE4AD0F2966C32 /* voice_input_live_audio_tap.swift */, 2FA1E80BDAEC9549CBFAAB49 /* voice_input_local_transcriber.swift */, + 55F48C9449F2D5147FE806AF /* voice_input_model_choice.swift */, + C75D0AB54BB57795258F109F /* voice_input_model_downloader.swift */, 2761F315707E81BC58E239D8 /* voice_input_model_library_model.swift */, 4C4D086A77C159F47E36FA8B /* voice_input_model_library_view.swift */, 55CC42AC07A3DC0E467CB0B5 /* voice_input_model_package_installer.swift */, @@ -598,7 +644,6 @@ B42CE3568045B6705385AA62 /* voice_input_starter_model.swift */, CFEFF4EE6C988820D5DBF4AB /* voice_input_style_mapping.swift */, C6EADFB3B5D6639DCD2B7ECF /* voice_input_system_control_reloader.swift */, - A09F9722E196FE3137A439D7 /* voice_input_theme.swift */, 2CB986919BBFA21E33388B38 /* voice_input_ui_test_hooks.swift */, ); path = app; @@ -632,6 +677,17 @@ path = ../../../Sources/hardware_controller_voice_ffi; sourceTree = ""; }; + CA7C230F02DE40AE4CC6E820 /* hardware_controller_model_download */ = { + isa = PBXGroup; + children = ( + 2404E197C794C26AAE6AE5D9 /* asr_model_catalog.swift */, + F12B3D8871B70101F8036CE8 /* asr_model_file_fetcher.swift */, + 9D5E18A8885A9B92F464E9C9 /* asr_model_package_assembler.swift */, + ); + name = hardware_controller_model_download; + path = ../../../Sources/hardware_controller_model_download; + sourceTree = ""; + }; DAC190110174095D0BAF2536 /* portable_voice_core */ = { isa = PBXGroup; children = ( @@ -685,12 +741,15 @@ E98488951407A36CC1D61A03 /* voice_input_history_session_test.swift */, E59D05A6E89992CBAB7EB9E0 /* voice_input_host_field_policy_test.swift */, B428772494717A951306D51A /* voice_input_insertion_recovery_policy_test.swift */, + ACD75495E3A781A28EAEEEF6 /* voice_input_keyboard_layout_test.swift */, CC3E8041320574E94F37ABA5 /* voice_input_keyboard_policy_test.swift */, 11013B8B033E9437866234F1 /* voice_input_keychain_store_test.swift */, 659DA20B14ABA097FDD7322B /* voice_input_lifecycle_notification_mapper_test.swift */, E73827118DA96919FED1F547 /* voice_input_lifecycle_policy_test.swift */, 6E80C3A99EB8CBD76D72CA99 /* voice_input_live_audio_tap_test.swift */, 23BB17096DE2EC193AB541C9 /* voice_input_local_transcriber_test.swift */, + 7C7D30D754D18735AC99FCB3 /* voice_input_model_choice_test.swift */, + 87D2D1CC00DC195714DF4BF5 /* voice_input_model_downloader_test.swift */, 83300748E2DEF79D228F4A66 /* voice_input_model_library_model_test.swift */, 6A193C9D9D6CEADAB85C8ED1 /* voice_input_model_package_fixture.swift */, 0289EFF8A21EF431945174AF /* voice_input_model_package_installer_test.swift */, @@ -755,6 +814,7 @@ 1EEEBFA274F84522325BEF6B /* PBXTargetDependency */, 0AAF9D37077723C66345877D /* PBXTargetDependency */, 2B0A0C052E4324015CF1C958 /* PBXTargetDependency */, + 367BF6FE7AFFC821B3347432 /* PBXTargetDependency */, 3159BDA2A9C453AB78BEB87C /* PBXTargetDependency */, C631DC67D7774463642097CF /* PBXTargetDependency */, CB85289A82023FAA7A4F3B08 /* PBXTargetDependency */, @@ -766,6 +826,23 @@ productReference = A32BB963BE2685D5364B725F /* VoiceInput.app */; productType = "com.apple.product-type.application"; }; + 7C949B6C8A224AEFD7A4ACD1 /* HardwareControllerModelDownload */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCBBFBC5D1053A6AF5BBBADA /* Build configuration list for PBXNativeTarget "HardwareControllerModelDownload" */; + buildPhases = ( + 252E8201242D366D05076674 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = HardwareControllerModelDownload; + packageProductDependencies = ( + ); + productName = HardwareControllerModelDownload; + productReference = FB5E951ED570B5C58267E86E /* HardwareControllerModelDownload.framework */; + productType = "com.apple.product-type.framework.static"; + }; 801B983378BF2B89B7538947 /* VoiceInputTests */ = { isa = PBXNativeTarget; buildConfigurationList = ECEB7C642EA28897786873AA /* Build configuration list for PBXNativeTarget "VoiceInputTests" */; @@ -782,6 +859,7 @@ B13824861AAE51AD2F738B42 /* PBXTargetDependency */, 16C439ECE1ABCCF4E9409251 /* PBXTargetDependency */, BCCE16BA1D957A92D488EAB1 /* PBXTargetDependency */, + A295F8B1AA73008C7C7F26D0 /* PBXTargetDependency */, ); name = VoiceInputTests; packageProductDependencies = ( @@ -933,6 +1011,7 @@ C589E50707AA3D8F3E0F9D93 /* VoiceInputShared */, 053FA83E762BA74C46F67809 /* HardwareControllerCore */, EA13D8260D3CDFFA8D3331DE /* HardwareControllerVoiceFFI */, + 7C949B6C8A224AEFD7A4ACD1 /* HardwareControllerModelDownload */, DD067EEC4C535897C7152DAB /* VoiceASRBridge */, F4EC3DB9B4E3A533DB5C14CC /* VoiceInputKeyboard */, C7357476174EC37DEFF7D558 /* VoiceInputWidgets */, @@ -964,6 +1043,16 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 252E8201242D366D05076674 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 626507302A5E638C6AA32D76 /* asr_model_catalog.swift in Sources */, + A8A8184598B4A8E3FCDCE64D /* asr_model_file_fetcher.swift in Sources */, + AA9A1B822D4018FA1D142B5A /* asr_model_package_assembler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 39C1E73EA8CBFC0FBDBDD520 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -991,12 +1080,15 @@ 56B6483B72465C0381294FD6 /* voice_input_history_session_test.swift in Sources */, F6C56F90353A839C7C830400 /* voice_input_host_field_policy_test.swift in Sources */, 69B7D66F8B4F0C8FE52555C5 /* voice_input_insertion_recovery_policy_test.swift in Sources */, + 50D97C701384B50B636B65F4 /* voice_input_keyboard_layout_test.swift in Sources */, 5C0AD0D1EBDEF1C0A7627516 /* voice_input_keyboard_policy_test.swift in Sources */, E2F2A4BC4EF4B857702A04F9 /* voice_input_keychain_store_test.swift in Sources */, C88188E79A53C59A17188BA0 /* voice_input_lifecycle_notification_mapper_test.swift in Sources */, CC64B99825542AD17E27CE9E /* voice_input_lifecycle_policy_test.swift in Sources */, 453F44F50EEAF923A612DC44 /* voice_input_live_audio_tap_test.swift in Sources */, 74AC5E1B2441F225F877BF44 /* voice_input_local_transcriber_test.swift in Sources */, + 3C7CDCE4B8DE54F0CCB400E4 /* voice_input_model_choice_test.swift in Sources */, + 88216DC0EF9B06AA980CFE97 /* voice_input_model_downloader_test.swift in Sources */, 601A8A59075842D2B2D336B0 /* voice_input_model_library_model_test.swift in Sources */, 56BD640ECB67FA724E133982 /* voice_input_model_package_fixture.swift in Sources */, B5DAF11C777DBBB15C26A056 /* voice_input_model_package_installer_test.swift in Sources */, @@ -1030,6 +1122,7 @@ CFE52B091FD3A71C14E34A97 /* voice_input_environment.swift in Sources */, E32EF7A11D5F7BBDEF95C11D /* voice_input_host_field.swift in Sources */, 0689EC2180798B82F344AA66 /* voice_input_insertion_recovery.swift in Sources */, + D097C2AEB25F587F16EE45D1 /* voice_input_keyboard_layout.swift in Sources */, 727ECEAB4825A9602485A6E9 /* voice_input_keychain_store.swift in Sources */, 6987C49422E0CF3395F217AF /* voice_input_local_copy.swift in Sources */, 68B9D04F5D4D77708DA1ACD4 /* voice_input_onboarding.swift in Sources */, @@ -1038,6 +1131,7 @@ 39B5ED77194467F901FBD4FA /* voice_input_style.swift in Sources */, 997A36CA88066AC910D3E93A /* voice_input_style_preference.swift in Sources */, ED030D03B1A4E4246F8CFE50 /* voice_input_system_capture.swift in Sources */, + 6761CEC7FBFF23DCC353F2D2 /* voice_input_theme.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1092,6 +1186,8 @@ 7EC357F014207286C47ECAA4 /* voice_input_lifecycle_notification_mapper.swift in Sources */, FC6171908D53F02641CFC69A /* voice_input_live_audio_tap.swift in Sources */, 9FAAE9B6C46841D53A29847E /* voice_input_local_transcriber.swift in Sources */, + F5979822F759DD034F97F045 /* voice_input_model_choice.swift in Sources */, + FB00E14F390B0DFF8BCDACB4 /* voice_input_model_downloader.swift in Sources */, 80912E4129B0B3D4AA16150B /* voice_input_model_library_model.swift in Sources */, F0B221E9C8D6539465ACB680 /* voice_input_model_library_view.swift in Sources */, FEC66FC78A64BD691A1F8309 /* voice_input_model_package_installer.swift in Sources */, @@ -1104,7 +1200,6 @@ 0A3E522F44E124926B436BEB /* voice_input_style_mapping.swift in Sources */, BAFB5F8313ED06A20812FC98 /* voice_input_system_capture_intents.swift in Sources */, C9F9DCC491990F05F058BEA8 /* voice_input_system_control_reloader.swift in Sources */, - AE365A316BD3AFEFBBDD75FE /* voice_input_theme.swift in Sources */, CDC6C2A0560AFDF985DE21A9 /* voice_input_ui_test_hooks.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1122,6 +1217,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C071448C5633A8C8479E2207 /* keyboard_keys_view.swift in Sources */, 7FACC86CD0EA5EFB6B53F922 /* keyboard_view_controller.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1159,6 +1255,11 @@ target = DD067EEC4C535897C7152DAB /* VoiceASRBridge */; targetProxy = 4C126D30C4384590280AA091 /* PBXContainerItemProxy */; }; + 367BF6FE7AFFC821B3347432 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7C949B6C8A224AEFD7A4ACD1 /* HardwareControllerModelDownload */; + targetProxy = 712E009225B689EFCAC32C4A /* PBXContainerItemProxy */; + }; 523328CCAB3AF4086687A8B8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = C589E50707AA3D8F3E0F9D93 /* VoiceInputShared */; @@ -1174,6 +1275,11 @@ target = C589E50707AA3D8F3E0F9D93 /* VoiceInputShared */; targetProxy = 8FD39496FF8746277B72D735 /* PBXContainerItemProxy */; }; + A295F8B1AA73008C7C7F26D0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7C949B6C8A224AEFD7A4ACD1 /* HardwareControllerModelDownload */; + targetProxy = F13F710DC03E4B11243621EA /* PBXContainerItemProxy */; + }; B13824861AAE51AD2F738B42 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 48ACF5FA6ECBC71A34114E9A /* VoiceInput */; @@ -1460,6 +1566,29 @@ }; name = Release; }; + 69E423037C48B94265A98D68 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.longdevity.hardwarecontroller.voiceinput.modeldownload; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; 883E617DABF594507BF91DAD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1562,6 +1691,29 @@ }; name = Release; }; + C8DC9339BD9BE2CD1AE1398E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.longdevity.hardwarecontroller.voiceinput.modeldownload; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; CFBDE1424F2221CE5DE037B9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1772,6 +1924,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + CCBBFBC5D1053A6AF5BBBADA /* Build configuration list for PBXNativeTarget "HardwareControllerModelDownload" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8DC9339BD9BE2CD1AE1398E /* Debug */, + 69E423037C48B94265A98D68 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; D8CE5F8A8D538D38977FB4F5 /* Build configuration list for PBXNativeTarget "VoiceASRBridge" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/ios/voice_input/app/voice_input_app.swift b/apps/ios/voice_input/app/voice_input_app.swift index f0c391d..f9db0e5 100644 --- a/apps/ios/voice_input/app/voice_input_app.swift +++ b/apps/ios/voice_input/app/voice_input_app.swift @@ -126,7 +126,11 @@ struct VoiceInputApp: App { wrappedValue: VoiceInputModelLibraryModel( manager: registry, asrWorkflow: asrWorkflow, - starter: VoiceInputStarterModel() + starter: VoiceInputStarterModel(), + downloader: VoiceInputModelDownloader( + stagingRoot: modelRoot.appendingPathComponent("downloads", isDirectory: true), + manager: registry + ) ) ) _history = StateObject( diff --git a/apps/ios/voice_input/app/voice_input_dictate_view.swift b/apps/ios/voice_input/app/voice_input_dictate_view.swift index eae796b..1c8de84 100644 --- a/apps/ios/voice_input/app/voice_input_dictate_view.swift +++ b/apps/ios/voice_input/app/voice_input_dictate_view.swift @@ -179,7 +179,8 @@ private struct VoiceInputRecordButton: View { .foregroundStyle(presentation.isActive ? Color.black : Color(.systemBackground)) .frame(width: 88, height: 88) .background( - Circle().fill(presentation.isActive ? VoiceInputTheme.signal : Color.primary) + Circle().fill( + presentation.isActive ? Color(uiColor: VoiceInputTheme.signal) : Color.primary) ) .overlay { if contrast == .increased { diff --git a/apps/ios/voice_input/app/voice_input_history_model.swift b/apps/ios/voice_input/app/voice_input_history_model.swift index c961b79..9cd86de 100644 --- a/apps/ios/voice_input/app/voice_input_history_model.swift +++ b/apps/ios/voice_input/app/voice_input_history_model.swift @@ -18,6 +18,8 @@ protocol VoiceInputHistoryAccessing: Sendable { isPinned: Bool ) async throws -> VoiceInputHistorySession func retentionMaintenanceMessage() async -> String? + func delete(sessionID: UUID) async throws + func deleteAll() async throws } extension VoiceInputHistoryRepository: VoiceInputHistoryAccessing {} @@ -85,6 +87,33 @@ final class VoiceInputHistoryModel: ObservableObject { } } + func delete(sessionID: UUID) async { + guard let history else { + return + } + do { + try await history.delete(sessionID: sessionID) + sessions.removeAll { $0.id == sessionID } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + func deleteAll() async { + guard let history else { + return + } + do { + try await history.deleteAll() + sessions = [] + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + await load(searching: !query.isEmpty) + } + } + func updateRetentionSettings( _ settings: VoiceHistoryRetentionSettings ) async { diff --git a/apps/ios/voice_input/app/voice_input_history_repository.swift b/apps/ios/voice_input/app/voice_input_history_repository.swift index e9a66b9..aaf00b9 100644 --- a/apps/ios/voice_input/app/voice_input_history_repository.swift +++ b/apps/ios/voice_input/app/voice_input_history_repository.swift @@ -300,6 +300,32 @@ actor VoiceInputHistoryRepository { return updated } + /// Deletes one session and its audio. Audio goes first: an orphaned `.caf` + /// would be adopted as recovery audio, while a row whose audio is already + /// gone simply retries cleanly. + func delete(sessionID: UUID) throws { + try reconcileIfNeeded() + try requireOpen() + guard let existing = try storedSession(id: sessionID) else { + throw VoiceInputHistoryError.invalidSession + } + try removeAudio(of: existing) + try deleteRows(sql: "DELETE FROM voice_input_history WHERE id = ?1;") { statement in + try Self.bind(sessionID.uuidString, at: 1, in: statement) + } + } + + /// Deletes every session and its audio. An in-progress capture's `.partial` + /// file is not a session, so recording continues unaffected. + func deleteAll() throws { + try reconcileIfNeeded() + try requireOpen() + for session in try allSessions() { + try removeAudio(of: session) + } + try deleteRows(sql: "DELETE FROM voice_input_history;") { _ in } + } + func retentionMaintenanceMessage() -> String? { maintenanceMessage } @@ -422,6 +448,38 @@ actor VoiceInputHistoryRepository { } } + private func removeAudio(of session: VoiceInputHistorySession) throws { + guard + let artifact = session.audioArtifact, + FileManager.default.fileExists(atPath: artifact.url.path) + else { + return + } + do { + try removeRetainedAudio(artifact.url) + } catch { + throw VoiceInputHistoryError.storageUnavailable + } + } + + private func deleteRows( + sql: String, + bind: (OpaquePointer) throws -> Void + ) throws { + var statement: OpaquePointer? + guard + sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, + let statement + else { + throw VoiceInputHistoryError.storageUnavailable + } + defer { sqlite3_finalize(statement) } + try bind(statement) + guard sqlite3_step(statement) == SQLITE_DONE else { + throw VoiceInputHistoryError.storageUnavailable + } + } + private func applyRetention(now: Date) throws -> VoiceHistoryRetentionPlan { let retained = try allSessions().compactMap { session -> VoiceHistoryRetentionCandidate? in guard let artifact = session.audioArtifact else { diff --git a/apps/ios/voice_input/app/voice_input_history_view.swift b/apps/ios/voice_input/app/voice_input_history_view.swift index 3b3b3ad..f4fc07a 100644 --- a/apps/ios/voice_input/app/voice_input_history_view.swift +++ b/apps/ios/voice_input/app/voice_input_history_view.swift @@ -5,6 +5,7 @@ import VoiceInputShared struct VoiceInputHistoryView: View { @ObservedObject var model: VoiceInputHistoryModel @ObservedObject var audioPlayer: VoiceInputHistoryAudioPlayerModel + @State private var isConfirmingClearAll = false var body: some View { NavigationStack { @@ -21,11 +22,18 @@ struct VoiceInputHistoryView: View { audioPlayer: audioPlayer, setPinned: { isPinned in Task { await model.setPinned(sessionID: session.id, isPinned: isPinned) } - } + }, + delete: { await delete(session) } ) } label: { VoiceInputHistoryRow(session: session) } + .swipeActions { + Button("Delete", systemImage: "trash", role: .destructive) { + Task { await delete(session) } + } + .accessibilityIdentifier("history_delete_row") + } } } .listStyle(.plain) @@ -50,9 +58,45 @@ struct VoiceInputHistoryView: View { if query.isEmpty { Task { await model.refresh() } } } .refreshable { await model.refresh() } - .accessibilityIdentifier("voice_history") + .toolbar { + if !model.sessions.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("Clear All History…", systemImage: "trash", role: .destructive) { + isConfirmingClearAll = true + } + .accessibilityIdentifier("history_clear_all") + } label: { + Label("More", systemImage: "ellipsis") + } + .accessibilityIdentifier("history_menu") + } + } + } + .confirmationDialog( + "Delete every dictation and its audio?", + isPresented: $isConfirmingClearAll, + titleVisibility: .visible + ) { + Button("Clear All History", role: .destructive) { + Task { + audioPlayer.stop() + await model.deleteAll() + } + } + .accessibilityIdentifier("history_confirm_clear_all") + } message: { + Text("This can't be undone.") + } } } + + private func delete(_ session: VoiceInputHistorySession) async { + if audioPlayer.playingSessionID == session.id { + audioPlayer.stop() + } + await model.delete(sessionID: session.id) + } } private struct VoiceInputHistoryRow: View { @@ -86,7 +130,10 @@ private struct VoiceInputHistoryDetailView: View { let session: VoiceInputHistorySession @ObservedObject var audioPlayer: VoiceInputHistoryAudioPlayerModel let setPinned: (Bool) -> Void + let delete: () async -> Void @State private var copyMessage: String? + @State private var isConfirmingDelete = false + @Environment(\.dismiss) private var dismiss private let localClipboard = VoiceInputSystemLocalClipboard() var body: some View { @@ -167,10 +214,30 @@ private struct VoiceInputHistoryDetailView: View { "\(session.style.kind.displayName) · \(session.modelPackageID) \(session.modelVersion)") } } + + Section { + Button("Delete Dictation", role: .destructive) { + isConfirmingDelete = true + } + .accessibilityIdentifier("history_delete") + } } .navigationTitle(Text(session.endedAt, format: .dateTime.month().day().hour().minute())) .navigationBarTitleDisplayMode(.inline) .onDisappear { audioPlayer.stop() } + .confirmationDialog( + "Delete this dictation and its audio?", + isPresented: $isConfirmingDelete, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + Task { + await delete() + dismiss() + } + } + .accessibilityIdentifier("history_confirm_delete") + } } private var isPlaying: Bool { diff --git a/apps/ios/voice_input/app/voice_input_model_choice.swift b/apps/ios/voice_input/app/voice_input_model_choice.swift new file mode 100644 index 0000000..c0cb838 --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_model_choice.swift @@ -0,0 +1,83 @@ +import Foundation +import HardwareControllerModelDownload + +/// One row of the Speech model list: the built-in model, each download the +/// catalog offers, then anything imported. Pure, so every state is tested. +struct VoiceInputModelChoice: Equatable, Identifiable, Sendable { + enum State: Equatable, Sendable { + case active + case installed(VoiceInputInstalledModelPackage) + case downloading(ASRModelCatalogEntry, fraction: Double) + case available(ASRModelCatalogEntry) + } + + let id: String + let title: String + let summary: String + let bytes: Int64 + let state: State + /// The installed copy, which the user may remove. + let installed: VoiceInputInstalledModelPackage? + + static func choices( + installed: [VoiceInputInstalledModelPackage], + active: VoiceInputInstalledModelPackage?, + catalog: [ASRModelCatalogEntry], + downloads: [String: Double] + ) -> [Self] { + let compatible = installed.filter { VoiceInputASRModelRegistry.isCompatible($0.package) } + var rows: [Self] = [] + var listed = Set() + + func row(for package: VoiceInputInstalledModelPackage) -> Self { + listed.insert(package.package.packageID) + return Self( + id: package.package.packageID, + title: package.package.displayName, + summary: summary( + for: package.package.packageID, publisherVerified: package.publisherVerified), + bytes: Int64(clamping: package.package.verifiedBytes), + state: package == active ? .active : .installed(package), + installed: package + ) + } + + if let builtIn = compatible.first(where: { $0.package.packageID == builtInPackageID }) { + rows.append(row(for: builtIn)) + } + for entry in catalog { + if let package = compatible.first(where: { $0.package.packageID == entry.packageID }) { + rows.append(row(for: package)) + continue + } + listed.insert(entry.packageID) + rows.append( + Self( + id: entry.packageID, + title: entry.displayName, + summary: summary(for: entry.packageID, publisherVerified: true), + bytes: entry.downloadBytes, + state: downloads[entry.packageID].map { .downloading(entry, fraction: $0) } + ?? .available(entry), + installed: nil + ) + ) + } + for package in compatible where !listed.contains(package.package.packageID) { + rows.append(row(for: package)) + } + return rows + } + + static let builtInPackageID = "com.longdevity.asr.whisper_tiny_en" + + private static func summary(for packageID: String, publisherVerified: Bool) -> String { + switch packageID { + case builtInPackageID: "Built in · fastest · English" + case "com.longdevity.asr.whisper_base_en": "Faster than Small, more accurate than Tiny" + case "com.longdevity.asr.whisper_small_en": "More accurate · English" + case "com.longdevity.asr.parakeet_tdt_v3": "Most accurate · 25 languages · same as the Mac" + default: publisherVerified ? "Verified" : "Imported from Files" + } + } +} diff --git a/apps/ios/voice_input/app/voice_input_model_downloader.swift b/apps/ios/voice_input/app/voice_input_model_downloader.swift new file mode 100644 index 0000000..f14dada --- /dev/null +++ b/apps/ios/voice_input/app/voice_input_model_downloader.swift @@ -0,0 +1,58 @@ +import Foundation +import HardwareControllerModelDownload + +/// Downloads one pinned catalog package into private staging, then admits it +/// through the normal Rust-validated installer as publisher verified. +struct VoiceInputModelDownloader: Sendable { + let stagingRoot: URL + let manager: any VoiceInputModelManaging + let fetcher: any ASRModelFileFetching + + init( + stagingRoot: URL, + manager: any VoiceInputModelManaging, + fetcher: any ASRModelFileFetching = URLSessionModelFileFetcher() + ) { + self.stagingRoot = stagingRoot + self.manager = manager + self.fetcher = fetcher + } + + func download( + _ entry: ASRModelCatalogEntry, + progress: @escaping @Sendable (ASRModelDownloadProgress) -> Void + ) async throws -> VoiceInputInstalledModelPackage { + let staging = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: staging) } + // Partial model bytes are never worth backing up. + var excluded = stagingRoot + var values = URLResourceValues() + values.isExcludedFromBackup = true + try excluded.setResourceValues(values) + let manifestSHA256 = try await ASRModelPackageAssembler(entry: entry, fetcher: fetcher) + .assemble(in: staging, progress: progress) + return try await manager.install(from: staging, expectedManifestSHA256: manifestSHA256) + } +} + +extension VoiceInputModelDownloader { + /// User-facing text; the shared error type stays free of iOS copy. + static func message(for error: any Error) -> String { + guard let error = error as? ASRModelDownloadError else { + return error.localizedDescription + } + switch error { + case .catalogUnavailable: + return "This model is unavailable in this build." + case .transferFailed(let reason): + return "The download stopped. \(reason) Check the connection and try again." + case .integrityMismatch(let file): + return "\(file) did not match its pinned checksum, so nothing was installed." + case .installFailed(let reason): + return "The model could not be installed. \(reason)" + case .cancelled: + return "The download was cancelled." + } + } +} diff --git a/apps/ios/voice_input/app/voice_input_model_library_model.swift b/apps/ios/voice_input/app/voice_input_model_library_model.swift index 589508d..7573ed6 100644 --- a/apps/ios/voice_input/app/voice_input_model_library_model.swift +++ b/apps/ios/voice_input/app/voice_input_model_library_model.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import HardwareControllerModelDownload protocol VoiceInputModelPackageInstalling: Sendable { func install( @@ -21,11 +22,16 @@ final class VoiceInputModelLibraryModel: ObservableObject { @Published private(set) var isImporting = false @Published private(set) var isRemoving = false @Published private(set) var isPreparingStarter = false + /// Download progress from 0 to 1, keyed by catalog package ID. + @Published private(set) var downloads: [String: Double] = [:] @Published private(set) var errorMessage: String? + let catalog: [ASRModelCatalogEntry] private let manager: any VoiceInputModelManaging private let asrWorkflow: VoiceInputASRWorkflow? private let starter: VoiceInputStarterModel? + private let downloader: VoiceInputModelDownloader? + private var downloadTasks: [String: Task] = [:] /// Provisioning runs once per launch, so removing the starter is not undone /// until the next launch finds the library without a speech model. private var starterProvisioningAttempted = false @@ -59,11 +65,86 @@ final class VoiceInputModelLibraryModel: ObservableObject { init( manager: any VoiceInputModelManaging, asrWorkflow: VoiceInputASRWorkflow? = nil, - starter: VoiceInputStarterModel? = nil + starter: VoiceInputStarterModel? = nil, + downloader: VoiceInputModelDownloader? = nil, + catalog: [ASRModelCatalogEntry] = ASRModelCatalog.voiceInputDownloads ) { self.manager = manager self.asrWorkflow = asrWorkflow self.starter = starter + self.downloader = downloader + self.catalog = downloader == nil ? [] : catalog + } + + var choices: [VoiceInputModelChoice] { + VoiceInputModelChoice.choices( + installed: packages, + active: activeASRModel, + catalog: catalog, + downloads: downloads + ) + } + + /// Downloads, verifies, installs, and then activates one catalog package, + /// as on the Mac: the user asked for it, so it becomes the speech model. + func download(_ entry: ASRModelCatalogEntry) { + guard let downloader, downloads[entry.packageID] == nil else { + return + } + downloads[entry.packageID] = 0 + errorMessage = nil + // Progress arrives on the download's threads; a stream keeps every + // published change on the main actor. + let (progress, reporter) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + downloadTasks[entry.packageID] = Task { [weak self] in + let updates = Task { [weak self] in + for await fraction in progress { + self?.showProgress(fraction, for: entry.packageID) + } + } + defer { updates.cancel() } + do { + let installed = try await downloader.download(entry) { reporter.yield($0.fraction) } + reporter.finish() + await self?.finishDownload(entry.packageID, installed: installed) + } catch { + reporter.finish() + self?.failDownload(entry.packageID, error: error) + } + } + } + + func cancelDownload(_ entry: ASRModelCatalogEntry) { + downloadTasks.removeValue(forKey: entry.packageID)?.cancel() + downloads[entry.packageID] = nil + } + + private func showProgress(_ fraction: Double, for packageID: String) { + guard downloads[packageID] != nil else { + return + } + downloads[packageID] = fraction + } + + private func finishDownload( + _ packageID: String, + installed: VoiceInputInstalledModelPackage + ) async { + downloadTasks[packageID] = nil + downloads[packageID] = nil + await refresh() + await selectASRModel(installed) + } + + private func failDownload(_ packageID: String, error: any Error) { + downloadTasks[packageID] = nil + let wasCancelled = downloads[packageID] == nil || Task.isCancelled + downloads[packageID] = nil + if !wasCancelled, (error as? ASRModelDownloadError) != .cancelled { + errorMessage = VoiceInputModelDownloader.message(for: error) + } } func refresh() async { diff --git a/apps/ios/voice_input/app/voice_input_model_library_view.swift b/apps/ios/voice_input/app/voice_input_model_library_view.swift index 15c15ee..038f998 100644 --- a/apps/ios/voice_input/app/voice_input_model_library_view.swift +++ b/apps/ios/voice_input/app/voice_input_model_library_view.swift @@ -1,4 +1,4 @@ -import HardwareControllerVoiceFFI +import HardwareControllerModelDownload import SwiftUI import UniformTypeIdentifiers @@ -14,20 +14,21 @@ struct VoiceInputModelLibrarySection: View { if model.isPreparingStarter { LabeledContent("Installing built-in model") { ProgressView() } } - ForEach(model.packages, id: \.rootURL) { installed in - packageRow(installed) - } - Button { - isImporterPresented = true - } label: { - Label( - model.isImporting ? "Importing…" : "Import Model package…", - systemImage: "square.and.arrow.down" + ForEach(model.choices) { choice in + VoiceInputModelChoiceRow( + choice: choice, + isBusy: model.isImporting || model.isRemoving, + select: { installed in Task { await model.selectASRModel(installed) } }, + download: model.download, + cancel: model.cancelDownload ) + .swipeActions { + if let installed = choice.installed { + Button("Remove", role: .destructive) { pendingRemoval = installed } + .accessibilityIdentifier("remove_model_package") + } + } } - .disabled(model.isImporting || model.isRemoving) - .accessibilityIdentifier("import_model_package") - if let errorMessage = model.errorMessage ?? pickerErrorMessage { Text(errorMessage) .foregroundStyle(.red) @@ -37,7 +38,9 @@ struct VoiceInputModelLibrarySection: View { Text("Speech model") .accessibilityIdentifier("local_model_library") } footer: { - Text("Models are verified and stay in this app's private storage.") + Text( + "Speech never leaves this iPhone. Downloads come from pinned Hugging Face files, are checked against fixed checksums, and need no account." + ) } .fileImporter( isPresented: $isImporterPresented, @@ -50,7 +53,7 @@ struct VoiceInputModelLibrarySection: View { isPresented: removalConfirmationIsPresented, titleVisibility: .visible ) { - Button("Remove installed copy", role: .destructive) { + Button("Remove", role: .destructive) { guard let installed = pendingRemoval else { return } @@ -59,57 +62,25 @@ struct VoiceInputModelLibrarySection: View { } Button("Cancel", role: .cancel) { pendingRemoval = nil } } message: { - Text("The original imported folder is not changed.") + Text("You can download it again later.") } - } - private func packageRow(_ installed: VoiceInputInstalledModelPackage) -> some View { - let isActive = model.isActiveASRModel(installed) - let canSelect = VoiceInputASRModelRegistry.isCompatible(installed.package) && !isActive - return Button { - guard canSelect else { - return - } - Task { await model.selectASRModel(installed) } - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(installed.package.displayName) - .foregroundStyle(.primary) - Text(packageSummary(installed)) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if isActive { - Image(systemName: "checkmark") - .accessibilityLabel("Active") - .accessibilityIdentifier("active_asr_model") - } + Section { + Button { + isImporterPresented = true + } label: { + Label( + model.isImporting ? "Importing…" : "Import from Files…", + systemImage: "folder" + ) } + .disabled(model.isImporting || model.isRemoving) + .accessibilityIdentifier("import_model_package") + } footer: { + Text( + "For a Model package folder made on a Mac with scripts/prepare_ios_asr_model_package.sh and copied into Files." + ) } - .disabled(model.isImporting || model.isRemoving) - .accessibilityHint(canSelect ? "Use for speech to text" : "") - .accessibilityIdentifier(canSelect ? "select_asr_model" : "model_package_card") - .swipeActions { - Button("Remove", role: .destructive) { pendingRemoval = installed } - .accessibilityIdentifier("remove_model_package") - } - .contextMenu { - Button("Remove", systemImage: "trash", role: .destructive) { pendingRemoval = installed } - } - } - - private func packageSummary(_ installed: VoiceInputInstalledModelPackage) -> String { - let package = installed.package - let languages = - package.languages.isEmpty ? "No declared language" : package.languages.joined(separator: ", ") - let bytes = ByteCountFormatter.string( - fromByteCount: Int64(clamping: package.verifiedBytes), - countStyle: .file - ) - let provenance = installed.publisherVerified ? "Verified" : "Manual import" - return "\(languages) · \(bytes) · \(provenance)" } private func handleSelection(_ result: Result<[URL], Error>) { @@ -137,3 +108,81 @@ struct VoiceInputModelLibrarySection: View { ) } } + +private struct VoiceInputModelChoiceRow: View { + let choice: VoiceInputModelChoice + let isBusy: Bool + let select: (VoiceInputInstalledModelPackage) -> Void + let download: (ASRModelCatalogEntry) -> Void + let cancel: (ASRModelCatalogEntry) -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(choice.title) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + accessory + } + .contentShape(Rectangle()) + .onTapGesture { + if case .installed(let installed) = choice.state, !isBusy { + select(installed) + } + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelectable ? .isButton : []) + .accessibilityHint(isSelectable ? "Use for speech to text" : "") + .accessibilityIdentifier("model_choice_\(choice.id)") + } + + private var isSelectable: Bool { + if case .installed = choice.state { return true } + return false + } + + private var subtitle: String { + let size = ByteCountFormatter.string(fromByteCount: choice.bytes, countStyle: .file) + return "\(choice.summary) · \(size)" + } + + @ViewBuilder + private var accessory: some View { + switch choice.state { + case .active: + Image(systemName: "checkmark") + .fontWeight(.semibold) + .accessibilityLabel("Active") + .accessibilityIdentifier("active_asr_model") + case .installed: + Text("Use") + .foregroundStyle(.secondary) + case .downloading(let entry, let fraction): + HStack(spacing: 10) { + ProgressView(value: fraction) + .progressViewStyle(.circular) + .accessibilityLabel("Downloading") + .accessibilityValue("\(Int(fraction * 100)) percent") + Button { + cancel(entry) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .accessibilityLabel("Cancel download") + .accessibilityIdentifier("cancel_model_download") + } + case .available(let entry): + Button("Download") { download(entry) } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.small) + .disabled(isBusy) + .accessibilityIdentifier("download_model_\(entry.packageID)") + } + } +} diff --git a/apps/ios/voice_input/app/voice_input_theme.swift b/apps/ios/voice_input/app/voice_input_theme.swift deleted file mode 100644 index fbfae2e..0000000 --- a/apps/ios/voice_input/app/voice_input_theme.swift +++ /dev/null @@ -1,7 +0,0 @@ -import SwiftUI - -/// Voice Input inherits the repository style: neutral surfaces, system type, -/// and the icon's signal amber reserved for an active recording. -enum VoiceInputTheme { - static let signal = Color(red: 0.98, green: 0.69, blue: 0.13) -} diff --git a/apps/ios/voice_input/keyboard/keyboard_keys_view.swift b/apps/ios/voice_input/keyboard/keyboard_keys_view.swift new file mode 100644 index 0000000..c5e2e9e --- /dev/null +++ b/apps/ios/voice_input/keyboard/keyboard_keys_view.swift @@ -0,0 +1,253 @@ +import UIKit +import VoiceInputShared + +/// Draws the key grid from `VoiceInputKeyboardLayout` rows with the system +/// keyboard's proportions, colors, and press behavior. +@MainActor +final class KeyboardKeysView: UIView { + /// Fired on touch-down for delete (and its repeats), on touch-up otherwise. + var onKey: ((VoiceInputKeyboardKey) -> Void)? + + private static let sideMargin: CGFloat = 3 + private static let keyGap: CGFloat = 6 + private static let rowGap: CGFloat = 11 + private static let topMargin: CGFloat = 8 + private static let bottomMargin: CGFloat = 4 + + private var rows: [VoiceInputKeyboardRow] = [] + private var keyButtons: [[KeyboardKeyButton]] = [] + private var deleteRepeat: Timer? + + func configure( + rows: [VoiceInputKeyboardRow], + isUppercase: Bool, + isCapsLocked: Bool + ) { + if rows != self.rows { + self.rows = rows + for button in keyButtons.flatMap({ $0 }) { + button.removeFromSuperview() + } + keyButtons = rows.map { row in + row.slots.map { slot in + let button = KeyboardKeyButton(key: slot.key) + addTarget(for: button) + addSubview(button) + return button + } + } + setNeedsLayout() + } + for button in keyButtons.flatMap({ $0 }) { + button.apply(isUppercase: isUppercase, isCapsLocked: isCapsLocked) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + guard !rows.isEmpty else { + return + } + let rowCount = CGFloat(rows.count) + let rowHeight = + (bounds.height - Self.topMargin - Self.bottomMargin - Self.rowGap * (rowCount - 1)) + / rowCount + let columns = CGFloat(VoiceInputKeyboardLayout.rowWidth) + let unit = + (bounds.width - Self.sideMargin * 2 - Self.keyGap * (columns - 1)) / columns + for (rowIndex, row) in rows.enumerated() { + let y = Self.topMargin + CGFloat(rowIndex) * (rowHeight + Self.rowGap) + var x = Self.sideMargin + CGFloat(row.inset) * (unit + Self.keyGap) + for (slot, button) in zip(row.slots, keyButtons[rowIndex]) { + // Wider keys absorb the gaps they span, so every row ends flush. + let width = CGFloat(slot.width) * unit + CGFloat(slot.width - 1) * Self.keyGap + button.frame = CGRect(x: x, y: y, width: width, height: rowHeight).integral + x += width + Self.keyGap + } + } + } + + private func addTarget(for button: KeyboardKeyButton) { + if button.key == .delete { + button.addTarget(self, action: #selector(beginDelete(_:)), for: .touchDown) + button.addTarget( + self, + action: #selector(endDelete), + for: [.touchUpInside, .touchUpOutside, .touchCancel] + ) + } else { + button.addTarget(self, action: #selector(keyTapped(_:)), for: .touchUpInside) + } + } + + @objc private func keyTapped(_ sender: KeyboardKeyButton) { + UIDevice.current.playInputClick() + onKey?(sender.key) + } + + /// Delete fires at once, then repeats after a pause while held. + @objc private func beginDelete(_ sender: KeyboardKeyButton) { + UIDevice.current.playInputClick() + onKey?(.delete) + deleteRepeat?.invalidate() + deleteRepeat = Timer.scheduledTimer(withTimeInterval: 0.45, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { + self?.deleteRepeat = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { + [weak self] _ in + MainActor.assumeIsolated { self?.onKey?(.delete) } + } + } + } + } + + @objc private func endDelete() { + deleteRepeat?.invalidate() + deleteRepeat = nil + } +} + +/// One key cap. Pressing swaps to the other key color, as the system does. +@MainActor +final class KeyboardKeyButton: UIControl { + let key: VoiceInputKeyboardKey + private let label = UILabel() + private let symbol = UIImageView() + private var isUppercase = false + private var isCapsLocked = false + + init(key: VoiceInputKeyboardKey) { + self.key = key + super.init(frame: .zero) + layer.cornerRadius = 8 + layer.cornerCurve = .continuous + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.3 + layer.shadowOffset = CGSize(width: 0, height: 1) + layer.shadowRadius = 0 + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = false + label.isUserInteractionEnabled = false + symbol.contentMode = .center + symbol.isUserInteractionEnabled = false + addSubview(label) + addSubview(symbol) + isAccessibilityElement = true + accessibilityTraits = [.keyboardKey] + accessibilityIdentifier = Self.identifier(for: key) + // CGColor borders do not follow appearance changes on their own. + registerForTraitChanges([UITraitUserInterfaceStyle.self, UITraitAccessibilityContrast.self]) { + (self: Self, _: UITraitCollection) in + self.refresh() + } + refresh() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("Key caps are built in code.") + } + + override var isHighlighted: Bool { + didSet { refresh() } + } + + func apply(isUppercase: Bool, isCapsLocked: Bool) { + self.isUppercase = isUppercase + self.isCapsLocked = isCapsLocked + refresh() + } + + override func layoutSubviews() { + super.layoutSubviews() + label.frame = bounds + symbol.frame = bounds + layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath + } + + private func refresh() { + let pressedLooksFunction = key.isFunction != isHighlighted + backgroundColor = pressedLooksFunction ? Self.functionColor : Self.characterColor + let increasedContrast = traitCollection.accessibilityContrast == .high + layer.borderWidth = increasedContrast ? 1 : 0 + layer.borderColor = UIColor.label.cgColor + label.textColor = .label + symbol.tintColor = .label + label.text = nil + symbol.image = nil + switch key { + case .character(let character): + label.text = isUppercase ? character.uppercased() : character + label.font = .systemFont(ofSize: 24, weight: .regular) + accessibilityLabel = label.text + case .space: + label.text = "space" + label.font = .systemFont(ofSize: 16) + accessibilityLabel = "Space" + case .returnKey: + label.text = "return" + label.font = .systemFont(ofSize: 16) + accessibilityLabel = "Return" + case .layer(let layer): + label.text = Self.title(for: layer) + label.font = .systemFont(ofSize: 16) + accessibilityLabel = Self.accessibilityTitle(for: layer) + case .shift: + let name = isCapsLocked ? "capslock.fill" : (isUppercase ? "shift.fill" : "shift") + symbol.image = UIImage(systemName: name, withConfiguration: Self.symbolConfiguration) + accessibilityLabel = isCapsLocked ? "Caps Lock" : "Shift" + accessibilityValue = isUppercase ? "On" : "Off" + case .delete: + symbol.image = UIImage(systemName: "delete.left", withConfiguration: Self.symbolConfiguration) + accessibilityLabel = "Delete" + case .nextKeyboard: + symbol.image = UIImage(systemName: "globe", withConfiguration: Self.symbolConfiguration) + accessibilityLabel = "Next keyboard" + } + } + + private static let symbolConfiguration = UIImage.SymbolConfiguration( + pointSize: 19, + weight: .regular + ) + + private static func title(for layer: VoiceInputKeyboardLayer) -> String { + switch layer { + case .letters: "ABC" + case .numbers: "123" + case .symbols: "#+=" + } + } + + private static func accessibilityTitle(for layer: VoiceInputKeyboardLayer) -> String { + switch layer { + case .letters: "Letters" + case .numbers: "Numbers" + case .symbols: "Symbols" + } + } + + /// Identifiers stay stable for UI tests and match the earlier keyboard. + private static func identifier(for key: VoiceInputKeyboardKey) -> String { + switch key { + case .character(let character): "key_\(character)" + case .shift: "shift" + case .delete: "delete" + case .layer(let layer): "layer_\(title(for: layer))" + case .nextKeyboard: "next_keyboard" + case .space: "space" + case .returnKey: "return" + } + } + + static let characterColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 0.42, alpha: 1) + : .white + } + + static let functionColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 0.26, alpha: 1) + : UIColor(red: 0.67, green: 0.69, blue: 0.73, alpha: 1) + } +} diff --git a/apps/ios/voice_input/keyboard/keyboard_view_controller.swift b/apps/ios/voice_input/keyboard/keyboard_view_controller.swift index 6946fd3..28da065 100644 --- a/apps/ios/voice_input/keyboard/keyboard_view_controller.swift +++ b/apps/ios/voice_input/keyboard/keyboard_view_controller.swift @@ -24,17 +24,33 @@ final class KeyboardViewController: UIInputViewController { private var insertionRecovery: VoiceInputInsertionRecovery? private var hostChangeRevision: UInt64 = 0 private var selectedStyleKind = VoiceInputStyleKind.natural + private let keysView = KeyboardKeysView() + private var layer = VoiceInputKeyboardLayer.letters private var isUppercase = false + private var isCapsLocked = false + private var lastShiftTap: Date? + private var indicatorTimer: Timer? + private var indicatedPhase: VoiceInputSnapshot.Phase? override func viewDidLoad() { super.viewDidLoad() + inputView = KeyboardInputView(frame: .zero, inputViewStyle: .keyboard) selectedStyleKind = stylePreference.read() buildKeyboard() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + updateAutoCapitalization() + renderKeys() refreshStatus() + startIndicator() + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + // The globe key depends on the host, which is known only once presented. + renderKeys() } private func startPolling() { @@ -53,6 +69,8 @@ final class KeyboardViewController: UIInputViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) stopPolling() + indicatorTimer?.invalidate() + indicatorTimer = nil clearInsertionRecovery() } @@ -65,6 +83,8 @@ final class KeyboardViewController: UIInputViewController { let insertionWasPending = insertionRecovery != nil hostChangeRevision &+= 1 super.textDidChange(textInput) + updateAutoCapitalization() + renderKeys() refreshStatus() if insertionWasPending { clearInsertionRecovery() @@ -79,48 +99,53 @@ final class KeyboardViewController: UIInputViewController { refreshStatus() if insertionWasPending { clearInsertionRecovery() - showStatus("The field changed. The transcript remains in Voice Input History.") + showStatus("The field changed. The text is in Voice Input History.") } } private func buildKeyboard() { - view.backgroundColor = .systemGray6 - view.heightAnchor.constraint(greaterThanOrEqualToConstant: 250).isActive = true - statusLabel.font = .preferredFont(forTextStyle: .caption1) + statusLabel.adjustsFontForContentSizeCategory = false statusLabel.textColor = .secondaryLabel statusLabel.numberOfLines = 2 statusLabel.textAlignment = .center + statusLabel.lineBreakMode = .byTruncatingTail statusLabel.accessibilityIdentifier = "voice_status" - let rows = UIStackView(arrangedSubviews: [ - statusAndStyleRow, - letterRow("qwertyuiop"), - letterRow("asdfghjkl"), - thirdRow, - utilityRow, - ]) - rows.axis = .vertical - rows.spacing = 7 - rows.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(rows) + keysView.onKey = { [weak self] key in self?.handle(key) } + let bar = voiceBar + bar.translatesAutoresizingMaskIntoConstraints = false + keysView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bar) + view.addSubview(keysView) + // The system may resize the keyboard; one height keeps keys stable. + let height = view.heightAnchor.constraint(equalToConstant: 270) + height.priority = .defaultHigh NSLayoutConstraint.activate([ - rows.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 5), - rows.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -5), - rows.topAnchor.constraint(equalTo: view.topAnchor, constant: 6), - rows.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -6), + height, + bar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), + bar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + bar.topAnchor.constraint(equalTo: view.topAnchor, constant: 6), + bar.heightAnchor.constraint(equalToConstant: 44), + keysView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + keysView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + keysView.topAnchor.constraint(equalTo: bar.bottomAnchor), + keysView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + configureMicrophoneButton() + renderKeys() } - private var statusAndStyleRow: UIStackView { - insertionRecoveryButton.setTitle("Recover…", for: .normal) + /// Style on the left, status in the middle, the voice button on the right. + private var voiceBar: UIStackView { + insertionRecoveryButton.configuration = Self.barButtonConfiguration(title: "Recover…") insertionRecoveryButton.accessibilityLabel = "Recover unconfirmed voice insertion" insertionRecoveryButton.accessibilityIdentifier = "voice_insertion_recovery" insertionRecoveryButton.showsMenuAsPrimaryAction = true insertionRecoveryButton.isHidden = true insertionRecoveryButton.setContentHuggingPriority(.required, for: .horizontal) - restartButton.setTitle("Restart…", for: .normal) + restartButton.configuration = Self.barButtonConfiguration(title: "Restart…") restartButton.accessibilityLabel = "Show voice restart steps" restartButton.accessibilityIdentifier = "voice_restart" restartButton.isHidden = true @@ -131,131 +156,183 @@ final class KeyboardViewController: UIInputViewController { styleButton.accessibilityIdentifier = "voice_style" styleButton.showsMenuAsPrimaryAction = true styleButton.setContentHuggingPriority(.required, for: .horizontal) + styleButton.setContentCompressionResistancePriority(.required, for: .horizontal) configureStyleMenu() + statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let row = UIStackView(arrangedSubviews: [ + styleButton, statusLabel, insertionRecoveryButton, restartButton, - styleButton, + microphoneButton, ]) row.axis = .horizontal + row.alignment = .center row.spacing = 8 return row } - private func letterRow(_ letters: String) -> UIStackView { - stack(letters.map { letterButton(String($0)) }) - } - - private var thirdRow: UIStackView { - let shift = keyButton(title: "⇧", identifier: "shift") - shift.addTarget(self, action: #selector(toggleCase), for: .touchUpInside) - let delete = keyButton(title: "⌫", identifier: "delete") - delete.addTarget(self, action: #selector(deleteBackward), for: .touchUpInside) - return stack([shift] + "zxcvbnm".map { letterButton(String($0)) } + [delete]) + private static func barButtonConfiguration(title: String) -> UIButton.Configuration { + var configuration = UIButton.Configuration.plain() + configuration.title = title + configuration.baseForegroundColor = .label + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 4, leading: 6, bottom: 4, trailing: 6) + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .preferredFont(forTextStyle: .subheadline) + return attributes + } + return configuration } - private var utilityRow: UIStackView { - let globe = keyButton(title: "◉", identifier: "next_keyboard") - globe.addTarget(self, action: #selector(nextKeyboard), for: .touchUpInside) - - microphoneButton.setImage(UIImage(systemName: "mic.fill"), for: .normal) + private func configureMicrophoneButton() { microphoneButton.accessibilityLabel = "Voice capture" microphoneButton.accessibilityIdentifier = "voice_microphone" - microphoneButton.backgroundColor = .label - microphoneButton.tintColor = .systemBackground - microphoneButton.layer.cornerRadius = 8 + microphoneButton.layer.cornerRadius = 20 + microphoneButton.layer.cornerCurve = .continuous microphoneButton.addTarget(self, action: #selector(handleMicrophone), for: .touchUpInside) - - let space = keyButton(title: "space", identifier: "space") - space.addTarget(self, action: #selector(insertSpace), for: .touchUpInside) - space.widthAnchor.constraint(greaterThanOrEqualToConstant: 130).isActive = true - - let returnKey = keyButton(title: "return", identifier: "return") - returnKey.addTarget(self, action: #selector(insertReturn), for: .touchUpInside) - return stack([globe, microphoneButton, space, returnKey]) - } - - private func stack(_ views: [UIView]) -> UIStackView { - let row = UIStackView(arrangedSubviews: views) - row.axis = .horizontal - row.spacing = 5 - row.distribution = .fillEqually - return row - } - - private func letterButton(_ letter: String) -> UIButton { - let button = keyButton( - title: isUppercase ? letter.uppercased() : letter, - identifier: "key_\(letter)" + microphoneButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + microphoneButton.widthAnchor.constraint(equalToConstant: 40), + microphoneButton.heightAnchor.constraint(equalToConstant: 40), + ]) + showCapturePhase(nil) + } + + /// Neutral at rest; amber only while the app records, as in the app. + private func showCapturePhase(_ phase: VoiceInputSnapshot.Phase?) { + let symbol: String + switch phase { + case .recording: + microphoneButton.backgroundColor = VoiceInputTheme.signal + microphoneButton.tintColor = .black + symbol = "stop.fill" + microphoneButton.accessibilityValue = "Recording" + case .transcribing: + microphoneButton.backgroundColor = .secondarySystemFill + microphoneButton.tintColor = .label + symbol = "ellipsis" + microphoneButton.accessibilityValue = "Finishing" + case .none, .idle, .ready, .interrupted, .failed: + microphoneButton.backgroundColor = .label + microphoneButton.tintColor = .systemBackground + symbol = "mic.fill" + microphoneButton.accessibilityValue = nil + } + microphoneButton.setImage( + UIImage( + systemName: symbol, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold) + ), + for: .normal ) - button.addTarget(self, action: #selector(insertLetter(_:)), for: .touchUpInside) - return button - } - - private func keyButton(title: String, identifier: String) -> UIButton { - let button = UIButton(type: .system) - button.setTitle(title, for: .normal) - button.setTitleColor(.label, for: .normal) - button.titleLabel?.font = .preferredFont(forTextStyle: .body) - button.backgroundColor = .systemBackground - button.layer.cornerRadius = 6 - button.accessibilityIdentifier = identifier - return button } - @objc private func insertLetter(_ sender: UIButton) { - guard let letter = sender.title(for: .normal) else { + private func startIndicator() { + guard indicatorTimer == nil else { return } - textDocumentProxy.insertText(letter) - if isUppercase { - toggleCase() + updateIndicator() + indicatorTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.updateIndicator() } } } - @objc private func insertSpace() { - textDocumentProxy.insertText(" ") - } - - @objc private func insertReturn() { - textDocumentProxy.insertText("\n") + /// Reads only the bounded snapshot; it never starts or stops capture. + private func updateIndicator() { + let phase = hasFullAccess ? (try? store.readSnapshot())?.phase : nil + guard phase != indicatedPhase else { + return + } + indicatedPhase = phase + showCapturePhase(phase) } - @objc private func deleteBackward() { - textDocumentProxy.deleteBackward() + private func renderKeys() { + keysView.configure( + rows: VoiceInputKeyboardLayout.rows(for: layer, showsNextKeyboard: needsInputModeSwitchKey), + isUppercase: layer == .letters && (isUppercase || isCapsLocked), + isCapsLocked: isCapsLocked + ) } - @objc private func nextKeyboard() { - advanceToNextInputMode() + private func handle(_ key: VoiceInputKeyboardKey) { + switch key { + case .character(let character): + let uppercase = layer == .letters && (isUppercase || isCapsLocked) + textDocumentProxy.insertText(uppercase ? character.uppercased() : character) + if !isCapsLocked { + isUppercase = false + } + if layer != .letters, character == "'" { + layer = .letters + } + case .space: + if VoiceInputKeyboardLayout.shouldInsertPeriod( + after: textDocumentProxy.documentContextBeforeInput) + { + textDocumentProxy.deleteBackward() + textDocumentProxy.insertText(". ") + } else { + textDocumentProxy.insertText(" ") + } + layer = .letters + updateAutoCapitalization() + case .returnKey: + textDocumentProxy.insertText("\n") + layer = .letters + updateAutoCapitalization() + case .delete: + textDocumentProxy.deleteBackward() + updateAutoCapitalization() + case .shift: + toggleShift() + case .layer(let next): + layer = next + case .nextKeyboard: + advanceToNextInputMode() + } + renderKeys() + } + + /// One tap shifts the next letter; a quick second tap locks capitals. + private func toggleShift() { + let now = Date.now + if isCapsLocked { + isCapsLocked = false + isUppercase = false + } else if isUppercase, let lastShiftTap, now.timeIntervalSince(lastShiftTap) < 0.35 { + isCapsLocked = true + } else { + isUppercase.toggle() + } + lastShiftTap = now } - @objc private func toggleCase() { - isUppercase.toggle() - for case let button as UIButton in view.subviewsRecursive { - guard - let identifier = button.accessibilityIdentifier, - identifier.hasPrefix("key_") - else { - continue - } - let letter = String(identifier.dropFirst(4)) - button.setTitle(isUppercase ? letter.uppercased() : letter, for: .normal) + private func updateAutoCapitalization() { + guard !isCapsLocked else { + return } + let autocapitalizes = (textDocumentProxy.autocapitalizationType ?? .sentences) != .none + isUppercase = + autocapitalizes + && VoiceInputKeyboardLayout.shouldCapitalize( + after: textDocumentProxy.documentContextBeforeInput) } @objc private func handleMicrophone() { clearInsertionRecovery() guard hasFullAccess else { restartButton.isHidden = true - showStatus("Typing works. Enable Full Access for local voice handoff.") + showStatus("To dictate here, turn on Allow Full Access for Voice Keyboard.") return } guard currentFieldEligibility == .supported else { deliveryTarget = nil restartButton.isHidden = true - showStatus("Typing works. Voice capture is unavailable in this field.") + showStatus("Voice is off in this field. Typing still works.") return } guard let snapshot = try? store.readSnapshot() else { @@ -272,7 +349,7 @@ final class KeyboardViewController: UIInputViewController { } guard !invalidateDeliveryTargetIfNeeded() else { stopPolling() - showStatus("The field changed. Recover the completed transcript from Voice Input History.") + showStatus("The field changed. The text is in Voice Input History.") return } guard let snapshot = try? store.readSnapshot() else { @@ -302,15 +379,15 @@ final class KeyboardViewController: UIInputViewController { ) switch decision { case .requestStop: - showStatus("Stopping local capture…") + showStatus("Stopping…") case .waitingForResult: - showStatus("Finalizing locally…") + showStatus("Transcribing…") case .serviceStale: showStaleService("The app-owned capture stopped responding.") case .insert, .alreadyInserted: applyDecision(for: snapshot) case .manualActivationRequired: - showStatus("Capture stopped without a result. Start again in Voice Input.") + showStatus("Recording stopped without text. Start again in Voice Input.") self.deliveryTarget = nil stopPolling() case .requiresFullAccess, .unsupportedField: @@ -339,14 +416,14 @@ final class KeyboardViewController: UIInputViewController { case .requiresFullAccess: deliveryTarget = nil stopPolling() - showStatus("Typing works. Enable Full Access for local voice handoff.") + showStatus("To dictate here, turn on Allow Full Access for Voice Keyboard.") case .unsupportedField: deliveryTarget = nil stopPolling() - showStatus("Typing works. Voice capture is unavailable in this field.") + showStatus("Voice is off in this field. Typing still works.") case .manualActivationRequired: deliveryTarget = nil - showStatus("Start capture in Voice Input or its Control Center control, then return here.") + showStatus("Start recording in Voice Input or Control Center, then tap the mic.") case .requestStop(let sessionID): let requestedTarget = VoiceInputDeliveryTarget( sessionID: sessionID, @@ -365,7 +442,7 @@ final class KeyboardViewController: UIInputViewController { deliveryTarget = requestedTarget restartButton.isHidden = true startPolling() - showStatus("Stopping local capture…") + showStatus("Stopping…") } catch VoiceInputStoreError.commandPending { if deliveryTarget == requestedTarget { showStatus("A local capture command is already pending…") @@ -378,7 +455,7 @@ final class KeyboardViewController: UIInputViewController { showStatus("The stop request could not be written locally.") } case .waitingForResult: - showStatus("Finalizing locally…") + showStatus("Transcribing…") case .serviceStale: showStaleService("The app-owned capture is not responding.") case .insert(let sessionID, let sequence, let text): @@ -394,7 +471,7 @@ final class KeyboardViewController: UIInputViewController { else { self.deliveryTarget = nil stopPolling() - showStatus("The field changed. Recover the completed transcript from Voice Input History.") + showStatus("The field changed. The text is in Voice Input History.") return } let receipt = VoiceInputInsertionReceipt( @@ -448,17 +525,17 @@ final class KeyboardViewController: UIInputViewController { } if !fullAccess { restartButton.isHidden = true - showStatus("Typing works. Full Access enables only the local keychain handoff.") + showStatus("To dictate here, turn on Allow Full Access for Voice Keyboard.") } else if fieldEligibility == .unsupported { restartButton.isHidden = true - showStatus("Typing works. Voice capture is unavailable in this field.") + showStatus("Voice is off in this field. Typing still works.") } else if deliveryTargetWasInvalidated { restartButton.isHidden = true - showStatus("The field changed. Recover the completed transcript from Voice Input History.") + showStatus("The field changed. The text is in Voice Input History.") } else if insertionRecoveryWasInvalidated { - showStatus("The field changed. The transcript remains in Voice Input History.") + showStatus("The field changed. The text is in Voice Input History.") } else if statusLabel.text == nil { - showStatus("Tap the mic after starting capture in Voice Input or Control Center.") + showStatus("Start recording in Voice Input, then tap the mic to insert.") } } @@ -638,7 +715,24 @@ final class KeyboardViewController: UIInputViewController { } private func configureStyleMenu() { - styleButton.setTitle(selectedStyleKind.displayName, for: .normal) + var configuration = UIButton.Configuration.gray() + configuration.title = selectedStyleKind.displayName + configuration.image = UIImage( + systemName: "chevron.down", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 10, weight: .semibold) + ) + configuration.imagePlacement = .trailing + configuration.imagePadding = 4 + configuration.cornerStyle = .capsule + configuration.baseForegroundColor = .label + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 6, leading: 12, bottom: 6, trailing: 10) + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .preferredFont(forTextStyle: .subheadline) + return attributes + } + styleButton.configuration = configuration styleButton.menu = UIMenu( title: "Style", children: VoiceInputStyleKind.allCases.map { styleKind in @@ -661,8 +755,7 @@ final class KeyboardViewController: UIInputViewController { } -extension UIView { - fileprivate var subviewsRecursive: [UIView] { - subviews + subviews.flatMap(\.subviewsRecursive) - } +/// Enables the system's input clicks for this keyboard's keys. +final class KeyboardInputView: UIInputView, UIInputViewAudioFeedback { + var enableInputClicksWhenVisible: Bool { true } } diff --git a/apps/ios/voice_input/project.yml b/apps/ios/voice_input/project.yml index 90edca6..a2aca72 100644 --- a/apps/ios/voice_input/project.yml +++ b/apps/ios/voice_input/project.yml @@ -31,6 +31,8 @@ targets: - target: HardwareControllerCore - target: HardwareControllerVoiceFFI embed: false + - target: HardwareControllerModelDownload + embed: false - target: VoiceASRBridge embed: false - target: VoiceInputKeyboard @@ -125,6 +127,19 @@ targets: GENERATE_INFOPLIST_FILE: YES PRODUCT_BUNDLE_IDENTIFIER: com.longdevity.hardwarecontroller.voiceinput.voiceffi + # Pinned speech-model downloads: the app's only network code (decision 0062). + HardwareControllerModelDownload: + type: framework.static + platform: iOS + sources: + - path: ../../../Sources/hardware_controller_model_download + group: portable + createIntermediateGroups: false + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: com.longdevity.hardwarecontroller.voiceinput.modeldownload + VoiceASRBridge: type: framework.static platform: iOS @@ -216,6 +231,8 @@ targets: - target: HardwareControllerCore - target: HardwareControllerVoiceFFI embed: false + - target: HardwareControllerModelDownload + embed: false entitlements: path: config/tests.entitlements properties: diff --git a/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png b/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png index e18941e..ec5d3f4 100644 Binary files a/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png and b/apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png differ diff --git a/apps/ios/voice_input/shared/voice_input_keyboard_layout.swift b/apps/ios/voice_input/shared/voice_input_keyboard_layout.swift new file mode 100644 index 0000000..ad6036b --- /dev/null +++ b/apps/ios/voice_input/shared/voice_input_keyboard_layout.swift @@ -0,0 +1,131 @@ +/// The Voice Keyboard's keys and typing rules. Pure, so layers, widths, and +/// capitalization are tested without a keyboard host. +public enum VoiceInputKeyboardKey: Equatable, Hashable, Sendable { + case character(String) + case shift + case delete + case layer(VoiceInputKeyboardLayer) + case nextKeyboard + case space + case returnKey + + /// Characters, space, and the layer toggles read as primary keys; the rest + /// use the darker function style, as on the system keyboard. + public var isFunction: Bool { + switch self { + case .character, .space: false + case .shift, .delete, .layer, .nextKeyboard, .returnKey: true + } + } +} + +public enum VoiceInputKeyboardLayer: Equatable, Hashable, Sendable { + case letters + case numbers + case symbols +} + +public struct VoiceInputKeyboardSlot: Equatable, Sendable { + public let key: VoiceInputKeyboardKey + /// Width relative to one letter key. + public let width: Double + + public init(_ key: VoiceInputKeyboardKey, width: Double = 1) { + self.key = key + self.width = width + } +} + +public struct VoiceInputKeyboardRow: Equatable, Sendable { + public let slots: [VoiceInputKeyboardSlot] + /// Empty space before the first key, in letter-key widths, so the shorter + /// middle row centers as on the system keyboard. + public let inset: Double + + public init(_ slots: [VoiceInputKeyboardSlot], inset: Double = 0) { + self.slots = slots + self.inset = inset + } +} + +public enum VoiceInputKeyboardLayout { + /// Ten letter-key widths per row; every row sums to this with its insets. + public static let rowWidth = 10.0 + + public static func rows( + for layer: VoiceInputKeyboardLayer, + showsNextKeyboard: Bool + ) -> [VoiceInputKeyboardRow] { + let characters: [String] + let third: [String] + let toggle: VoiceInputKeyboardSlot + switch layer { + case .letters: + characters = ["qwertyuiop", "asdfghjkl"] + third = "zxcvbnm".map(String.init) + toggle = VoiceInputKeyboardSlot(.shift, width: 1.5) + case .numbers: + characters = ["1234567890", "-/:;()$&@\""] + third = [".", ",", "?", "!", "'"] + toggle = VoiceInputKeyboardSlot(.layer(.symbols), width: 1.5) + case .symbols: + characters = ["[]{}#%^*+=", "_\\|~<>€£¥•"] + third = [".", ",", "?", "!", "'"] + toggle = VoiceInputKeyboardSlot(.layer(.numbers), width: 1.5) + } + let top = characters.map { row in row.map { VoiceInputKeyboardSlot(.character(String($0))) } } + let thirdKeyWidth = (rowWidth - 3) / Double(third.count) + return [ + VoiceInputKeyboardRow(top[0]), + VoiceInputKeyboardRow(top[1], inset: (rowWidth - Double(top[1].count)) / 2), + VoiceInputKeyboardRow( + [toggle] + + third.map { VoiceInputKeyboardSlot(.character($0), width: thirdKeyWidth) } + + [VoiceInputKeyboardSlot(.delete, width: 1.5)] + ), + bottomRow(layer: layer, showsNextKeyboard: showsNextKeyboard), + ] + } + + private static func bottomRow( + layer: VoiceInputKeyboardLayer, + showsNextKeyboard: Bool + ) -> VoiceInputKeyboardRow { + let layerToggle = VoiceInputKeyboardSlot( + .layer(layer == .letters ? .numbers : .letters), + width: 1.25 + ) + let returnKey = VoiceInputKeyboardSlot(.returnKey, width: 2) + let fixed = showsNextKeyboard ? 1.25 + 1.25 + 2 : 1.25 + 2 + let space = VoiceInputKeyboardSlot(.space, width: rowWidth - fixed) + let leading = + showsNextKeyboard + ? [layerToggle, VoiceInputKeyboardSlot(.nextKeyboard, width: 1.25)] + : [layerToggle] + return VoiceInputKeyboardRow(leading + [space, returnKey]) + } + + /// Sentence-start capitalization from the text before the cursor. + public static func shouldCapitalize(after context: String?) -> Bool { + guard let context, let last = context.last(where: { $0 != " " }) else { + return true + } + if last.isNewline { + return true + } + // A sentence end counts only once a space follows it. + return context.last == " " && ".!?".contains(last) + } + + /// Double space after a word becomes ". ", as on the system keyboard. + public static func shouldInsertPeriod(after context: String?) -> Bool { + guard + let context, + context.last == " ", + let previous = context.dropLast().last + else { + return false + } + return previous.isLetter || previous.isNumber + } +} diff --git a/apps/ios/voice_input/shared/voice_input_theme.swift b/apps/ios/voice_input/shared/voice_input_theme.swift new file mode 100644 index 0000000..f07fc0e --- /dev/null +++ b/apps/ios/voice_input/shared/voice_input_theme.swift @@ -0,0 +1,8 @@ +import UIKit + +/// Voice Input inherits the repository style: neutral surfaces, system type, +/// and the icon's signal amber reserved for an active recording. The app and +/// the keyboard share this one definition. +public enum VoiceInputTheme { + public static let signal = UIColor(red: 0.98, green: 0.69, blue: 0.13, alpha: 1) +} diff --git a/apps/ios/voice_input/tests/voice_input_history_model_test.swift b/apps/ios/voice_input/tests/voice_input_history_model_test.swift index a423de4..121e0be 100644 --- a/apps/ios/voice_input/tests/voice_input_history_model_test.swift +++ b/apps/ios/voice_input/tests/voice_input_history_model_test.swift @@ -23,6 +23,23 @@ final class VoiceInputHistoryModelTest: XCTestCase { XCTAssertEqual(history.queries, ["matched"]) } + @MainActor + func testDeleteAndDeleteAllUpdateVisibleSessions() async throws { + let first = try Self.session(text: "First", endedAt: 20) + let second = try Self.session(text: "Second", endedAt: 10) + let history = RecordingHistoryAccess(recent: [first, second], search: []) + let model = VoiceInputHistoryModel(history: history) + await model.refresh() + + await model.delete(sessionID: first.id) + XCTAssertEqual(model.sessions, [second]) + await model.deleteAll() + + XCTAssertEqual(model.sessions, []) + XCTAssertEqual(history.deletions, [.one(first.id), .all]) + XCTAssertNil(model.errorMessage) + } + @MainActor func testUnavailableHistorySurfacesTheInitializationFailure() async { let model = VoiceInputHistoryModel( @@ -120,6 +137,12 @@ private final class RecordingHistoryAccess: VoiceInputHistoryAccessing, Sendable var queries: [String] = [] var pinUpdates: [PinUpdate] = [] var retentionSettings: [VoiceHistoryRetentionSettings] = [] + var deletions: [Deletion] = [] + } + + enum Deletion: Equatable, Sendable { + case one(UUID) + case all } private let recentSessions: [VoiceInputHistorySession] @@ -136,6 +159,7 @@ private final class RecordingHistoryAccess: VoiceInputHistoryAccessing, Sendable var queries: [String] { state.withLock { $0.queries } } var pinUpdates: [PinUpdate] { state.withLock { $0.pinUpdates } } + var deletions: [Deletion] { state.withLock { $0.deletions } } var retentionSettings: [VoiceHistoryRetentionSettings] { state.withLock { $0.retentionSettings } } @@ -194,6 +218,14 @@ private final class RecordingHistoryAccess: VoiceInputHistoryAccessing, Sendable func retentionMaintenanceMessage() async -> String? { nil } + + func delete(sessionID: UUID) async throws { + state.withLock { $0.deletions.append(.one(sessionID)) } + } + + func deleteAll() async throws { + state.withLock { $0.deletions.append(.all) } + } } private struct PinUpdate: Equatable, Sendable { diff --git a/apps/ios/voice_input/tests/voice_input_history_repository_test.swift b/apps/ios/voice_input/tests/voice_input_history_repository_test.swift index fe56703..54d9187 100644 --- a/apps/ios/voice_input/tests/voice_input_history_repository_test.swift +++ b/apps/ios/voice_input/tests/voice_input_history_repository_test.swift @@ -40,6 +40,66 @@ final class VoiceInputHistoryRepositoryTest: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: artifact.url.path)) } + func testDeleteRemovesOneSessionAndItsAudioAndSurvivesReload() async throws { + let fixture = try Fixture() + addTeardownBlock { try await fixture.remove() } + let kept = try await fixture.save("Keep this one.", at: 20) + let deleted = try await fixture.save("Delete this one.", at: 30) + + try await fixture.repository.delete(sessionID: deleted.id) + let reloaded = try VoiceInputHistoryRepository( + rootURL: fixture.historyRoot, + retentionSettings: .iOSDefault + ) + addTeardownBlock { try await reloaded.close() } + + let remaining = try await reloaded.recent(limit: 20) + XCTAssertEqual(remaining.map(\.id), [kept.id]) + let deletedAudio = try XCTUnwrap(deleted.audioArtifact) + XCTAssertFalse(FileManager.default.fileExists(atPath: deletedAudio.url.path)) + let keptAudio = try XCTUnwrap(kept.audioArtifact) + XCTAssertTrue(FileManager.default.fileExists(atPath: keptAudio.url.path)) + do { + try await fixture.repository.delete(sessionID: deleted.id) + XCTFail("Deleting a missing session must fail explicitly.") + } catch VoiceInputHistoryError.invalidSession {} + } + + func testDeleteAllClearsHistoryButKeepsAnActiveCapture() async throws { + let fixture = try Fixture() + addTeardownBlock { try await fixture.remove() } + _ = try await fixture.save("First.", at: 20) + _ = try await fixture.save("Second.", at: 30) + let activeCapture = fixture.historyRoot + .appendingPathComponent("audio", isDirectory: true) + .appendingPathComponent("\(UUID().uuidString.lowercased()).partial") + try Data("recording".utf8).write(to: activeCapture) + + try await fixture.repository.deleteAll() + + let remaining = try await fixture.repository.recent(limit: 20) + XCTAssertEqual(remaining, []) + let audio = try FileManager.default.contentsOfDirectory( + atPath: activeCapture.deletingLastPathComponent().path + ) + XCTAssertEqual(audio, [activeCapture.lastPathComponent]) + } + + func testFailedAudioRemovalKeepsTheSessionForRetry() async throws { + let fixture = try Fixture(removeRetainedAudio: { _ in throw CocoaError(.fileWriteNoPermission) } + ) + addTeardownBlock { try await fixture.remove() } + let saved = try await fixture.save("Stay until audio is gone.", at: 20) + + do { + try await fixture.repository.delete(sessionID: saved.id) + XCTFail("A failed audio removal must surface.") + } catch VoiceInputHistoryError.storageUnavailable {} + + let remaining = try await fixture.repository.recent(limit: 20) + XCTAssertEqual(remaining.map(\.id), [saved.id]) + } + func testConfiguredArtifactCapExpiresOldAudioWithoutDeletingTranscript() async throws { let fixture = try Fixture( retentionSettings: VoiceHistoryRetentionSettings( @@ -697,6 +757,16 @@ private struct Fixture { ) } + func save(_ text: String, at endedAt: TimeInterval) async throws -> VoiceInputHistorySession { + try await repository.save( + sessionID: UUID(), + startedAt: Date(timeIntervalSince1970: endedAt - 1), + endedAt: Date(timeIntervalSince1970: endedAt), + transcript: try process(text), + sourceAudioURL: audioURL + ) + } + func remove() async throws { try await repository.close() try FileManager.default.removeItem(at: root) diff --git a/apps/ios/voice_input/tests/voice_input_keyboard_layout_test.swift b/apps/ios/voice_input/tests/voice_input_keyboard_layout_test.swift new file mode 100644 index 0000000..1ce20ae --- /dev/null +++ b/apps/ios/voice_input/tests/voice_input_keyboard_layout_test.swift @@ -0,0 +1,64 @@ +import VoiceInputShared +import XCTest + +final class VoiceInputKeyboardLayoutTest: XCTestCase { + private let layers: [VoiceInputKeyboardLayer] = [.letters, .numbers, .symbols] + + func testEveryRowFillsTheKeyboardWidth() { + for layer in layers { + for showsNextKeyboard in [true, false] { + for row in VoiceInputKeyboardLayout.rows(for: layer, showsNextKeyboard: showsNextKeyboard) { + let width = row.inset * 2 + row.slots.reduce(0) { $0 + $1.width } + XCTAssertEqual(width, VoiceInputKeyboardLayout.rowWidth, accuracy: 0.0001, "\(layer)") + } + } + } + } + + func testLayersOfferLettersNumbersAndSymbols() { + let letters = characters(.letters) + XCTAssertEqual(letters.count, 26) + XCTAssertTrue(characters(.numbers).isSuperset(of: Set("0123456789".map(String.init)))) + XCTAssertTrue(characters(.symbols).isSuperset(of: ["#", "%", "€", "\\"])) + let bottom = VoiceInputKeyboardLayout.rows(for: .numbers, showsNextKeyboard: false)[3] + XCTAssertEqual(bottom.slots.map(\.key), [.layer(.letters), .space, .returnKey]) + } + + func testTheGlobeKeyAppearsOnlyWhenTheSystemAsks() { + let keys = { (shows: Bool) in + VoiceInputKeyboardLayout.rows(for: .letters, showsNextKeyboard: shows)[3].slots.map(\.key) + } + XCTAssertTrue(keys(true).contains(.nextKeyboard)) + XCTAssertFalse(keys(false).contains(.nextKeyboard)) + } + + func testSentenceStartCapitalization() { + let cases: [(String?, Bool)] = [ + (nil, true), ("", true), (" ", true), ("Hello. ", true), ("Why? ", true), + ("Line\n", true), ("Hello", false), ("Hello ", false), ("Hello.", false), ("e.g", false), + ] + for (context, expected) in cases { + XCTAssertEqual( + VoiceInputKeyboardLayout.shouldCapitalize(after: context), expected, "\(context ?? "nil")") + } + } + + func testDoubleSpaceBecomesAPeriodOnlyAfterAWord() { + XCTAssertTrue(VoiceInputKeyboardLayout.shouldInsertPeriod(after: "Hello ")) + XCTAssertTrue(VoiceInputKeyboardLayout.shouldInsertPeriod(after: "at 5 ")) + for context in [nil, "", " ", "Hello. ", "Hello", "Hi. "] as [String?] { + XCTAssertFalse( + VoiceInputKeyboardLayout.shouldInsertPeriod(after: context), "\(context ?? "nil")") + } + } + + private func characters(_ layer: VoiceInputKeyboardLayer) -> Set { + Set( + VoiceInputKeyboardLayout.rows(for: layer, showsNextKeyboard: true) + .flatMap(\.slots) + .compactMap { slot in + if case .character(let character) = slot.key { return character } + return nil + }) + } +} diff --git a/apps/ios/voice_input/tests/voice_input_model_choice_test.swift b/apps/ios/voice_input/tests/voice_input_model_choice_test.swift new file mode 100644 index 0000000..c978ae5 --- /dev/null +++ b/apps/ios/voice_input/tests/voice_input_model_choice_test.swift @@ -0,0 +1,88 @@ +import Foundation +import HardwareControllerModelDownload +import XCTest + +@testable import VoiceInput + +final class VoiceInputModelChoiceTest: XCTestCase { + func testBuiltInLeadsThenCatalogThenImports() async throws { + let library = try await ModelLibraryFixture() + let builtIn = try await library.installStarter() + let imported = try await library.installFixture() + let catalog = [ASRModelCatalog.whisperBaseEnglish, ASRModelCatalog.parakeetV3] + .compactMap { $0 } + XCTAssertEqual(catalog.count, 2) + + let choices = VoiceInputModelChoice.choices( + installed: [imported, builtIn], + active: builtIn, + catalog: catalog, + downloads: ["com.longdevity.asr.parakeet_tdt_v3": 0.25] + ) + + XCTAssertEqual( + choices.map(\.id), + [ + VoiceInputModelChoice.builtInPackageID, "com.longdevity.asr.whisper_base_en", + "com.longdevity.asr.parakeet_tdt_v3", imported.package.packageID, + ]) + XCTAssertEqual(choices[0].state, .active) + XCTAssertEqual(choices[1].state, .available(catalog[0])) + XCTAssertEqual(choices[2].state, .downloading(catalog[1], fraction: 0.25)) + XCTAssertEqual(choices[3].state, .installed(imported)) + XCTAssertEqual(choices[3].summary, "Imported from Files") + XCTAssertNil(choices[1].installed) + } + + func testAnInstalledCatalogPackageReplacesItsDownloadRow() async throws { + let library = try await ModelLibraryFixture() + let builtIn = try await library.installStarter() + let catalog = [try XCTUnwrap(ASRModelCatalog.whisperBaseEnglish)] + + let choices = VoiceInputModelChoice.choices( + installed: [builtIn], + active: nil, + catalog: catalog, + downloads: [:] + ) + + XCTAssertEqual(choices.first?.state, .installed(builtIn)) + XCTAssertEqual(choices.count, 2) + } +} + +/// A private library holding the bundled starter and the shared fixture. +struct ModelLibraryFixture { + let root: URL + let registry: VoiceInputASRModelRegistry + + init() async throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + registry = VoiceInputASRModelRegistry( + installer: VoiceInputModelPackageInstaller( + rootURL: root.appendingPathComponent("models", isDirectory: true) + ), + selectionURL: root.appendingPathComponent("active_asr.json") + ) + } + + func installStarter() async throws -> VoiceInputInstalledModelPackage { + try await registry.install( + from: try XCTUnwrap(VoiceInputStarterModel()).packageURL, + expectedManifestSHA256: VoiceInputStarterModel.manifestSHA256 + ) + } + + /// The shared fixture, rewritten as a compatible transcribe.cpp package. + func installFixture() async throws -> VoiceInputInstalledModelPackage { + let source = root.appendingPathComponent("fixture", isDirectory: true) + try copyVoiceInputModelFixture(to: source) + let manifestURL = source.appendingPathComponent("manifest.json") + try String(contentsOf: manifestURL, encoding: .utf8) + .replacingOccurrences(of: "\"sherpa_onnx\"", with: "\"transcribe_cpp\"") + .write(to: manifestURL, atomically: true, encoding: .utf8) + return try await registry.install(from: source, expectedManifestSHA256: nil) + } +} diff --git a/apps/ios/voice_input/tests/voice_input_model_downloader_test.swift b/apps/ios/voice_input/tests/voice_input_model_downloader_test.swift new file mode 100644 index 0000000..d9114be --- /dev/null +++ b/apps/ios/voice_input/tests/voice_input_model_downloader_test.swift @@ -0,0 +1,56 @@ +import Foundation +import HardwareControllerModelDownload +import XCTest + +@testable import VoiceInput + +final class VoiceInputModelDownloaderTest: XCTestCase { + func testEveryFailureHasPlainText() { + let cases: [(ASRModelDownloadError, String)] = [ + (.catalogUnavailable, "This model is unavailable in this build."), + ( + .transferFailed("Offline."), + "The download stopped. Offline. Check the connection and try again." + ), + ( + .integrityMismatch("model.gguf"), + "model.gguf did not match its pinned checksum, so nothing was installed." + ), + (.installFailed("Full."), "The model could not be installed. Full."), + (.cancelled, "The download was cancelled."), + ] + for (error, message) in cases { + XCTAssertEqual(VoiceInputModelDownloader.message(for: error), message) + } + } + + /// A failed download leaves no staging bytes behind. + func testFailureRemovesStaging() async throws { + let library = try await ModelLibraryFixture() + let staging = library.root.appendingPathComponent("downloads") + let downloader = VoiceInputModelDownloader( + stagingRoot: staging, + manager: library.registry, + fetcher: OfflineFetcher() + ) + let entry = try XCTUnwrap(ASRModelCatalog.whisperBaseEnglish) + + do { + _ = try await downloader.download(entry) { _ in } + XCTFail("An offline fetch must fail.") + } catch let error as ASRModelDownloadError { + XCTAssertEqual(error, .transferFailed("Offline.")) + } + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: staging.path), []) + } +} + +private struct OfflineFetcher: ASRModelFileFetching { + func fetch( + _: URL, + to _: URL, + progress _: @escaping @Sendable (Int64) -> Void + ) async throws(ASRModelDownloadError) { + throw .transferFailed("Offline.") + } +} diff --git a/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift b/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift index 6c6ec73..9f22f93 100644 --- a/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift +++ b/apps/ios/voice_input/tests/voice_input_model_library_model_test.swift @@ -1,4 +1,6 @@ +import CryptoKit import Foundation +import HardwareControllerModelDownload import XCTest @testable import VoiceInput @@ -141,6 +143,67 @@ final class VoiceInputModelLibraryModelTest: XCTestCase { XCTAssertNil(model.activeASRModel) } + @MainActor + func testDownloadInstallsVerifiesAndActivatesTheModel() async throws { + let library = try await ModelLibraryFixture() + let entry = try FixtureCatalog.entry() + let model = VoiceInputModelLibraryModel( + manager: library.registry, + downloader: VoiceInputModelDownloader( + stagingRoot: library.root.appendingPathComponent("downloads"), + manager: library.registry, + fetcher: FixtureCatalog.Fetcher() + ), + catalog: [entry] + ) + + model.download(entry) + XCTAssertEqual(model.downloads[entry.packageID], 0) + try await waitUntil { model.downloads.isEmpty && model.activeASRModel != nil } + + XCTAssertEqual(model.activeASRModel?.package.packageID, entry.packageID) + XCTAssertEqual(model.activeASRModel?.publisherVerified, true) + XCTAssertNil(model.errorMessage) + XCTAssertEqual(model.choices.first?.state, .active) + } + + @MainActor + func testCorruptedDownloadInstallsNothingAndExplains() async throws { + let library = try await ModelLibraryFixture() + let entry = try FixtureCatalog.entry() + let model = VoiceInputModelLibraryModel( + manager: library.registry, + downloader: VoiceInputModelDownloader( + stagingRoot: library.root.appendingPathComponent("downloads"), + manager: library.registry, + fetcher: FixtureCatalog.Fetcher(corrupt: true) + ), + catalog: [entry] + ) + + model.download(entry) + try await waitUntil { model.downloads.isEmpty } + + XCTAssertEqual( + model.errorMessage, + "model.gguf did not match its pinned checksum, so nothing was installed." + ) + let installed = try await library.registry.installedPackages() + XCTAssertEqual(installed, []) + } + + @MainActor + private func waitUntil(_ condition: @MainActor () -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(10) + while !condition() { + guard ContinuousClock.now < deadline else { + XCTFail("Timed out waiting for the download to settle.") + throw CancellationError() + } + try await Task.sleep(for: .milliseconds(10)) + } + } + private func temporaryDirectory() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -169,3 +232,60 @@ private struct FailingPrewarmTranscriber: VoiceInputTranscribing { throw VoiceInputTranscriptionError.modelLoadFailed } } + +/// A one-package catalog served from memory with pinned digests. +private enum FixtureCatalog { + static let contents: [String: Data] = [ + "model.gguf": Data(repeating: 7, count: 2_048), + "vad.gguf": Data(repeating: 3, count: 256), + "LICENSE": Data("Apache License 2.0".utf8), + ] + + struct Fetcher: ASRModelFileFetching { + var corrupt = false + + func fetch( + _ url: URL, + to destination: URL, + progress: @escaping @Sendable (Int64) -> Void + ) async throws(ASRModelDownloadError) { + var data = FixtureCatalog.contents[url.lastPathComponent] ?? Data() + if corrupt, url.lastPathComponent == "model.gguf" { + data[0] ^= 0xff + } + do { + try data.write(to: destination) + } catch { + throw .transferFailed("fixture") + } + progress(Int64(data.count)) + } + } + + static func entry() throws -> ASRModelCatalogEntry { + let base = try XCTUnwrap(URL(string: "https://example.invalid/resolve/abc")) + func file(_ name: String, _ role: String?) -> ASRModelCatalogFile { + let data = contents[name] ?? Data() + return ASRModelCatalogFile( + name: name == "LICENSE" ? "model-LICENSE.txt" : name, + url: base.appending(path: name), + sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), + bytes: Int64(data.count), + role: role + ) + } + return ASRModelCatalogEntry( + packageID: "com.longdevity.asr.fixture_download", + version: "1", + displayName: "Fixture Download", + languages: ["en-US"], + spdxExpression: "Apache-2.0", + sourceURL: base, + minimumMemoryBytes: 1, + recommendedMemoryBytes: 1, + files: [ + file("model.gguf", "model"), file("vad.gguf", "speech_detection"), file("LICENSE", nil), + ] + ) + } +} diff --git a/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift b/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift index b537988..9a60c88 100644 --- a/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift +++ b/apps/ios/voice_input/ui_tests/voice_input_ui_test.swift @@ -50,6 +50,39 @@ final class VoiceInputUITest: XCTestCase { attachScreenshot(app, named: "history_detail") } + /// Deleting one dictation and clearing History both reach storage: the + /// empty state survives a relaunch. + @MainActor + func testHistoryDeletesOneDictationThenClearsAll() throws { + let app = try launch(reset: true, fixture: true) + for _ in 0..<2 { + try dictate(in: app) + } + app.tabBars.buttons["History"].tap() + // Each History entry is one list cell; its label and link are not counted. + let rows = app.collectionViews.cells + wait(for: rows, toCount: 2) + + rows.firstMatch.swipeLeft() + app.buttons["history_delete_row"].tap() + wait(for: rows, toCount: 1) + + app.buttons["history_menu"].tap() + app.buttons["history_clear_all"].tap() + // iOS renders the dialog action twice (sheet and accessibility copy). + app.buttons["history_confirm_clear_all"].firstMatch.tap() + XCTAssertTrue( + app.descendants(matching: .any)["history_empty"].waitForExistence(timeout: Self.timeout)) + + app.terminate() + app.launchArguments.removeAll { $0 == "-voice_input_reset_state" } + app.launch() + app.tabBars.buttons["History"].tap() + XCTAssertTrue( + app.descendants(matching: .any)["history_empty"].waitForExistence(timeout: Self.timeout)) + XCTAssertEqual(rows.count, 0) + } + @MainActor func testSettingsShowTheVerifiedModelSetupAndStorage() throws { let app = try launch(reset: true, fixture: false) @@ -59,7 +92,9 @@ final class VoiceInputUITest: XCTestCase { app.descendants(matching: .any)["active_asr_model"].waitForExistence(timeout: Self.timeout) ) XCTAssertTrue(app.staticTexts["Whisper Tiny English"].exists) - XCTAssertTrue(app.buttons["import_model_package"].exists) + for packageID in ["whisper_base_en", "whisper_small_en", "parakeet_tdt_v3"] { + XCTAssertTrue(app.buttons["download_model_com.longdevity.asr.\(packageID)"].exists, packageID) + } for label in ["Keep audio for", "Audio up to", "Recordings"] { // A Form menu Picker is exposed as a button labeled ", <value>". let picker = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", label)) @@ -69,6 +104,11 @@ final class VoiceInputUITest: XCTestCase { } XCTAssertTrue(picker.isHittable, label) } + let importButton = app.buttons["import_model_package"] + for _ in 0..<6 where !importButton.isHittable { + app.swipeUp() + } + XCTAssertTrue(importButton.isHittable) let shortcuts = app.buttons["open_voice_shortcuts"] for _ in 0..<6 where !shortcuts.isHittable { app.swipeUp() @@ -129,6 +169,26 @@ final class VoiceInputUITest: XCTestCase { return app } + @MainActor + private func dictate(in app: XCUIApplication) throws { + app.tabBars.buttons["Dictate"].tap() + let start = app.buttons["start_capture"] + XCTAssertTrue(start.waitForExistence(timeout: Self.timeout)) + wait(for: start, toBe: "isEnabled == true") + start.tap() + let stop = app.buttons["stop_capture"] + XCTAssertTrue(stop.waitForExistence(timeout: Self.timeout)) + stop.tap() + let title = app.staticTexts["dictation_title"] + wait(for: title, toBe: "label == 'Done'") + } + + @MainActor + private func wait(for query: XCUIElementQuery, toCount count: Int) { + expectation(for: NSPredicate(format: "count == %d", count), evaluatedWith: query) + waitForExpectations(timeout: Self.timeout) + } + @MainActor private func wait(for element: XCUIElement, toBe predicate: String) { expectation(for: NSPredicate(format: predicate), evaluatedWith: element) diff --git a/docs/architecture.md b/docs/architecture.md index dad30a5..e9d479c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,7 @@ observe it and never block it. | Portable Voice core | `crates/voice_core`, `voice_models`, `voice_archive` | Retention policy, Model-package and archive verification. | No platform types; unsafe Rust denied outside `voice_ffi`. | | Voice FFI | `crates/voice_ffi`, `Sources/hardware_controller_voice_ffi` | Versioned synchronous C ABI and typed Swift wrappers. | Caller owns every buffer; no pointer survives a call ([0035](decisions/0035_portable_voice_c_abi.md)). | | ASR bridge | `Sources/voice_asr_bridge`, `Sources/voice_asr_runtime` | One transcribe.cpp model, session, and optional VAD session; `ASRKernel` is its typed Swift owner. | Typed failures for no speech and incomplete decode; never partial text. | +| Model downloads | `Sources/hardware_controller_model_download` | Pinned catalog, HTTPS-only fetcher, verifying package assembler for both apps. | The only network code besides the Ollama adapter; on explicit tap only ([0062](decisions/0062_shared_pinned_model_downloads.md)). | | macOS ASR service | `Sources/hardware_controller_asr_service*`, `Contents/XPCServices/HardwareControllerASRService.xpc` | The only macOS code that links transcribe.cpp; revalidates the package digest, then decodes. | A ggml abort, Metal fault, or hang kills only this process; no entitlements. | | Speech engine | `SpeechEngineSessionFactory`, `TranscribeCppSpeechEngine`, `TranscribeCppSpeechRecognitionSession` | Engine selection, supervision (deadline, CPU retry, disable after two crashes, idle kill), Apple fallback from the buffered recording. | A missing package refuses the session; it never silently uses Apple. Every fallback is announced and recorded. | | Presentation | `HardwareControllerApp` views and models | Main-actor rendering of snapshots; forwards intents. | Owns no hardware, transactions, or transcription lifecycle. | diff --git a/docs/decisions/0062_shared_pinned_model_downloads.md b/docs/decisions/0062_shared_pinned_model_downloads.md new file mode 100644 index 0000000..818a087 --- /dev/null +++ b/docs/decisions/0062_shared_pinned_model_downloads.md @@ -0,0 +1,47 @@ +# 0062: Offer pinned speech-model downloads on iOS through one shared module + +- **Status:** Accepted +- **Date:** 2026-11-02 +- **Amends:** [0049](0049_ios_offline_storage_enforcement.md), [0060](0060_macos_transcribe_cpp_default_asr.md), [0061](0061_ios_bundled_starter_model.md) + +## Context + +Voice Input bundled only Whisper Tiny English. Better models needed a Mac +script, a Files copy, and an import the owner found unusable. The owner asked +for one-tap downloads like the Mac app. iOS had no network code (0049); the Mac +already downloads one pinned package (0060). + +## Decision matrix + +| Criterion | Shared pinned downloader | Bundle every model | iOS-only downloader | +| --- | --- | --- | --- | +| One tap to a better model | 🟢 Yes. | 🟢 Already there. | 🟢 Yes. | +| App size | 🟢 +0 MB. | 🔴 +1.1 GB. | 🟢 +0 MB. | +| One implementation of fetch, pins, and verification | 🟢 Both apps. | 🟢 No fetch. | 🔴 Two copies to drift. | +| Network exposure | 🟡 HTTPS to pinned Hugging Face commits, on tap only. | 🟢 None. | 🟡 Same, twice. | + +## Decision + +`Sources/hardware_controller_model_download` holds the catalog, the HTTPS-only +fetcher, and the assembler that verifies each file's pinned size and SHA-256, +then writes the notice and manifest. The Mac installs the result into its single +store; Voice Input admits it through its Rust-validated installer as publisher +verified, then selects it. + +Voice Input offers Whisper Base English (85 MB), Whisper Small English (270 MB), +and Parakeet TDT 0.6B v3 (741 MB, the Mac default) beside the bundled Whisper +Tiny English. Downloads start only from an explicit tap, carry no cookies, +cache, or credentials, refuse non-HTTPS redirects, and never send audio or text. +Import from Files remains for packages made on a Mac. + +The fetcher is the only network code in Voice Input; only the containing app +links it, never the keyboard or widgets. + +## Consequences + +- `scripts/check_conventions.sh` allows network APIs only in the fetcher and the + Ollama adapter; `scripts/check_ios_local_only.sh` fails if anything but the + app links the download module; `scripts/build_ios_device.sh` fails if an + extension contains it. +- Updating a pin is a reviewed change to `asr_model_catalog.swift`; tests check + every entry is HTTPS, commit-pinned, and fully digested. diff --git a/docs/decisions/0063_center_signal_app_icons.md b/docs/decisions/0063_center_signal_app_icons.md new file mode 100644 index 0000000..354c8af --- /dev/null +++ b/docs/decisions/0063_center_signal_app_icons.md @@ -0,0 +1,34 @@ +# 0063: Replace Signal Bridge with the Center Signal icon family + +- **Status:** Accepted +- **Date:** 2026-11-02 +- **Supersedes:** the provisional icon in [0025](0025_public_source_publication.md) + +## Context + +Signal Bridge was provisional. The owner judged it and its iOS crop poor: the +glossy raster did not read at small sizes, and the nodes did not say "pedal" or +"voice". + +## Decision matrix + +| Criterion | Center Signal (code-drawn) | Revised raster | System symbols | +| --- | --- | --- | --- | +| Says what each app is | 🟢 Pedals; a voice waveform. | 🟡 Depends on the art. | 🟡 Generic. | +| Legible at 16 px | 🟢 Three or five bold capsules. | 🟠 Gloss and fine detail. | 🟢 Yes. | +| Reproducible and reviewable | 🟢 One Swift script, no tools. | 🔴 Opaque binary. | 🟢 Yes. | +| Matches the style (amber = active) | 🟢 Only the center is amber. | 🟡 Varies. | 🟠 No identity. | + +## Decision + +`tools/app_icons/render_app_icons.swift` draws both icons with CoreGraphics on a +graphite tile with rounded capsules. Hardware Controller shows three pedals from +above with the center pedal amber. Voice Input shows a five-bar waveform with +the center bar amber. macOS gets its own continuous-corner tile and shadow on +Apple's 824-point grid; iOS is full-bleed for the system mask. + +## Consequences + +- Regenerate with `swift tools/app_icons/render_app_icons.swift`; the PNGs are + committed outputs. +- BRANDING and the README style section name Center Signal. diff --git a/docs/user_guide.md b/docs/user_guide.md index b003759..460cc66 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -171,17 +171,22 @@ recovery copy; files from a newer app are left untouched. 3. To dictate into other apps, enable the **Voice Keyboard** with Full Access (**Settings → Setup** shows the steps). -Other models: prepare one on the Mac with -`scripts/prepare_ios_asr_model_package.sh <dir>`, copy it into Files, then -**Settings → Speech model → Import Model package…** and tap it to use it. +Better models: **Settings → Speech model → Download** fetches Whisper Base +English, Whisper Small English, or Parakeet v3 (the Mac default), checks every +file against a pinned checksum, and switches to it. Tap any installed model to +use it; swipe to remove it. **Import from Files…** still accepts a package made +with `scripts/prepare_ios_asr_model_package.sh`. + +History: swipe a dictation to delete it with its audio, or **⋯ → Clear All +History**. The app records and transcribes; iOS forbids microphone use inside a keyboard. Start from the keyboard, the app, Control Center, the Lock Screen, the Action button, or Siri/Shortcuts. The keyboard inserts the result once; if the field does not confirm it, **Recover…** offers one retry or a local copy that expires in ten minutes. Everything else falls back to **Voice Input → History**, where -audio cleanup defaults to 90 days, 1 GiB, or 2,000 recordings. The app has no -network access and never downloads models. +audio cleanup defaults to 90 days, 1 GiB, or 2,000 recordings. The app goes +online only when you tap **Download**, and never sends audio or text. ## Troubleshooting diff --git a/docs/ux_spec.md b/docs/ux_spec.md index 657d723..0f6276e 100644 --- a/docs/ux_spec.md +++ b/docs/ux_spec.md @@ -18,7 +18,8 @@ typography, spacing, and position carry everything else. | Profiles | Stable list and editor; active state is separate from selection. | | General | Native form sections: Speech recognition (first), Appearance, microphone, Local AI, Voice chord, History storage, Launch at Login. Apple stays visibly marked as the less accurate engine while selected. | | iOS Dictate | One centered record button: primary-colored at rest, amber only while recording. Status title and one line above it; the timer line is always reserved so nothing moves. Finished text above with quiet **Copy** and **Share**. | -| iOS History / Settings | Native searchable list with detail; Settings is a Form: Setup, Speech model, History storage, Without the keyboard, notices. | +| iOS History / Settings | Native searchable list with detail; swipe or detail **Delete Dictation**, **⋯ → Clear All History**, each confirmed. Settings is a Form: Setup, Speech model (built in, then downloads with size, progress, and Cancel; ✓ marks the active one), Import from Files, History storage, Without the keyboard, notices. | +| iOS Voice Keyboard | System-proportioned keys with letter, number, and symbol layers. One bar above: Style menu, a one-line status in plain words, and a round mic that is primary-colored at rest and amber while the app records. | | Transcript HUD | Click-through, nonactivating; only while a target cannot show live text and text exists; anchored to the caret or one stable edge, never the pointer. | ## Behavior diff --git a/docs/voice_cujs.md b/docs/voice_cujs.md index d06d8f9..1a2a8b5 100644 --- a/docs/voice_cujs.md +++ b/docs/voice_cujs.md @@ -56,7 +56,7 @@ events. | I1 | Onboard locally | A fresh install provisions the pinned starter model; microphone asked on first tap with exact denial recovery; imported packages still admitted. | `voice_input_starter_model_test`, `voice_input_asr_model_registry_test`, `voice_input_dictation_presentation_test`, `voice_input_onboarding_policy_test`, `voice_input_model_package_{stager,validator,installer}_test`, `testSettingsShowTheVerifiedModelSetupAndStorage` | | I2 | Dictate from the keyboard | Real local ASR, live pause-cut decoding, History commit before publish, one insertion. | `voice_input_asr_workflow_test`, `voice_input_local_transcriber_test`, `voice_input_live_audio_tap_test`, `voice_input_session_finalizer_test`, `voice_asr_bridge_test.c` | | I3 | Start when the app is cold | System surfaces foreground the app honestly. | `voice_input_system_capture_test`, `scripts/check_ios_system_capture_metadata.sh` | -| I4 | Type without Full Access | QWERTY works; voice explains what it needs. | `voice_input_keyboard_policy_test` | +| I4 | Type without Full Access | Letters, numbers, symbols, auto-capitals, and double-space period work; voice explains what it needs. | `voice_input_keyboard_policy_test`, `voice_input_keyboard_layout_test` | | I5 | Respect unsupported fields | Constrained or sensitive fields disable voice only. | `voice_input_host_field_policy_test`, `voice_input_delivery_target_policy_test` | | I6 | Choose a Style | Keyboard Style travels with its exact session. | `voice_input_style_test`, `voice_input_style_mapping_test`, `voice_input_style_preference_test`, `testStyleMenuOffersEveryCanonicalStyle` | | I7 | Survive interruptions | Typed lifecycle policy; no auto-resume; partial audio recovered. | `voice_input_lifecycle_policy_test`, `voice_input_lifecycle_notification_mapper_test`, `voice_input_capture_service_test` | @@ -64,6 +64,8 @@ events. | I9 | Recover failed insertion | One retry or a ten-minute local copy, only for the exact result and field. | `voice_input_insertion_recovery_policy_test` | | I10 | Stay offline within limits | No network code; age, bytes, count, reserve; Models never evicted implicitly. | `voice_input_history_repository_test`, `voice_input_history_retention_preferences_test`, `scripts/check_ios_local_only.sh`, `testSettingsShowTheVerifiedModelSetupAndStorage` | | I11 | Capture without the keyboard | Control Center, Lock Screen, Action button, Siri, Live Activity stop. | `voice_input_system_capture_test`, `testSettingsShowTheVerifiedModelSetupAndStorage` | +| I12 | Delete History | One dictation or all of it, with its audio; an in-progress capture survives; the empty state survives relaunch. | `voice_input_history_repository_test`, `voice_input_history_model_test`, `testHistoryDeletesOneDictationThenClearsAll` | +| I13 | Download a better model | One tap fetches a pinned package, verifies every digest, admits it through Rust, and activates it; a corrupt file installs nothing. | `asr_model_catalog_test`, `asr_model_package_assembler_test`, `voice_input_model_library_model_test`, `voice_input_model_choice_test`, `testSettingsShowTheVerifiedModelSetupAndStorage` | ## Accepted defaults diff --git a/packaging/app_icon_source.png b/packaging/app_icon_source.png index 16314b7..347f295 100644 Binary files a/packaging/app_icon_source.png and b/packaging/app_icon_source.png differ diff --git a/scripts/build_ios_device.sh b/scripts/build_ios_device.sh index f883661..e9e0934 100755 --- a/scripts/build_ios_device.sh +++ b/scripts/build_ios_device.sh @@ -121,8 +121,9 @@ for extension_bundle in "$app_bundle"/PlugIns/*.appex; do if [[ "$extension_dependencies" == *"CTranscribe.framework/CTranscribe"* \ || "$extension_symbols" == *"_voice_asr_context_"* \ || "$extension_symbols" == *"_transcribe_"* \ - || "$extension_symbols" == *"_voice_asr_model_resolve_v2"* ]]; then - print -u2 "$extension_bundle must not link model or ASR runtime code." + || "$extension_symbols" == *"_voice_asr_model_resolve_v2"* \ + || "$extension_symbols" == *"URLSessionModelFileFetcher"* ]]; then + print -u2 "$extension_bundle must not link model, download, or ASR runtime code." exit 1 fi done diff --git a/scripts/check_conventions.sh b/scripts/check_conventions.sh index 427c418..4c54332 100755 --- a/scripts/check_conventions.sh +++ b/scripts/check_conventions.sh @@ -56,15 +56,15 @@ for import_line in "${(@f)$(git grep -hE '^(@testable )?import ' -- 'Sources/Har [[ "$import_line" == "import Foundation" ]] || failures+=("HardwareControllerCore: forbidden ${import_line}") done -# Only the fixed-loopback Ollama adapter and the pinned speech-model downloader -# (decision 0060) may use a network API on macOS. +# Only the fixed-loopback Ollama adapter and the pinned speech-model fetcher +# (decisions 0060, 0062) may use a network API. network_files=( Sources/HardwareControllerMac/ollama_local_ai_refiner.swift - Sources/HardwareControllerMac/asr_model_downloader.swift + Sources/hardware_controller_model_download/asr_model_file_fetcher.swift ) for file in "${(@f)$(git grep -lwE '(URLSession|NWConnection|NWListener|CFStream)' -- 'Sources/*.swift' || true)}"; do [[ -z "$file" || ${network_files[(Ie)$file]} -gt 0 ]] && continue - failures+=("$file: network APIs are confined to the Ollama adapter and model downloader") + failures+=("$file: network APIs are confined to the Ollama adapter and model fetcher") done # Xcode's bundled formatter differs per Xcode release; only the pinned one lints. diff --git a/scripts/check_ios_local_only.sh b/scripts/check_ios_local_only.sh index 989500d..d7d0542 100755 --- a/scripts/check_ios_local_only.sh +++ b/scripts/check_ios_local_only.sh @@ -2,6 +2,11 @@ set -euo pipefail +# Voice Input's own sources stay free of network code. Its one network path is +# the shared, pinned speech-model fetcher in +# Sources/hardware_controller_model_download (decision 0062), which only the +# containing app links; the extensions never do. + repo_root="$(cd "$(dirname "$0")/.." && pwd)" cd "$repo_root" @@ -34,4 +39,14 @@ if rg -n --glob '*.entitlements' --glob '*.plist' --glob '*.yml' \ exit 1 fi +# Only the containing app may link the model-download module. +linking_targets="$(awk ' + /^ [A-Za-z]+:$/ { target = $1 } + /target: HardwareControllerModelDownload$/ { print target } +' apps/ios/voice_input/project.yml | sort -u | tr '\n' ' ')" +[[ "$linking_targets" == "VoiceInput: VoiceInputTests: " ]] || { + print -u2 "iOS local-only check failed: model downloads linked by $linking_targets" + exit 1 +} + print "iOS local-only boundary: PASS" diff --git a/tools/app_icons/render_app_icons.swift b/tools/app_icons/render_app_icons.swift new file mode 100644 index 0000000..ed0c885 --- /dev/null +++ b/tools/app_icons/render_app_icons.swift @@ -0,0 +1,255 @@ +// Renders both app icons from code so the art is reproducible and reviewable. +// Run from the repository root: swift tools/app_icons/render_app_icons.swift +// +// One family: a graphite tile, rounded capsules, and amber for the center +// element, matching the apps' rule that amber marks the active thing. +// Hardware Controller shows three pedals from above with the center pedal lit; +// Voice Input shows a five-bar voice waveform with the center bar lit. + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +let size = 1_024 + +struct RGB { + let red: CGFloat + let green: CGFloat + let blue: CGFloat + + init(_ hex: UInt32) { + red = CGFloat((hex >> 16) & 0xff) / 255 + green = CGFloat((hex >> 8) & 0xff) / 255 + blue = CGFloat(hex & 0xff) / 255 + } + + func color(alpha: CGFloat = 1) -> CGColor { + CGColor(srgbRed: red, green: green, blue: blue, alpha: alpha) + } +} + +let graphiteTop = RGB(0x3A3D44) +let graphiteBottom = RGB(0x15161A) +let amberTop = RGB(0xFFD15C) +let amberBottom = RGB(0xF39A12) +let chalkTop = RGB(0xF7F7F5) +let chalkBottom = RGB(0xC4C6CB) + +func makeContext() -> CGContext { + guard + let context = CGContext( + data: nil, + width: size, + height: size, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + fatalError("Could not create a bitmap context.") + } + // Draw in top-left coordinates like the design grid. + context.translateBy(x: 0, y: CGFloat(size)) + context.scaleBy(x: 1, y: -1) + return context +} + +func linearGradient(_ top: RGB, _ bottom: RGB) -> CGGradient { + guard + let gradient = CGGradient( + colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), + colors: [top.color(), bottom.color()] as CFArray, + locations: [0, 1] + ) + else { + fatalError("Could not create a gradient.") + } + return gradient +} + +/// A continuous-corner rounded square (superellipse), like Apple's icon shape. +func squircle(in rect: CGRect, exponent: CGFloat = 5) -> CGPath { + let path = CGMutablePath() + let steps = 720 + for step in 0...steps { + let angle = CGFloat(step) / CGFloat(steps) * 2 * .pi + let cosine = cos(angle) + let sine = sin(angle) + let x = pow(abs(cosine), 2 / exponent) * (cosine < 0 ? -1 : 1) + let y = pow(abs(sine), 2 / exponent) * (sine < 0 ? -1 : 1) + let point = CGPoint(x: rect.midX + x * rect.width / 2, y: rect.midY + y * rect.height / 2) + step == 0 ? path.move(to: point) : path.addLine(to: point) + } + path.closeSubpath() + return path +} + +func fill(_ path: CGPath, in context: CGContext, top: RGB, bottom: RGB) { + let box = path.boundingBox + context.saveGState() + context.addPath(path) + context.clip() + context.drawLinearGradient( + linearGradient(top, bottom), + start: CGPoint(x: box.midX, y: box.minY), + end: CGPoint(x: box.midX, y: box.maxY), + options: [] + ) + context.restoreGState() +} + +/// The graphite surface with a faint top sheen. +func drawTile(_ path: CGPath, in context: CGContext) { + fill(path, in: context, top: graphiteTop, bottom: graphiteBottom) + let box = path.boundingBox + context.saveGState() + context.addPath(path) + context.clip() + guard + let sheen = CGGradient( + colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), + colors: [CGColor(gray: 1, alpha: 0.10), CGColor(gray: 1, alpha: 0)] as CFArray, + locations: [0, 1] + ) + else { + fatalError("Could not create the sheen.") + } + context.drawRadialGradient( + sheen, + startCenter: CGPoint(x: box.midX, y: box.minY), + startRadius: 0, + endCenter: CGPoint(x: box.midX, y: box.minY), + endRadius: box.height * 0.75, + options: [] + ) + context.restoreGState() +} + +/// A capsule with a soft cast shadow, a vertical gradient, and an optional +/// inner glow for the lit element. +func drawCapsule( + _ rect: CGRect, + in context: CGContext, + top: RGB, + bottom: RGB, + glow: RGB? = nil, + treads: Int = 0 +) { + let radius = rect.width / 2 + let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) + context.saveGState() + if let glow { + context.setShadow(offset: .zero, blur: 70, color: glow.color(alpha: 0.55)) + } else { + context.setShadow(offset: CGSize(width: 0, height: 18), blur: 36, color: CGColor(gray: 0, alpha: 0.55)) + } + context.addPath(path) + context.setFillColor(bottom.color()) + context.fillPath() + context.restoreGState() + fill(path, in: context, top: top, bottom: bottom) + + // Pedal treads: short recessed bars across the upper face. + guard treads > 0 else { + return + } + let treadWidth = rect.width * 0.46 + let treadHeight = rect.width * 0.075 + let spacing = treadHeight * 2.6 + let firstY = rect.minY + rect.width * 0.62 + context.saveGState() + context.setFillColor(CGColor(gray: 0, alpha: 0.16)) + for index in 0..<treads { + let tread = CGRect( + x: rect.midX - treadWidth / 2, + y: firstY + CGFloat(index) * spacing, + width: treadWidth, + height: treadHeight + ) + context.addPath( + CGPath( + roundedRect: tread, + cornerWidth: treadHeight / 2, + cornerHeight: treadHeight / 2, + transform: nil + )) + context.fillPath() + } + context.restoreGState() +} + +func centered(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> CGRect { + CGRect(x: x - width / 2, y: y - height / 2, width: width, height: height) +} + +func write(_ context: CGContext, to path: String) { + guard + let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + URL(fileURLWithPath: path) as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) + else { + fatalError("Could not encode \(path).") + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + fatalError("Could not write \(path).") + } + print(path) +} + +/// macOS draws its own tile on Apple's 824-point grid with a drop shadow. +func renderMac() { + let context = makeContext() + let tileRect = CGRect(x: 100, y: 100, width: 824, height: 824) + let tile = squircle(in: tileRect) + context.saveGState() + context.setShadow(offset: CGSize(width: 0, height: 12), blur: 28, color: CGColor(gray: 0, alpha: 0.35)) + context.addPath(tile) + context.setFillColor(graphiteBottom.color()) + context.fillPath() + context.restoreGState() + drawTile(tile, in: context) + + let sideWidth: CGFloat = 132 + let sideHeight: CGFloat = 384 + let centerWidth: CGFloat = 176 + let centerHeight: CGFloat = 492 + drawCapsule( + centered(x: 512 - 222, y: 548, width: sideWidth, height: sideHeight), + in: context, top: chalkTop, bottom: chalkBottom, treads: 3) + drawCapsule( + centered(x: 512 + 222, y: 548, width: sideWidth, height: sideHeight), + in: context, top: chalkTop, bottom: chalkBottom, treads: 3) + drawCapsule( + centered(x: 512, y: 518, width: centerWidth, height: centerHeight), + in: context, top: amberTop, bottom: amberBottom, glow: amberBottom, treads: 4) + write(context, to: "packaging/app_icon_source.png") +} + +/// iOS masks the icon itself, so the tile fills the whole square. +func renderIOS() { + let context = makeContext() + drawTile(CGPath(rect: CGRect(x: 0, y: 0, width: size, height: size), transform: nil), in: context) + let barWidth: CGFloat = 96 + let pitch: CGFloat = 150 + let heights: [CGFloat] = [280, 476, 660, 476, 280] + for (index, height) in heights.enumerated() { + let rect = centered( + x: 512 + CGFloat(index - 2) * pitch, y: 512, width: barWidth, height: height) + if index == 2 { + drawCapsule(rect, in: context, top: amberTop, bottom: amberBottom, glow: amberBottom) + } else { + drawCapsule(rect, in: context, top: chalkTop, bottom: chalkBottom) + } + } + write(context, to: "apps/ios/voice_input/resources/assets.xcassets/AppIcon.appiconset/app_icon.png") +} + +renderMac() +renderIOS()