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:
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(), "第一段 第二段")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user