From 4cc84f3a9dc6b35d2f5db78552d3406eeac2cfb0 Mon Sep 17 00:00:00 2001 From: MarcusJRLee <7527115+MarcusJRLee@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:42:49 -0500 Subject: [PATCH] Add model downloads, History deletion, a new keyboard, and new icons - Voice Input: one-tap downloads of Whisper Base, Whisper Small, and Parakeet v3 through a module shared with the Mac (decision 0062); every file is pinned and verified, then Rust-admitted and activated. Import from Files moves to its own section. - History: swipe or detail delete with audio, and Clear All History. - Voice Keyboard: system-proportioned keys, number and symbol layers, auto-capitals, double-space period, hold-to-delete, and a voice bar whose mic turns amber while the app records; plain-language status. - Center Signal icons for both apps, drawn by tools/app_icons/render_app_icons.swift (decision 0063). Co-Authored-By: Claude Opus 5.5 --- BRANDING.md | 2 +- CONTRIBUTING.md | 2 + Package.swift | 13 + README.md | 6 +- .../speech_engine_model.swift | 1 + .../asr_model_downloader.swift | 339 +---------------- .../asr_model_catalog.swift | 222 ++++++++++++ .../asr_model_file_fetcher.swift | 83 +++++ .../asr_model_package_assembler.swift | 179 +++++++++ .../speech_engine_model_test.swift | 1 + .../asr_model_downloader_test.swift | 10 +- .../asr_model_catalog_test.swift | 40 ++ .../asr_model_file_fetcher_test.swift | 17 + .../asr_model_package_assembler_test.swift | 97 +++++ Tests/untested_sources.txt | 5 +- apps/ios/voice_input/README.md | 6 +- .../VoiceInput.xcodeproj/project.pbxproj | 169 ++++++++- .../ios/voice_input/app/voice_input_app.swift | 6 +- .../app/voice_input_dictate_view.swift | 3 +- .../app/voice_input_history_model.swift | 29 ++ .../app/voice_input_history_repository.swift | 58 +++ .../app/voice_input_history_view.swift | 71 +++- .../app/voice_input_model_choice.swift | 83 +++++ .../app/voice_input_model_downloader.swift | 58 +++ .../app/voice_input_model_library_model.swift | 83 ++++- .../app/voice_input_model_library_view.swift | 173 +++++---- .../voice_input/app/voice_input_theme.swift | 7 - .../keyboard/keyboard_keys_view.swift | 253 +++++++++++++ .../keyboard/keyboard_view_controller.swift | 341 +++++++++++------- apps/ios/voice_input/project.yml | 17 + .../AppIcon.appiconset/app_icon.png | Bin 1147613 -> 733535 bytes .../shared/voice_input_keyboard_layout.swift | 131 +++++++ .../shared/voice_input_theme.swift | 8 + .../voice_input_history_model_test.swift | 32 ++ .../voice_input_history_repository_test.swift | 70 ++++ .../voice_input_keyboard_layout_test.swift | 64 ++++ .../tests/voice_input_model_choice_test.swift | 88 +++++ .../voice_input_model_downloader_test.swift | 56 +++ ...voice_input_model_library_model_test.swift | 120 ++++++ .../ui_tests/voice_input_ui_test.swift | 62 +++- docs/architecture.md | 1 + .../0062_shared_pinned_model_downloads.md | 47 +++ .../decisions/0063_center_signal_app_icons.md | 34 ++ docs/user_guide.md | 15 +- docs/ux_spec.md | 3 +- docs/voice_cujs.md | 4 +- packaging/app_icon_source.png | Bin 1208005 -> 529013 bytes scripts/build_ios_device.sh | 5 +- scripts/check_conventions.sh | 8 +- scripts/check_ios_local_only.sh | 15 + tools/app_icons/render_app_icons.swift | 255 +++++++++++++ 51 files changed, 2824 insertions(+), 568 deletions(-) create mode 100644 Sources/hardware_controller_model_download/asr_model_catalog.swift create mode 100644 Sources/hardware_controller_model_download/asr_model_file_fetcher.swift create mode 100644 Sources/hardware_controller_model_download/asr_model_package_assembler.swift create mode 100644 Tests/hardware_controller_model_download_tests/asr_model_catalog_test.swift create mode 100644 Tests/hardware_controller_model_download_tests/asr_model_file_fetcher_test.swift create mode 100644 Tests/hardware_controller_model_download_tests/asr_model_package_assembler_test.swift create mode 100644 apps/ios/voice_input/app/voice_input_model_choice.swift create mode 100644 apps/ios/voice_input/app/voice_input_model_downloader.swift delete mode 100644 apps/ios/voice_input/app/voice_input_theme.swift create mode 100644 apps/ios/voice_input/keyboard/keyboard_keys_view.swift create mode 100644 apps/ios/voice_input/shared/voice_input_keyboard_layout.swift create mode 100644 apps/ios/voice_input/shared/voice_input_theme.swift create mode 100644 apps/ios/voice_input/tests/voice_input_keyboard_layout_test.swift create mode 100644 apps/ios/voice_input/tests/voice_input_model_choice_test.swift create mode 100644 apps/ios/voice_input/tests/voice_input_model_downloader_test.swift create mode 100644 docs/decisions/0062_shared_pinned_model_downloads.md create mode 100644 docs/decisions/0063_center_signal_app_icons.md create mode 100644 tools/app_icons/render_app_icons.swift 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 e18941ecd6b25eb42a5286683eab8e6c626409d5..ec5d3f4eb9e8e260acc80a9412ac370006777648 100644 GIT binary patch literal 733535 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 dzGR9O9xE>PTKm%9C!O-<_mg8Q}|7 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