feat: add GatedInserter, SwappableTranscriber and LevelSource
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user