feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
Migrate keyboard dictation to continuous Flow sessions with auto-start, tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
This commit is contained in:
@@ -12,18 +12,15 @@ public enum AppGroup {
|
||||
|
||||
/// Whether the App Group container is available on this device.
|
||||
///
|
||||
/// Cached at first read — the underlying `UserDefaults(suiteName:)`
|
||||
/// call is cheap, but main-app startup and every keyboard-extension
|
||||
/// read hit it, so we memoize the result.
|
||||
///
|
||||
/// Production code paths MUST go through `isAvailable` first and
|
||||
/// surface a friendly error view (e.g. `AppGroupErrorView`) on the
|
||||
/// main app, or the keyboard extension's persisted-locale load.
|
||||
/// Calling `defaults` directly when the group is missing will trip
|
||||
/// the DEBUG `fatalError` below — that path is reserved for
|
||||
/// developer-only escape hatches and intentional debugging.
|
||||
/// Checks both `UserDefaults(suiteName:)` *and* the on-disk container.
|
||||
/// The suite alone can appear to open while the container is still `(null)`
|
||||
/// when provisioning is misconfigured — that case produces the
|
||||
/// `CFPrefsPlistSource … Container: (null)` console warning.
|
||||
public static let isAvailable: Bool = {
|
||||
UserDefaults(suiteName: identifier) != nil
|
||||
guard UserDefaults(suiteName: identifier) != nil else { return false }
|
||||
return FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: identifier
|
||||
) != nil
|
||||
}()
|
||||
|
||||
/// Shared UserDefaults instance for cross-process config.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// EngineServiceLabel.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Human-readable summary of the active engine / AI provider for UI hints.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum EngineServiceLabel {
|
||||
public static func summary(
|
||||
engineMode: String,
|
||||
providerId: String,
|
||||
model: String
|
||||
) -> String {
|
||||
let isChinese = Locale.preferredLanguages.first?.hasPrefix("zh") == true
|
||||
let prefix = isChinese ? "当前:" : "Active: "
|
||||
if engineMode == "local" {
|
||||
return isChinese
|
||||
? "\(prefix)本地引擎 · Apple SpeechAnalyzer"
|
||||
: "\(prefix)On-device · Apple SpeechAnalyzer"
|
||||
}
|
||||
let provider = LLMProvider.provider(id: providerId)
|
||||
let trimmedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedModel.isEmpty { return "\(prefix)\(provider.name)" }
|
||||
return "\(prefix)\(provider.name) · \(trimmedModel)"
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
static let modeId = "config.modeId"
|
||||
static let localeId = "config.localeId"
|
||||
static let engineMode = "config.engineMode"
|
||||
static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
@@ -66,6 +67,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
@Published public var engineMode: String {
|
||||
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
|
||||
}
|
||||
@Published public var hasCompletedOnboarding: Bool {
|
||||
didSet { defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding) }
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
// Local engine (on-device ASR only) doesn't need an API key,
|
||||
@@ -73,10 +77,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// Treat it as always-configured so onboarding's "Next" button
|
||||
// enables the moment the user picks the local path, instead
|
||||
// of forcing them to fill in cloud fields they won't use.
|
||||
if engineMode == "local" { return true }
|
||||
if isLocalEngine { return true }
|
||||
return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||
}
|
||||
|
||||
/// On-device ASR only — no cloud LLM polish.
|
||||
public var isLocalEngine: Bool { engineMode == "local" }
|
||||
|
||||
/// The system prompt the user *sees* in the editor — fall back to the
|
||||
/// provider-aware default from `AppGroupStore` when nothing is set.
|
||||
public var defaultSystemPrompt: String {
|
||||
@@ -85,25 +92,27 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
let pid = defaults.string(forKey: Key.providerId) ?? "openai"
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
|
||||
self.defaults = resolvedDefaults
|
||||
let pid = resolvedDefaults.string(forKey: Key.providerId) ?? "openai"
|
||||
let preset = LLMProvider.provider(id: pid)
|
||||
self.providerId = pid
|
||||
self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
||||
self.baseURL = resolvedDefaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
||||
|
||||
// Resolve the API key with a one-shot migration from the legacy
|
||||
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
|
||||
// is empty in the suite and all subsequent reads go through the
|
||||
// Keychain.
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: defaults)
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults)
|
||||
|
||||
self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||
self.systemPrompt = defaults.string(forKey: Key.systemPrompt)
|
||||
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||
self.systemPrompt = resolvedDefaults.string(forKey: Key.systemPrompt)
|
||||
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
||||
self.modeId = defaults.string(forKey: Key.modeId) ?? "polish"
|
||||
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
|
||||
self.engineMode = defaults.string(forKey: Key.engineMode) ?? "cloud"
|
||||
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
|
||||
self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto"
|
||||
self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud"
|
||||
self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding)
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time
|
||||
|
||||
@@ -114,72 +114,111 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var analyzer: SpeechAnalyzer?
|
||||
private var analyzerTask: Task<Void, Never>?
|
||||
private var analyzerFinished = false
|
||||
|
||||
/// Canonical capture format: 16 kHz mono Float32 from `AudioCaptureService`
|
||||
/// / `PreviewASRController` before it reaches SpeechAnalyzer.
|
||||
private static let captureFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { continuation in
|
||||
// SpeechAnalyzer is always fully on-device.
|
||||
continuation.yield(.capability(onDeviceSupported: true))
|
||||
|
||||
let transcriber = DictationTranscriber(locale: locale, preset: .progressiveShortDictation)
|
||||
let newAnalyzer = SpeechAnalyzer(modules: [transcriber])
|
||||
self.lock.withLock { self.analyzer = newAnalyzer }
|
||||
|
||||
// iOS 26's `DictationTranscriber` requires **Int16** PCM
|
||||
// (precondition `"Audio sample data must be 16-bit signed
|
||||
// integers"` — the legacy recognizer used Float32 at this
|
||||
// boundary; `SpeechAnalyzer` is strict Int16). 16 kHz mono, Int16,
|
||||
// interleaved — the canonical layout Apple's Speech
|
||||
// framework examples use.
|
||||
let audioFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatInt16,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: true
|
||||
)!
|
||||
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await newAnalyzer.prepareToAnalyze(in: audioFormat)
|
||||
|
||||
let inputStream = self.makeInputStream(from: stream, format: audioFormat)
|
||||
|
||||
// Feed audio in a child task so we can concurrently
|
||||
// iterate `transcriber.results` on the outer task.
|
||||
// After the audio stream ends, finalize so the results
|
||||
// sequence can drain and complete.
|
||||
let feedTask = Task {
|
||||
do {
|
||||
try await newAnalyzer.start(inputSequence: inputStream)
|
||||
try await newAnalyzer.finalizeAndFinishThroughEndOfInput()
|
||||
} catch {}
|
||||
self.lock.withLock { self.analyzerFinished = false }
|
||||
defer {
|
||||
self.lock.withLock {
|
||||
self.analyzer = nil
|
||||
self.analyzerTask = nil
|
||||
self.analyzerFinished = true
|
||||
}
|
||||
defer { feedTask.cancel() }
|
||||
}
|
||||
|
||||
var lastText = ""
|
||||
do {
|
||||
guard let resolvedLocale = await DictationTranscriber.supportedLocale(equivalentTo: locale) else {
|
||||
Self.debug("locale unsupported: \(locale.identifier(.bcp47))")
|
||||
continuation.yield(.error("当前系统未分配可用语音语言模型,请稍后重试或切换语言"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
let transcriber = DictationTranscriber(locale: resolvedLocale, preset: .progressiveShortDictation)
|
||||
do {
|
||||
try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale)
|
||||
} catch {
|
||||
Self.debug("asset prepare failed: \(error.localizedDescription)")
|
||||
continuation.yield(.error("语音语言资源未就绪,请稍后重试"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
let newAnalyzer = SpeechAnalyzer(modules: [transcriber])
|
||||
self.lock.withLock { self.analyzer = newAnalyzer }
|
||||
|
||||
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(
|
||||
compatibleWith: [transcriber],
|
||||
considering: Self.captureFormat
|
||||
) else {
|
||||
continuation.yield(.error("当前设备不支持该语音输入格式"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
try await newAnalyzer.prepareToAnalyze(in: analyzerFormat)
|
||||
|
||||
let inputStream = self.makeInputStream(from: stream, analyzerFormat: analyzerFormat)
|
||||
|
||||
// Apple recommends consuming `transcriber.results` concurrently
|
||||
// while `analyzeSequence` drains the input stream.
|
||||
let resultsTask = Task<String, Error> {
|
||||
var lastText = ""
|
||||
for try await result in transcriber.results {
|
||||
if Task.isCancelled { break }
|
||||
// `result.text` is an AttributedString; extract plain text.
|
||||
let text = result.text.characters.map(String.init).joined()
|
||||
let text = String(result.text.characters)
|
||||
guard !text.isEmpty, text != lastText else { continue }
|
||||
lastText = text
|
||||
continuation.yield(.partial(text))
|
||||
}
|
||||
} catch {
|
||||
// Results sequence threw — likely cancellation.
|
||||
return lastText
|
||||
}
|
||||
|
||||
if !Task.isCancelled {
|
||||
continuation.yield(.final(lastText))
|
||||
let lastSampleTime = try await newAnalyzer.analyzeSequence(inputStream)
|
||||
|
||||
if let lastSampleTime {
|
||||
try await newAnalyzer.finalizeAndFinish(through: lastSampleTime)
|
||||
} else {
|
||||
try await newAnalyzer.cancelAndFinishNow()
|
||||
}
|
||||
|
||||
let lastText: String
|
||||
do {
|
||||
lastText = try await resultsTask.value
|
||||
} catch {
|
||||
Self.debug("transcriber results failed: \(error.localizedDescription)")
|
||||
continuation.yield(.error(error.localizedDescription))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
let trimmed = lastText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
continuation.yield(.error("未识别到语音内容,请重试"))
|
||||
} else {
|
||||
continuation.yield(.final(trimmed))
|
||||
}
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish()
|
||||
} catch {
|
||||
Self.debug("SpeechAnalyzer failed: \(error.localizedDescription)")
|
||||
continuation.yield(.error(error.localizedDescription))
|
||||
continuation.finish()
|
||||
}
|
||||
@@ -192,64 +231,52 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
let (task, currentAnalyzer) = lock.withLock { () -> (Task<Void, Never>?, SpeechAnalyzer?) in
|
||||
let t = analyzerTask
|
||||
let a = analyzer
|
||||
analyzerTask = nil
|
||||
analyzer = nil
|
||||
return (t, a)
|
||||
private static func debug(_ message: String) {
|
||||
#if DEBUG
|
||||
print("🎙️[ASRService] \(message)")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func prepareAssetsIfNeeded(
|
||||
for transcriber: DictationTranscriber,
|
||||
locale: Locale
|
||||
) async throws {
|
||||
do {
|
||||
_ = try await AssetInventory.reserve(locale: locale)
|
||||
} catch {
|
||||
// Reservation may already exist or slots are full; continue.
|
||||
}
|
||||
task?.cancel()
|
||||
if let a = currentAnalyzer {
|
||||
Task { await a.cancelAndFinishNow() }
|
||||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||
try await request.downloadAndInstall()
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the `AudioBufferSnapshot` stream into the `AnalyzerInput` stream
|
||||
/// that `SpeechAnalyzer` consumes.
|
||||
///
|
||||
/// `AudioBufferSnapshot.samples` is `[Float]` (the transport format
|
||||
/// both `AudioCaptureService` and `PreviewASRController` produce —
|
||||
/// Float32 is what `AVAudioEngine` gives us at the hardware rate
|
||||
/// and we already downsample to 16 kHz mono before this point).
|
||||
/// iOS 26's `DictationTranscriber` requires **Int16** PCM at the
|
||||
/// `AnalyzerInput` boundary, so we convert per-snapshot here.
|
||||
///
|
||||
/// The conversion is the textbook `[-1.0, 1.0]` × 32767 + clip +
|
||||
/// cast. For a 16 kHz mono feed the loop is ~16k iters/sec —
|
||||
/// well under any audio-thread budget — so a simple scalar loop
|
||||
/// beats pulling in `vDSP` (which would also need a scratch
|
||||
/// buffer the audio thread can't easily allocate).
|
||||
func cancel() {
|
||||
let (task, currentAnalyzer, finished) = lock.withLock { () -> (Task<Void, Never>?, SpeechAnalyzer?, Bool) in
|
||||
let t = analyzerTask
|
||||
let a = analyzer
|
||||
let f = analyzerFinished
|
||||
analyzerTask = nil
|
||||
analyzer = nil
|
||||
return (t, a, f)
|
||||
}
|
||||
task?.cancel()
|
||||
guard !finished, let currentAnalyzer else { return }
|
||||
Task { await currentAnalyzer.cancelAndFinishNow() }
|
||||
}
|
||||
|
||||
/// Maps 16 kHz Float32 snapshots into `AnalyzerInput` using the format
|
||||
/// returned by `bestAvailableAudioFormat(compatibleWith:considering:)`.
|
||||
private func makeInputStream(
|
||||
from stream: AsyncStream<AudioBufferSnapshot>,
|
||||
format: AVAudioFormat
|
||||
analyzerFormat: AVAudioFormat
|
||||
) -> AsyncStream<AnalyzerInput> {
|
||||
AsyncStream { continuation in
|
||||
Task {
|
||||
for await snap in stream {
|
||||
guard !snap.samples.isEmpty else { continue }
|
||||
let capacity = AVAudioFrameCount(snap.samples.count)
|
||||
guard let pcm = AVAudioPCMBuffer(
|
||||
pcmFormat: format,
|
||||
frameCapacity: capacity
|
||||
) else { continue }
|
||||
pcm.frameLength = capacity
|
||||
|
||||
// For a 1-channel Int16 buffer (interleaved or not —
|
||||
// single channel, so the data layout is identical),
|
||||
// `int16ChannelData?[0]` gives us the raw sample
|
||||
// pointer. Clip on overflow to avoid wraparound
|
||||
// (a Float like 1.5 would otherwise become a
|
||||
// negative Int16 after the implicit truncation).
|
||||
if let dst = pcm.int16ChannelData?[0] {
|
||||
snap.samples.withUnsafeBufferPointer { src in
|
||||
ASRServiceFactory.convertFloat32ToInt16(
|
||||
source: src.baseAddress,
|
||||
sourceCount: src.count,
|
||||
destination: dst
|
||||
)
|
||||
}
|
||||
guard let pcm = Self.makeAnalyzerPCMBuffer(from: snap, format: analyzerFormat) else {
|
||||
continue
|
||||
}
|
||||
continuation.yield(AnalyzerInput(buffer: pcm))
|
||||
}
|
||||
@@ -257,4 +284,37 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeAnalyzerPCMBuffer(
|
||||
from snap: AudioBufferSnapshot,
|
||||
format: AVAudioFormat
|
||||
) -> AVAudioPCMBuffer? {
|
||||
let capacity = AVAudioFrameCount(snap.samples.count)
|
||||
guard capacity > 0,
|
||||
let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else {
|
||||
return nil
|
||||
}
|
||||
pcm.frameLength = capacity
|
||||
|
||||
switch format.commonFormat {
|
||||
case .pcmFormatInt16:
|
||||
guard let dst = pcm.int16ChannelData?[0] else { return nil }
|
||||
snap.samples.withUnsafeBufferPointer { src in
|
||||
ASRServiceFactory.convertFloat32ToInt16(
|
||||
source: src.baseAddress,
|
||||
sourceCount: src.count,
|
||||
destination: dst
|
||||
)
|
||||
}
|
||||
case .pcmFormatFloat32:
|
||||
guard let dst = pcm.floatChannelData?[0] else { return nil }
|
||||
snap.samples.withUnsafeBufferPointer { src in
|
||||
guard let base = src.baseAddress else { return }
|
||||
memcpy(dst, base, src.count * MemoryLayout<Float>.stride)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return pcm
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,15 @@ import Foundation
|
||||
public struct AppGroupStore: @unchecked Sendable {
|
||||
public let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
if let defaults {
|
||||
self.defaults = defaults
|
||||
return
|
||||
}
|
||||
// Never hard-crash on implicit construction sites (e.g. default
|
||||
// service initializers). If App Group is unavailable, use .standard
|
||||
// so callers can still surface a user-facing setup error.
|
||||
self.defaults = AppGroup.isAvailable ? AppGroup.defaults : .standard
|
||||
}
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// DictationBridge.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Lightweight App Group bridge for host-app dictation handoff:
|
||||
// keyboard extension -> open host app for recording
|
||||
// host app -> writes final transcript
|
||||
// keyboard extension -> consumes pending transcript and inserts text
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DictationBridge {
|
||||
public enum Status: String, Sendable, Equatable {
|
||||
case idle
|
||||
case requested
|
||||
case recording
|
||||
case transcribing
|
||||
case done
|
||||
case cancelled
|
||||
case error
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
static let pendingText = "dictation.pendingText"
|
||||
static let updatedAt = "dictation.updatedAt"
|
||||
static let status = "dictation.status"
|
||||
static let statusUpdatedAt = "dictation.statusUpdatedAt"
|
||||
static let statusMessage = "dictation.statusMessage"
|
||||
}
|
||||
|
||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||
if let defaults {
|
||||
return defaults
|
||||
}
|
||||
return AppGroup.isAvailable ? AppGroup.defaults : .standard
|
||||
}
|
||||
|
||||
public static func setStatus(
|
||||
_ status: Status,
|
||||
message: String? = nil,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(status.rawValue, forKey: Key.status)
|
||||
store.set(Date().timeIntervalSince1970, forKey: Key.statusUpdatedAt)
|
||||
if let message, !message.isEmpty {
|
||||
store.set(message, forKey: Key.statusMessage)
|
||||
} else {
|
||||
store.removeObject(forKey: Key.statusMessage)
|
||||
}
|
||||
}
|
||||
|
||||
public static func currentStatus(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> (status: Status, message: String?, updatedAt: TimeInterval) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let raw = store.string(forKey: Key.status) ?? Status.idle.rawValue
|
||||
let status = Status(rawValue: raw) ?? .idle
|
||||
let message = store.string(forKey: Key.statusMessage)
|
||||
let updatedAt = store.double(forKey: Key.statusUpdatedAt)
|
||||
return (status, message, updatedAt)
|
||||
}
|
||||
|
||||
public static func markRequested(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.removeObject(forKey: Key.pendingText)
|
||||
setStatus(.requested, defaults: store)
|
||||
}
|
||||
|
||||
/// Store a transcript for the keyboard extension to consume.
|
||||
public static func storePendingTranscript(_ text: String, defaults: UserDefaults? = nil) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(trimmed, forKey: Key.pendingText)
|
||||
store.set(Date().timeIntervalSince1970, forKey: Key.updatedAt)
|
||||
setStatus(.done, defaults: store)
|
||||
}
|
||||
|
||||
/// Returns and clears the pending transcript if present.
|
||||
public static func consumePendingTranscript(
|
||||
maxAge: TimeInterval = 180,
|
||||
defaults: UserDefaults? = nil
|
||||
) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard let text = store.string(forKey: Key.pendingText) else {
|
||||
return nil
|
||||
}
|
||||
if maxAge > 0 {
|
||||
let ts = store.double(forKey: Key.updatedAt)
|
||||
if ts > 0, Date().timeIntervalSince1970 - ts > maxAge {
|
||||
clear(defaults: store)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
store.removeObject(forKey: Key.pendingText)
|
||||
setStatus(.idle, defaults: store)
|
||||
return text
|
||||
}
|
||||
|
||||
public static func clear(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.removeObject(forKey: Key.pendingText)
|
||||
store.removeObject(forKey: Key.updatedAt)
|
||||
setStatus(.idle, defaults: store)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// FlowContinuousCapture.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// TypeWhisper-style continuous mic capture for Flow sessions: one
|
||||
// AVAudioEngine + input tap for the entire session. Utterances gate
|
||||
// whether buffers are forwarded to ASR; levels are always computed on
|
||||
// the audio thread and read from the main thread (never UserDefaults
|
||||
// from the realtime tap — that caused cross-process crashes).
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import os
|
||||
|
||||
private enum FlowCaptureConstants {
|
||||
static let levelBarCount = 24
|
||||
static let targetSampleRate: Double = 16_000
|
||||
}
|
||||
|
||||
/// Thread-safe relay for utterance-scoped ASR snapshots.
|
||||
private final class FlowCaptureStreamRelay: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var continuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
|
||||
func bind(_ continuation: AsyncStream<AudioBufferSnapshot>.Continuation) {
|
||||
lock.withLock { self.continuation = continuation }
|
||||
}
|
||||
|
||||
func yield(_ snapshot: AudioBufferSnapshot) {
|
||||
lock.withLock { continuation?.yield(snapshot) }
|
||||
}
|
||||
|
||||
func finish() {
|
||||
lock.withLock {
|
||||
continuation?.finish()
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling bar levels updated from the audio tap; read on the main actor.
|
||||
private final class FlowLevelStore: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var levels: [Float]
|
||||
|
||||
init(barCount: Int) {
|
||||
levels = Array(repeating: 0, count: barCount)
|
||||
}
|
||||
|
||||
func update(from buffer: AVAudioPCMBuffer, barCount: Int) {
|
||||
let computed = Self.calculateLevels(from: buffer, barCount: barCount)
|
||||
lock.withLock { levels = computed }
|
||||
}
|
||||
|
||||
func snapshot() -> [Float] {
|
||||
lock.withLock { levels }
|
||||
}
|
||||
|
||||
private static func calculateLevels(from buffer: AVAudioPCMBuffer, barCount: Int) -> [Float] {
|
||||
guard let channelData = buffer.floatChannelData else {
|
||||
return Array(repeating: 0, count: barCount)
|
||||
}
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
guard frameLength > 0 else {
|
||||
return Array(repeating: 0, count: barCount)
|
||||
}
|
||||
let samplesPerBar = max(frameLength / barCount, 1)
|
||||
var result = [Float]()
|
||||
result.reserveCapacity(barCount)
|
||||
for barIndex in 0..<barCount {
|
||||
let start = barIndex * samplesPerBar
|
||||
let end = min(start + samplesPerBar, frameLength)
|
||||
var sum: Float = 0
|
||||
for i in start..<end {
|
||||
sum += abs(channelData[0][i])
|
||||
}
|
||||
let avg = sum / Float(max(end - start, 1))
|
||||
result.append(min(avg * 50, 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class FlowContinuousCapture {
|
||||
|
||||
public enum StartError: LocalizedError {
|
||||
case invalidHardwareFormat(sampleRate: Double, channels: Int)
|
||||
case formatCreateFailed
|
||||
case converterCreateFailed
|
||||
case engineStartFailed(String)
|
||||
case audioSessionFailed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidHardwareFormat(let sr, let ch):
|
||||
return String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.micUnavailable", comment: ""),
|
||||
sr,
|
||||
ch
|
||||
)
|
||||
case .formatCreateFailed:
|
||||
return NSLocalizedString("preview.error.formatCreate", comment: "")
|
||||
case .converterCreateFailed:
|
||||
return NSLocalizedString("preview.error.converterCreate", comment: "")
|
||||
case .engineStartFailed(let detail):
|
||||
return String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.engineStart", comment: ""),
|
||||
detail
|
||||
)
|
||||
case .audioSessionFailed(let detail):
|
||||
return String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.audioSession", comment: ""),
|
||||
detail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static let levelBarCount = FlowCaptureConstants.levelBarCount
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private let streamRelay = FlowCaptureStreamRelay()
|
||||
private let levelStore = FlowLevelStore(barCount: FlowCaptureConstants.levelBarCount)
|
||||
private let isUtteranceActive = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
private var didInstallTap = false
|
||||
private var isRunning = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .measurement,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
throw StartError.audioSessionFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
let inputNode = audioEngine.inputNode
|
||||
let hwFormat = inputNode.outputFormat(forBus: 0)
|
||||
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
|
||||
throw StartError.invalidHardwareFormat(
|
||||
sampleRate: hwFormat.sampleRate,
|
||||
channels: Int(hwFormat.channelCount)
|
||||
)
|
||||
}
|
||||
|
||||
guard let targetFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: FlowCaptureConstants.targetSampleRate,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
) else {
|
||||
throw StartError.formatCreateFailed
|
||||
}
|
||||
guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else {
|
||||
throw StartError.converterCreateFailed
|
||||
}
|
||||
|
||||
if !didInstallTap {
|
||||
let utteranceFlag = isUtteranceActive
|
||||
let relay = streamRelay
|
||||
let levels = levelStore
|
||||
let tap = Self.makeAudioTapBlock(
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
hwFormat: hwFormat,
|
||||
utteranceFlag: utteranceFlag,
|
||||
levelStore: levels,
|
||||
streamRelay: relay
|
||||
)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
||||
didInstallTap = true
|
||||
}
|
||||
|
||||
audioEngine.prepare()
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
throw StartError.engineStartFailed(error.localizedDescription)
|
||||
}
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
/// Tear down the engine and release the audio session.
|
||||
public func stop() {
|
||||
isUtteranceActive.withLock { $0 = false }
|
||||
streamRelay.finish()
|
||||
|
||||
if didInstallTap {
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
didInstallTap = false
|
||||
}
|
||||
if audioEngine.isRunning {
|
||||
audioEngine.stop()
|
||||
}
|
||||
isRunning = false
|
||||
try? AVAudioSession.sharedInstance().setActive(
|
||||
false,
|
||||
options: .notifyOthersOnDeactivation
|
||||
)
|
||||
}
|
||||
|
||||
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
||||
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
||||
isUtteranceActive.withLock { $0 = true }
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
streamRelay.bind(continuation)
|
||||
return stream
|
||||
}
|
||||
|
||||
/// Stop forwarding buffers; finishes the ASR stream.
|
||||
public func endUtterance() {
|
||||
isUtteranceActive.withLock { $0 = false }
|
||||
streamRelay.finish()
|
||||
}
|
||||
|
||||
public func cancelUtterance() {
|
||||
endUtterance()
|
||||
}
|
||||
|
||||
public func currentAudioLevels() -> [Float] {
|
||||
levelStore.snapshot()
|
||||
}
|
||||
|
||||
// MARK: - Audio tap (nonisolated — runs on realtime thread)
|
||||
|
||||
private nonisolated static func makeAudioTapBlock(
|
||||
converter: AVAudioConverter,
|
||||
targetFormat: AVAudioFormat,
|
||||
hwFormat: AVAudioFormat,
|
||||
utteranceFlag: OSAllocatedUnfairLock<Bool>,
|
||||
levelStore: FlowLevelStore,
|
||||
streamRelay: FlowCaptureStreamRelay
|
||||
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
return { buffer, _ in
|
||||
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
|
||||
|
||||
guard utteranceFlag.withLock({ $0 }) else { return }
|
||||
|
||||
let outFrames = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * targetFormat.sampleRate / hwFormat.sampleRate
|
||||
)
|
||||
guard outFrames > 0,
|
||||
let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames)
|
||||
else { return }
|
||||
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status == .haveData, error == nil, outBuffer.frameLength > 0 else { return }
|
||||
|
||||
let snapshot = AudioBufferSnapshot(buffer: outBuffer)
|
||||
guard !snapshot.samples.isEmpty else { return }
|
||||
streamRelay.yield(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// FlowSessionBridge.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// TypeWhisper-style Flow session bridge: keyboard writes recording
|
||||
// signals; host app writes transcription results. Legacy one-shot
|
||||
// dictation handoff remains in `DictationBridge`.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FlowSessionBridge {
|
||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||
if let defaults { return defaults }
|
||||
return AppGroup.isAvailable ? AppGroup.defaults : .standard
|
||||
}
|
||||
|
||||
/// Force cross-process visibility. Must only be called on the main thread.
|
||||
private static func flush(_ store: UserDefaults) {
|
||||
if Thread.isMainThread {
|
||||
store.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle (host app)
|
||||
|
||||
public static func markSessionActive(
|
||||
duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let expires = Date().timeIntervalSince1970 + duration
|
||||
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
writeHeartbeat(defaults: store)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func markSessionInactive(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func writeHeartbeat(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func extendSession(
|
||||
by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let expires = Date().timeIntervalSince1970 + duration
|
||||
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
// MARK: - Session validity (keyboard)
|
||||
|
||||
/// True when expires is in the future and heartbeat is fresh.
|
||||
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
guard expires > Date().timeIntervalSince1970 else { return false }
|
||||
|
||||
let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
guard heartbeat > 0 else { return false }
|
||||
|
||||
let staleness = Date().timeIntervalSince1970 - heartbeat
|
||||
return staleness <= FlowSessionKeys.heartbeatStaleInterval
|
||||
}
|
||||
|
||||
public static func sessionExpiresAt(defaults: UserDefaults? = nil) -> TimeInterval? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
return expires > 0 ? expires : nil
|
||||
}
|
||||
|
||||
/// Seconds until session expiry; nil when expired or never started.
|
||||
public static func remainingSessionDuration(defaults: UserDefaults? = nil) -> TimeInterval? {
|
||||
guard let expires = sessionExpiresAt(defaults: defaults) else { return nil }
|
||||
let remaining = expires - Date().timeIntervalSince1970
|
||||
return remaining > 0 ? remaining : nil
|
||||
}
|
||||
|
||||
// MARK: - Recording signals (keyboard → host)
|
||||
|
||||
public static func setRecordingState(
|
||||
_ state: FlowSessionKeys.RecordingState,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(state.rawValue, forKey: FlowSessionKeys.keyboardRecordingState)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func recordingState(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> FlowSessionKeys.RecordingState {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let raw = store.string(forKey: FlowSessionKeys.keyboardRecordingState) ?? FlowSessionKeys.RecordingState.idle.rawValue
|
||||
return FlowSessionKeys.RecordingState(rawValue: raw) ?? .idle
|
||||
}
|
||||
|
||||
public static func setTranscriptionLanguage(
|
||||
_ localeId: String,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(localeId, forKey: FlowSessionKeys.transcriptionLanguage)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
// MARK: - Results (host → keyboard)
|
||||
|
||||
public static func storeTranscriptionResult(
|
||||
_ text: String,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(trimmed, forKey: FlowSessionKeys.transcriptionResult)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func storeTranscriptionError(
|
||||
_ message: String,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(message, forKey: FlowSessionKeys.transcriptionError)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
/// Returns and clears a pending transcription result, if any.
|
||||
public static func consumeTranscriptionResult(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard let text = store.string(forKey: FlowSessionKeys.transcriptionResult), !text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
||||
flush(store)
|
||||
return text
|
||||
}
|
||||
|
||||
/// Returns and clears a pending transcription error, if any.
|
||||
public static func consumeTranscriptionError(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard let message = store.string(forKey: FlowSessionKeys.transcriptionError), !message.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
flush(store)
|
||||
return message
|
||||
}
|
||||
|
||||
public static func audioLevels(defaults: UserDefaults? = nil) -> [Float] {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [Double], !levels.isEmpty {
|
||||
return levels.map { Float($0) }
|
||||
}
|
||||
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [NSNumber], !levels.isEmpty {
|
||||
return levels.map { $0.floatValue }
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/// Host app: publish waveform bars for the keyboard (main thread only).
|
||||
public static func storeAudioLevels(
|
||||
_ levels: [Float],
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(levels.map { Double($0) }, forKey: FlowSessionKeys.audioLevels)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
/// Clear pending result/error before a new utterance.
|
||||
public static func clearPendingTranscription(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
clearTranscription(defaults: store)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func clearFlowState(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
private static func clearTranscription(defaults: UserDefaults) {
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// FlowSessionKeys.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// App Group keys for TypeWhisper-style Flow sessions between the
|
||||
// keyboard extension and the host app (Session Owner).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FlowSessionKeys {
|
||||
public static let flowSessionActive = "flow.flowSessionActive"
|
||||
public static let flowSessionExpires = "flow.flowSessionExpires"
|
||||
public static let flowHeartbeat = "flow.flowHeartbeat"
|
||||
public static let keyboardRecordingState = "flow.keyboardRecordingState"
|
||||
public static let transcriptionLanguage = "flow.transcriptionLanguage"
|
||||
public static let transcriptionResult = "flow.transcriptionResult"
|
||||
public static let transcriptionError = "flow.transcriptionError"
|
||||
public static let audioLevels = "flow.audioLevels"
|
||||
|
||||
/// Heartbeat older than this implies the host app was killed.
|
||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||
|
||||
/// Default Flow session length when started from the keyboard.
|
||||
public static let defaultSessionDuration: TimeInterval = 480
|
||||
|
||||
/// Maximum duration for a single keyboard utterance.
|
||||
public static let maxUtteranceDuration: TimeInterval = 60
|
||||
|
||||
public enum RecordingState: String, Sendable, Equatable {
|
||||
case idle
|
||||
case recording
|
||||
case stopped
|
||||
case processing
|
||||
case aborted
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ public final class KeyboardState: ObservableObject {
|
||||
/// `true` — kept on the state object because the UI's status
|
||||
/// badge still wants a single source of truth to read from.
|
||||
@Published public var onDeviceSupported: Bool = false
|
||||
/// Seconds remaining in the current utterance (Flow tap-to-talk).
|
||||
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
/// "local" → ASR only, no LLM. "cloud" → ASR + optional LLM polish.
|
||||
@Published public var engineMode: String = "cloud"
|
||||
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
// LiveDictationController.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Unified on-device dictation session: mic capture + iOS 26 SpeechAnalyzer.
|
||||
// Used by the keyboard preview sheet, host-app dictation handoff, and any
|
||||
// other foreground surface that needs live ASR without duplicating pipeline code.
|
||||
// Owns its own AVAudioEngine + AVAudioSession, downsamples to 16 kHz
|
||||
// mono Float32 on the audio thread (same as `AudioCaptureService`), and
|
||||
// feeds `AudioBufferSnapshot` to the shared `ASRService` (the same
|
||||
// pipeline the real keyboard extension
|
||||
// uses, so the preview exercises the *real* iOS speech APIs, not a
|
||||
// stub). Without this the in-app preview was a hardcoded transcript
|
||||
// and "did you actually call SFSpeechRecognizer?" was a fair review
|
||||
// note.
|
||||
//
|
||||
// Why not reuse `AudioCaptureService` from the extension? It lives in
|
||||
// `OSGKeyboardExt`, an `app-extension` target — the main app can't
|
||||
// import its symbols. We could move it to `OSGKeyboardShared`, but
|
||||
// `AVAudioSession` lifecycle differs enough between a keyboard
|
||||
// extension (no background, no recording entitlement surprise) and a
|
||||
// foreground app that a copy here is the lesser evil.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Speech
|
||||
import os
|
||||
|
||||
/// Thread-safe relay so the AVAudioEngine tap can yield snapshots without
|
||||
/// hopping through `@MainActor` (which adds latency and can reorder frames).
|
||||
private final class CaptureStreamRelay: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var continuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
|
||||
func bind(_ continuation: AsyncStream<AudioBufferSnapshot>.Continuation) {
|
||||
lock.withLock { self.continuation = continuation }
|
||||
}
|
||||
|
||||
func yield(_ snapshot: AudioBufferSnapshot) {
|
||||
lock.withLock { continuation?.yield(snapshot) }
|
||||
}
|
||||
|
||||
func finish() {
|
||||
lock.withLock {
|
||||
continuation?.finish()
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class LiveDictationController: ObservableObject {
|
||||
|
||||
public enum Phase: Equatable {
|
||||
case idle
|
||||
case requestingPermission
|
||||
case recording
|
||||
case processing
|
||||
case denied(String)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
@Published public private(set) var phase: Phase = .idle
|
||||
/// Normalized 0...1 RMS for the disc level meter. Polled from the
|
||||
/// audio tap via `Task { @MainActor in ... }` — the tap itself
|
||||
/// runs on a real-time audio thread, so we never touch published
|
||||
/// state from there.
|
||||
@Published public private(set) var level: Double = 0
|
||||
@Published public private(set) var currentPartial: String = ""
|
||||
@Published public private(set) var errorMessage: String?
|
||||
|
||||
/// Set when a `.final` ASR event lands. The owning sheet observes
|
||||
/// this and appends the text to its textbox, then clears it so the
|
||||
/// next recording starts from zero.
|
||||
@Published public var lastFinal: String = ""
|
||||
|
||||
private let asr: ASRService = ASRServiceFactory.make()
|
||||
private let audioEngine = AVAudioEngine()
|
||||
/// `internal` (not `private`) so the regression test in
|
||||
/// `OSGKeyboardTests/PreviewASRControllerStateTests.swift` can
|
||||
/// install a known consumer task and assert `stop()` doesn't
|
||||
/// cancel it. The class is `@MainActor`-isolated, so the
|
||||
/// natural Swift 6 isolation rules still prevent production
|
||||
/// code outside the class from racing on it.
|
||||
public var asrTask: Task<Void, Never>?
|
||||
private let streamRelay = CaptureStreamRelay()
|
||||
private var didConfigureAudioSession = false
|
||||
private var didInstallTap = false
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, …).
|
||||
public func start(localeId: String) async {
|
||||
await start(locale: SpeechLocaleResolver.resolve(localeId))
|
||||
}
|
||||
|
||||
public func start(locale: Locale) async {
|
||||
// Re-entry guard: ignore taps that arrive while we're already
|
||||
// running. (The sheet's `cyclePhase` is also guarded, but
|
||||
// async race windows are easier to lock down here.)
|
||||
switch phase {
|
||||
case .recording, .requestingPermission, .processing:
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
// Cancel any leftover consumer task from a previous recording.
|
||||
// Normally `stop()` lets the task run to completion (so it can
|
||||
// see the `.final` and transition out of `.processing`), but if
|
||||
// the user smashed the disc twice — stop, then immediately
|
||||
// start — the previous task might still be draining. Cancel it
|
||||
// here so we don't have two consumer tasks fighting over the
|
||||
// same `events` stream.
|
||||
asrTask?.cancel()
|
||||
asrTask = nil
|
||||
teardownCapturePipeline()
|
||||
phase = .requestingPermission
|
||||
currentPartial = ""
|
||||
lastFinal = ""
|
||||
errorMessage = nil
|
||||
level = 0
|
||||
|
||||
// 1. Microphone permission. The helper is `nonisolated` so the
|
||||
// (iOS < 17) callback closure does not inherit `@MainActor` —
|
||||
// `AVAudioSession.requestRecordPermission` delivers on a TCC
|
||||
// reply queue, and a `@MainActor`-inferred closure body there
|
||||
// hits `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift`.
|
||||
let micGranted = await Self.requestMicrophonePermission()
|
||||
guard micGranted else {
|
||||
phase = .denied(NSLocalizedString("keyboard.denied.mic", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Speech recognition permission. Same reasoning as above:
|
||||
// the callback fires on TCC's reply queue, NOT the main queue.
|
||||
let speechGranted = await Self.requestSpeechRecognitionPermission()
|
||||
guard speechGranted else {
|
||||
phase = .denied(NSLocalizedString("keyboard.denied.speech", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Audio session — only configure once per process.
|
||||
//
|
||||
// Category is `.record` (not `.playAndRecord`) because the
|
||||
// preview never plays back audio — it just records from the
|
||||
// mic and hands the buffers to `SpeechAnalyzer`. On the
|
||||
// iOS Simulator, `.playAndRecord` requires the
|
||||
// `AURemoteIO` Audio Unit's *output* side to also be
|
||||
// enabled, but the simulator's "speaker" reports a 0 Hz
|
||||
// hardware format, so `AURemoteIO::enable` fails with
|
||||
// `kAudioUnitErr_FormatNotSupported` (-10851) and any
|
||||
// subsequent `installTap` traps with "Failed to create tap
|
||||
// due to format mismatch". `.record` skips the output
|
||||
// side entirely, so the simulator can record.
|
||||
//
|
||||
// The real keyboard extension (`OSGKeyboardExt`) keeps
|
||||
// `.playAndRecord` because it runs on a real device where
|
||||
// the output side has a real hardware format, and may want
|
||||
// to play click sounds / haptic feedback. Only the preview
|
||||
// needs the simulator-friendly category.
|
||||
if !didConfigureAudioSession {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.record,
|
||||
mode: .measurement,
|
||||
options: [])
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
didConfigureAudioSession = true
|
||||
} catch {
|
||||
debug("audio session failed: \(error.localizedDescription)")
|
||||
phase = .error(String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.audioSession", comment: ""),
|
||||
error.localizedDescription
|
||||
))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Spin up the engine + ASR.
|
||||
phase = .recording
|
||||
startEngineAndASR(locale: locale)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
// Don't `asrTask?.cancel()` here — see the comment in
|
||||
// `startEngineAndASR` for the full rationale. Short version:
|
||||
// cancelling the consumer task at the same moment we close the
|
||||
// audio stream also triggers the producer's
|
||||
// `continuation.onTermination → self?.cancel()` cascade, which
|
||||
// marks the producer's outer task as cancelled and skips the
|
||||
// `.final` event. The UI is then left in `.processing` forever
|
||||
// because no one schedules the transition out. The consumer
|
||||
// task naturally exits when `events` finishes, so the right
|
||||
// thing is to let it run.
|
||||
//
|
||||
// If a previous `asrTask` is somehow still running (e.g. the
|
||||
// user smashed the disc twice quickly), `start()` cancels it
|
||||
// at the entry point as a safety net.
|
||||
teardownCapturePipeline()
|
||||
// Fallback: if we already have a meaningful partial but the
|
||||
// backend never emits `.final`, promote the partial so the
|
||||
// preview still inserts text after "停止录音".
|
||||
let partial = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !partial.isEmpty && lastFinal.isEmpty {
|
||||
lastFinal = partial
|
||||
currentPartial = ""
|
||||
}
|
||||
if phase == .recording {
|
||||
phase = .processing
|
||||
}
|
||||
// Deactivate so the user's music resumes if the preview is
|
||||
// dismissed mid-recording.
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
|
||||
// Safety net: if the ASR pipeline never produces a `.final`
|
||||
// (analyzer hang, system glitch, dropped continuation), force
|
||||
// the UI back to idle after a short delay so the user isn't
|
||||
// stuck. Normal recordings complete well under a second, so
|
||||
// the 3-second budget is only hit on the unhappy path; if the
|
||||
// pipeline finishes first and flips the phase to `.idle` (or
|
||||
// `.error`), the check below no-ops.
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard let self else { return }
|
||||
if self.phase == .processing {
|
||||
let stalePartial = self.currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !stalePartial.isEmpty, self.lastFinal.isEmpty {
|
||||
self.debug("processing timeout, using partial")
|
||||
self.lastFinal = stalePartial
|
||||
self.currentPartial = ""
|
||||
}
|
||||
self.phase = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
// Called by the sheet after appending `lastFinal` to the textbox,
|
||||
// so the next recording can produce a fresh final without us
|
||||
// double-appending.
|
||||
lastFinal = ""
|
||||
if phase == .processing {
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Engine + ASR
|
||||
|
||||
private func startEngineAndASR(locale: Locale) {
|
||||
let inputNode = audioEngine.inputNode
|
||||
let hwFormat = inputNode.outputFormat(forBus: 0)
|
||||
|
||||
// Pre-flight check: a placeholder / unconfigured input bus
|
||||
// reports `sampleRate == 0` (or `channelCount == 0`).
|
||||
// `installTap` on such a bus traps with "Failed to create
|
||||
// tap due to format mismatch" (an NSException, not a Swift
|
||||
// `Error`, so we can't `try`/`catch` it). The safest fix
|
||||
// is to refuse the tap up front and surface a clear
|
||||
// `.error` phase instead of crashing the app. We've seen
|
||||
// this on the iOS Simulator when the host's microphone
|
||||
// permission isn't granted to CoreSimulator, and on
|
||||
// devices where the audio session is in an unexpected
|
||||
// state from a previous foreground/background transition.
|
||||
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
|
||||
debug("invalid hardware format sr=\(hwFormat.sampleRate) ch=\(hwFormat.channelCount)")
|
||||
phase = .error(
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.micUnavailable", comment: ""),
|
||||
hwFormat.sampleRate,
|
||||
Int(hwFormat.channelCount)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let targetSampleRate: Double = 16_000
|
||||
guard let targetFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: targetSampleRate,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
) else {
|
||||
phase = .error(NSLocalizedString("preview.error.formatCreate", comment: ""))
|
||||
return
|
||||
}
|
||||
guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else {
|
||||
debug("converter creation failed")
|
||||
phase = .error(NSLocalizedString("preview.error.converterCreate", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
streamRelay.bind(continuation)
|
||||
|
||||
// Tap the hardware input. The closure passed to `installTap` runs
|
||||
// on the AVAudioEngine real-time audio thread. In Swift 6 strict
|
||||
// concurrency, a closure literal defined inside a `@MainActor`
|
||||
// method inherits `@MainActor` isolation, which would trip
|
||||
// `dispatch_assert_queue_fail` on first invocation from the
|
||||
// audio thread. The fix is to build the actual tap body in a
|
||||
// `nonisolated` helper (`makeAudioTapBlock`) and have the
|
||||
// installTap closure be a single function reference — function
|
||||
// references never carry inferred isolation, so the dispatch
|
||||
// runtime is happy and the body runs wherever AVAudioEngine
|
||||
// wants it (the audio thread).
|
||||
let onMeter: @Sendable (Double) -> Void = { [weak self] meter in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// Lightweight smoothing so the disc ring doesn't jitter.
|
||||
self.level = self.level * 0.55 + meter * 0.45
|
||||
}
|
||||
}
|
||||
let relay = streamRelay
|
||||
let onSnapshot: @Sendable (AudioBufferSnapshot) -> Void = { snapshot in
|
||||
relay.yield(snapshot)
|
||||
}
|
||||
let tap = Self.makeAudioTapBlock(
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
hwFormat: hwFormat,
|
||||
onMeter: onMeter,
|
||||
onSnapshot: onSnapshot
|
||||
)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
||||
didInstallTap = true
|
||||
|
||||
audioEngine.prepare()
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
debug("audio engine start failed: \(error.localizedDescription)")
|
||||
phase = .error(String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.engineStart", comment: ""),
|
||||
error.localizedDescription
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Wire up ASR.
|
||||
let events = asr.transcribe(
|
||||
stream: stream,
|
||||
locale: locale
|
||||
)
|
||||
asrTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
for await event in events {
|
||||
switch event {
|
||||
case .capability:
|
||||
break
|
||||
case .partial(let s):
|
||||
self.currentPartial = s
|
||||
case .final(let s):
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.lastFinal = trimmed
|
||||
self.currentPartial = ""
|
||||
self.phase = .idle
|
||||
case .error(let m):
|
||||
self.debug("asr error: \(m)")
|
||||
self.teardownCapturePipeline()
|
||||
self.errorMessage = m
|
||||
self.phase = .error(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission helpers (nonisolated)
|
||||
//
|
||||
// `SFSpeechRecognizer.requestAuthorization` delivers its callback
|
||||
// on a TCC reply queue, NOT the main queue. If we wrap that
|
||||
// callback inline in `start(locale:)` — which is `@MainActor` —
|
||||
// Swift 6 strict concurrency infers the closure body as
|
||||
// `@MainActor`, and the runtime crashes on
|
||||
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
|
||||
// soon as TCC calls us back.
|
||||
//
|
||||
// The first attempt (commit `e8a0310`) extracted the entire
|
||||
// permission request into a `nonisolated static func` helper.
|
||||
// That worked in isolation, but the Swift 6 optimizer
|
||||
// inlined those helpers back into `start(locale:)`. After
|
||||
// inlining, the `withCheckedContinuation` body and the
|
||||
// `requestAuthorization` callback were re-typed in the
|
||||
// `@MainActor` context of the caller, and the runtime
|
||||
// assertion came right back — same crash, different symbol:
|
||||
// `closure #1 in closure #2 in PreviewASRController.start(locale:)`.
|
||||
//
|
||||
// The fix that survives inlining is the *function-reference*
|
||||
// pattern, the same one used for `installTap` in
|
||||
// `makeAudioTapBlock` below. The callback is built in a
|
||||
// `nonisolated` static helper that takes a `CheckedContinuation`
|
||||
// and returns the `(Status) -> Void` handler. The body of that
|
||||
// helper has no enclosing actor, so the closure is created in
|
||||
// nonisolated context. When TCC calls us back, the runtime
|
||||
// sees a nonisolated closure on a non-main queue and is happy.
|
||||
//
|
||||
// `cont.resume(...)` is itself thread-safe on
|
||||
// `CheckedContinuation`, so we don't need to hop back to the
|
||||
// main actor before resuming.
|
||||
|
||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
// iOS 17+ API; the iOS < 17 fallback (`AVAudioSession.recordPermission`
|
||||
// + `requestRecordPermission` callback) is gone now that the
|
||||
// deployment target is iOS 26.
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted: return true
|
||||
case .denied: return false
|
||||
case .undetermined: return await AVAudioApplication.requestRecordPermission()
|
||||
@unknown default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechRecognitionPermission() async -> Bool {
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization(
|
||||
Self.makeSpeechAuthHandler(continuation: cont)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func makeSpeechAuthHandler(
|
||||
continuation: CheckedContinuation<Bool, Never>
|
||||
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
|
||||
return { status in
|
||||
continuation.resume(returning: status == .authorized)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio tap (nonisolated, runs on AVAudioEngine render thread)
|
||||
//
|
||||
// `AVAudioNode.installTap`'s callback fires on the audio engine's
|
||||
// real-time render thread. In Swift 6 strict concurrency, a closure
|
||||
// literal defined inside a `@MainActor` method inherits `@MainActor`
|
||||
// isolation — and `dispatch_assert_queue_fail` fires the moment
|
||||
// the runtime tries to dispatch that closure on a non-main queue.
|
||||
//
|
||||
// The trick is to build the actual tap body in a `nonisolated`
|
||||
// function and have the installTap closure be a *function reference*
|
||||
// to that helper. Function references never carry inferred
|
||||
// isolation, so the dispatch runtime is satisfied and the body
|
||||
// runs wherever AVAudioEngine wants. State updates to
|
||||
// `self.level` and the AsyncStream continuation hop back to the
|
||||
// main actor via `Task { @MainActor in … }`, which is itself
|
||||
// safe to call from a non-isolated context.
|
||||
private nonisolated static func makeAudioTapBlock(
|
||||
converter: AVAudioConverter,
|
||||
targetFormat: AVAudioFormat,
|
||||
hwFormat: AVAudioFormat,
|
||||
onMeter: @Sendable @escaping (Double) -> Void,
|
||||
onSnapshot: @Sendable @escaping (AudioBufferSnapshot) -> Void
|
||||
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
// `@Sendable` on the returned closure makes the Sendable
|
||||
// conformance explicit. `AVAudioNodeTapBlock` is declared as
|
||||
// a plain escaping closure in the SDK; we cast at the call
|
||||
// site via `as @Sendable`.
|
||||
return { buffer, _ in
|
||||
// 1) Level meter from raw hardware buffer.
|
||||
let n = Int(buffer.frameLength)
|
||||
var sumSquares: Float = 0
|
||||
if let channelData = buffer.floatChannelData?[0], n > 0 {
|
||||
for i in 0..<n {
|
||||
let v = channelData[i]
|
||||
sumSquares += v * v
|
||||
}
|
||||
}
|
||||
let rms = n > 0 ? sqrtf(sumSquares / Float(n)) : 0
|
||||
let meter = min(Double(rms) * 4.0, 1.0)
|
||||
onMeter(meter)
|
||||
|
||||
// 2) Downsample to 16 kHz mono Float32 for ASR (matches
|
||||
// `AudioCaptureService` and Apple's `considering:` hint).
|
||||
let outFrames = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * targetFormat.sampleRate / hwFormat.sampleRate
|
||||
)
|
||||
guard outFrames > 0,
|
||||
let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames)
|
||||
else { return }
|
||||
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status == .haveData, error == nil, outBuffer.frameLength > 0 else { return }
|
||||
|
||||
let snapshot = AudioBufferSnapshot(buffer: outBuffer)
|
||||
guard !snapshot.samples.isEmpty else { return }
|
||||
onSnapshot(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func teardownCapturePipeline() {
|
||||
if didInstallTap {
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
didInstallTap = false
|
||||
}
|
||||
if audioEngine.isRunning {
|
||||
audioEngine.stop()
|
||||
}
|
||||
streamRelay.finish()
|
||||
}
|
||||
|
||||
private func debug(_ message: String) {
|
||||
#if DEBUG
|
||||
print("🎙️[LiveDictationController] \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// SpeechLocaleResolver.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Maps persisted `localeId` settings to a `Locale` suitable for
|
||||
// `DictationTranscriber.supportedLocale(equivalentTo:)`.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SpeechLocaleResolver {
|
||||
/// Resolve a stored locale id (`auto`, `zh-Hans`, …) for on-device ASR.
|
||||
public static func resolve(_ localeId: String) -> Locale {
|
||||
let raw: String
|
||||
if localeId == "auto" {
|
||||
raw = Locale.preferredLanguages.first ?? "en-US"
|
||||
} else {
|
||||
raw = localeId
|
||||
}
|
||||
let normalized = raw.replacingOccurrences(of: "_", with: "-").lowercased()
|
||||
if normalized.hasPrefix("zh") { return Locale(identifier: "zh-Hans") }
|
||||
if normalized.hasPrefix("ja") { return Locale(identifier: "ja-JP") }
|
||||
if normalized.hasPrefix("ko") { return Locale(identifier: "ko-KR") }
|
||||
if normalized.hasPrefix("en") { return Locale(identifier: "en-US") }
|
||||
return Locale(identifier: raw.replacingOccurrences(of: "_", with: "-"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// DictationTextComposer.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Merges pre-dictation anchor text with a live cumulative transcript.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DictationTextComposer {
|
||||
/// Combine text that existed before dictation with the current live transcript.
|
||||
public static func compose(anchor: String, live: String) -> String {
|
||||
let trimmed = live.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return anchor }
|
||||
if anchor.isEmpty { return trimmed }
|
||||
if anchor.last == " " || anchor.last == "\n" { return anchor + trimmed }
|
||||
return anchor + " " + trimmed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user