feat: migrate on-device Qwen3 ASR to CoreML for background Flow dictation

Replace MLX GPU inference with CoreML bundles so transcription continues
while the host app is backgrounded. Adds model download and warm-up,
vendored Qwen3Speech, and updates onboarding, settings, and copy for the
~1.6 GB CoreML package (iOS 18+).
This commit is contained in:
Rocky
2026-06-23 00:46:58 +08:00
parent 5e5122f172
commit df1c5ff32c
160 changed files with 22080 additions and 492 deletions
@@ -0,0 +1,114 @@
// ChunkedUtterancePipelineTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
private struct StubChunkASR: ASRService, @unchecked Sendable {
let labels: @Sendable ([Float]) -> String
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
return .success(labels(samples))
}
}
final class ChunkedUtterancePipelineTests: XCTestCase {
func testPipelineStitchesQueuedChunks() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 0,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
sampleRate: 1_000
)
let asr = StubChunkASR { samples in
samples.isEmpty ? "" : "seg\(samples.count)"
}
let pipeline = ChunkedUtterancePipeline(
asr: asr,
locale: Locale(identifier: "zh-Hans"),
config: config
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.finish()
var partials: [String] = []
let outcome = await pipeline.transcribe(stream: stream) { partial in
partials.append(partial)
}
guard case .success(let success) = outcome else {
return XCTFail("expected success, got \(outcome)")
}
XCTAssertTrue(success.text.contains("seg"))
XCTAssertFalse(partials.isEmpty)
}
func testPipelineDeliversPartialSuccessWhenOneChunkFails() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 0,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
sampleRate: 1_000
)
let pipeline = ChunkedUtterancePipeline(
asr: FailingSecondChunkASR(),
locale: Locale(identifier: "zh-Hans"),
config: config
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.finish()
let outcome = await pipeline.transcribe(stream: stream) { _ in }
guard case .success(let success) = outcome else {
return XCTFail("expected partial success, got \(outcome)")
}
XCTAssertFalse(success.text.isEmpty)
XCTAssertEqual(success.chunkWarnings.count, 1)
}
}
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
private let lock = OSAllocatedUnfairLock()
private var index = 0
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
let current = lock.withLock {
defer { index += 1 }
return index
}
if current == 1 {
return .failure("simulated chunk error")
}
return .success("seg\(samples.count)")
}
}
+25 -1
View File
@@ -13,14 +13,25 @@ final class FlowSessionBridgeTests: XCTestCase {
return defaults
}
func testSessionActiveRequiresFreshHeartbeat() {
func testSessionActiveSurvivesStaleHeartbeatWhileNotExpired() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 60, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
let staleHeartbeat = Date().timeIntervalSince1970 - 10
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
}
func testSessionInactiveWhenExpired() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 1, defaults: defaults)
let expired = Date().timeIntervalSince1970 - 5
defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires)
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
}
func testRecordingStateRoundTrip() {
@@ -39,6 +50,19 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertNil(FlowSessionBridge.consumeTranscriptionResult(defaults: defaults))
}
func testConsumeTranscriptionDeliveryIncludesPolishWarning() {
let defaults = makeDefaults()
FlowSessionBridge.storeTranscriptionResult(
"raw text",
polishWarning: "polish failed",
defaults: defaults
)
let delivery = FlowSessionBridge.consumeTranscriptionDelivery(defaults: defaults)
XCTAssertEqual(delivery?.text, "raw text")
XCTAssertEqual(delivery?.polishWarning, "polish failed")
XCTAssertNil(FlowSessionBridge.consumeTranscriptionDelivery(defaults: defaults))
}
func testClearFlowStateRemovesSessionKeys() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(defaults: defaults)
+40 -25
View File
@@ -232,8 +232,7 @@ final class LLMClientTests: XCTestCase {
}
/// Cross-process App Group contract: what `ProviderConfig` writes must
/// be readable through `AppGroupStore` (and vice-versa) on the same
/// suite, and `mode == .off` short-circuits before any network call.
/// be readable through `AppGroupStore` on the same suite.
func testAppGroupCrossProcessAndOffModeShortCircuit() async {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
@@ -253,14 +252,6 @@ final class LLMClientTests: XCTestCase {
XCTAssertEqual(store.apiKey, "sk-test-1234", "API key did not survive the cross-process boundary")
XCTAssertEqual(store.modeId, "off")
XCTAssertEqual(store.model, "gpt-4o-mini")
// mode == .off must short-circuit (the keyboard extension never
// even calls `polisher.polish` in this mode, so no LLMClient is
// constructed and no network request happens). We model the
// short-circuit on the read side: the persisted mode is "off" and
// any upstream caller checking `state.mode == .off` would skip
// the LLM. The guarantee is the persistence + the literal value.
XCTAssertEqual(store.modeId, "off")
}
func testAppGroupStoreNoAPIKeySurfacesAsLLMError() async {
@@ -288,30 +279,25 @@ final class LLMClientTests: XCTestCase {
}
}
// MARK: - TEST-2: mode = .off short-circuits PolishingService
// MARK: - TEST-2: cloud always polishes (legacy modeId ignored)
/// `PolishingService.polish()` must not invoke the underlying
/// `LLMClient` when the App Group store reports `modeId == "off"`.
/// We verify both halves of that contract:
/// 1. The return value is the trimmed input (not a polished round-trip).
/// 2. The `LLMClient` is never asked to talk to the network.
func testPolisherSkipsNetworkWhenModeOff() async throws {
/// Cloud engine must invoke the LLM even when a legacy `modeId == "off"`
/// value is still present in the App Group suite.
func testPolisherPolishesWhenCloudEvenIfModeOffLegacy() async throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
// modeId = "off" this is the switch we care about.
defaults.set("off", forKey: "config.modeId")
defaults.set("cloud", forKey: "config.engineMode")
defaults.set("https://example.com/v1", forKey: "config.baseURL")
defaults.set("sk-should-not-be-used", forKey: "config.apiKey")
defaults.set("sk-test", forKey: "config.apiKey")
defaults.set("gpt-4o-mini", forKey: "config.model")
// Counter LLMClient: if `polish()` is ever called, this trips.
let counter = CallCounter()
let countingClient = CountingLLMClient(counter: counter) { _, _ in
XCTFail("LLMClient.polish was invoked under mode=off — short-circuit failed")
return ""
let countingClient = CountingLLMClient(counter: counter) { raw, _ in
"POLISHED: \(raw)"
}
let store = AppGroupStore(defaults: defaults)
@@ -322,9 +308,38 @@ final class LLMClientTests: XCTestCase {
)
let result = try await polisher.polish(" hello world ")
XCTAssertEqual(result, "hello world", "mode=off must return trimmed input, not polished output")
XCTAssertEqual(result, "POLISHED: hello world")
let calls = await counter.value()
XCTAssertEqual(calls, 0, "LLMClient.polish must not be called when modeId == \"off\"")
XCTAssertEqual(calls, 1, "cloud engine must polish even with legacy modeId=off")
}
/// Local engine is ASR-only and never calls the cloud `LLMClient`.
func testPolisherReturnsRawWhenEngineLocal() async throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set("local", forKey: "config.engineMode")
defaults.set("off", forKey: "config.modeId")
let counter = CallCounter()
let countingClient = CountingLLMClient(counter: counter) { _, _ in
XCTFail("cloud LLMClient must not run under local engine")
return ""
}
let store = AppGroupStore(defaults: defaults)
let polisher = PolishingService(
store: store,
client: countingClient,
timeout: 1
)
let result = try await polisher.polish(" hello ")
XCTAssertEqual(result, "hello")
let calls = await counter.value()
XCTAssertEqual(calls, 0)
}
}
@@ -0,0 +1,44 @@
// ProgressiveDictationTranscriptAccumulatorTests.swift
// OSGKeyboardTests
import CoreMedia
import XCTest
@testable import OSGKeyboardShared
final class ProgressiveDictationTranscriptAccumulatorTests: XCTestCase {
private func range(start: Double, duration: Double = 30) -> CMTimeRange {
CMTimeRange(
start: CMTime(seconds: start, preferredTimescale: 600),
duration: CMTime(seconds: duration, preferredTimescale: 600)
)
}
func testCumulativePartialsWithinSameRangeUpdateSegment() {
var acc = ProgressiveDictationTranscriptAccumulator()
let r0 = range(start: 0)
XCTAssertEqual(acc.ingest(range: r0, text: "今天天气"), "今天天气")
XCTAssertEqual(acc.ingest(range: r0, text: "今天天气很好"), "今天天气很好")
XCTAssertEqual(acc.finalize(), "今天天气很好")
}
func testNewRangeAppendsInsteadOfReplacingEarlierSpeech() {
var acc = ProgressiveDictationTranscriptAccumulator()
let r0 = range(start: 0)
let r30 = range(start: 30)
_ = acc.ingest(range: r0, text: "前三十秒的内容")
_ = acc.ingest(range: r30, text: "后二十秒的内容")
XCTAssertEqual(acc.finalize(), "前三十秒的内容 后二十秒的内容")
}
func testDuplicateEmissionIsSuppressed() {
var acc = ProgressiveDictationTranscriptAccumulator()
let r0 = range(start: 0)
XCTAssertNotNil(acc.ingest(range: r0, text: "hello"))
XCTAssertNil(acc.ingest(range: r0, text: "hello"))
}
}
@@ -0,0 +1,42 @@
// UtteranceStreamChunkerTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class UtteranceStreamChunkerTests: XCTestCase {
private let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 1,
overlapDurationSeconds: 0.1,
pauseExtensionMaxSeconds: 0.2,
pauseRMSThreshold: 0.02,
sampleRate: 1_000
)
func testPauseAwareSplitPrefersSilenceNearWindowEnd() {
var buffer = [Float](repeating: 0.2, count: 900)
buffer.append(contentsOf: [Float](repeating: 0.001, count: 50))
buffer.append(contentsOf: [Float](repeating: 0.2, count: 100))
let split = UtteranceStreamChunker.pauseAwareSplitIndex(in: buffer, config: config)
XCTAssertGreaterThanOrEqual(split, config.maxChunkSamples)
XCTAssertLessThanOrEqual(split, config.maxChunkSamples + config.pauseExtensionSamples)
}
func testChunksEmitMultipleSegmentsForLongStream() async {
let sampleCount = config.maxChunkSamples * 2 + 100
let samples = [Float](repeating: 0.05, count: sampleCount)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: samples, sampleRate: Double(config.sampleRate)))
continuation.finish()
var received: [UtteranceAudioChunk] = []
for await chunk in UtteranceStreamChunker.chunks(from: stream, config: config) {
received.append(chunk)
}
XCTAssertGreaterThanOrEqual(received.count, 2)
XCTAssertTrue(received.last?.isLast == true)
}
}
@@ -0,0 +1,23 @@
// UtteranceTranscriptStitcherTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class UtteranceTranscriptStitcherTests: XCTestCase {
func testMergeWithOverlapRemovesDuplicatedSuffixPrefix() {
let merged = UtteranceTranscriptStitcher.mergeWithOverlap(
previous: "今天天气很好",
next: "很好我们继续"
)
XCTAssertEqual(merged, "今天天气很好我们继续")
}
func testStitcherOrdersChunksByIndex() {
var stitcher = UtteranceTranscriptStitcher()
stitcher.append(index: 1, text: "第二段")
stitcher.append(index: 0, text: "第一段")
XCTAssertEqual(stitcher.composed(), "第一段 第二段")
}
}