diff --git a/Sources/SstCore/AppGlue.swift b/Sources/SstCore/AppGlue.swift new file mode 100644 index 0000000..51c3cdb --- /dev/null +++ b/Sources/SstCore/AppGlue.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Inserts text only while `shouldInsert()` says so (not when Sst itself is in front, nor for window recordings). +public struct GatedInserter: TextInserting { + private let inner: TextInserting + private let shouldInsert: () -> Bool + + public init(inner: TextInserting, shouldInsert: @escaping () -> Bool) { + self.inner = inner + self.shouldInsert = shouldInsert + } + + public func insert(_ text: String) async throws { + guard shouldInsert() else { return } + try await inner.insert(text) + } +} + +/// Lets the app replace the speech model (for example after a model switch) without rebuilding the controller. +public final class SwappableTranscriber: Transcriber, @unchecked Sendable { + public enum SwapError: Error, LocalizedError { + case noTranscriber + + public var errorDescription: String? { "Spraakmodel is nog niet geladen." } + } + + private let lock = NSLock() + private var current: Transcriber? + + public init(_ initial: Transcriber? = nil) { + current = initial + } + + public func swap(to transcriber: Transcriber?) { + lock.withLock { current = transcriber } + } + + public var hasTranscriber: Bool { + lock.withLock { current != nil } + } + + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let transcriber = lock.withLock({ current }) else { throw SwapError.noTranscriber } + return try await transcriber.transcribe(samples, vocabulary: vocabulary) + } +} + +/// The latest input level, written from the audio thread and read by the meter once per frame. +public final class LevelSource: @unchecked Sendable { + private let lock = NSLock() + private var value = AudioLevel.silenceFloorDbfs + + public init() {} + + public var dbfs: Float { lock.withLock { value } } + + public func update(_ dbfs: Float) { + lock.withLock { value = dbfs } + } + + public func reset() { + update(AudioLevel.silenceFloorDbfs) + } +} diff --git a/Tests/SstCoreTests/AppGlueTests.swift b/Tests/SstCoreTests/AppGlueTests.swift new file mode 100644 index 0000000..ca67bc7 --- /dev/null +++ b/Tests/SstCoreTests/AppGlueTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import SstCore + +struct GatedInserterTests { + @Test func insertsWhenAllowed() async throws { + let inner = FakeInserter() + try await GatedInserter(inner: inner, shouldInsert: { true }).insert("hallo") + #expect(inner.inserted == ["hallo"]) + } + + @Test func staysSilentWhenNotAllowed() async throws { + let inner = FakeInserter() + try await GatedInserter(inner: inner, shouldInsert: { false }).insert("hallo") + #expect(inner.inserted.isEmpty) + } + + @Test func askedAtInsertionTimeNotAtCreation() async throws { + let inner = FakeInserter() + var allowed = false + let gated = GatedInserter(inner: inner, shouldInsert: { allowed }) + allowed = true + try await gated.insert("nu wel") + #expect(inner.inserted == ["nu wel"]) + } +} + +struct SwappableTranscriberTests { + @Test func throwsWithoutATranscriber() async { + let swappable = SwappableTranscriber() + #expect(!swappable.hasTranscriber) + await #expect(throws: SwappableTranscriber.SwapError.self) { + _ = try await swappable.transcribe([0]) + } + } + + @Test func delegatesIncludingTheVocabulary() async throws { + let inner = FakeVocabularyTranscriber() + inner.result = "klaar" + let swappable = SwappableTranscriber(inner) + #expect(try await swappable.transcribe([0], vocabulary: ["Anthropic"]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"]]) + #expect(try await swappable.transcribe([0]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"], []]) + } + + @Test func swapReplacesTheTranscriber() async throws { + let first = FakeVocabularyTranscriber(), second = FakeVocabularyTranscriber() + first.result = "een"; second.result = "twee" + let swappable = SwappableTranscriber(first) + #expect(try await swappable.transcribe([0]) == "een") + swappable.swap(to: second) + #expect(try await swappable.transcribe([0]) == "twee") + swappable.swap(to: nil) + #expect(!swappable.hasTranscriber) + } +} + +struct LevelSourceTests { + @Test func startsSilentAndKeepsTheLatestValue() { + let source = LevelSource() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + source.update(-23) + source.update(-18) + #expect(source.dbfs == -18) + source.reset() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + } +}