Skip to content
5 changes: 4 additions & 1 deletion Sources/PladderCore/Support/OutputMuteController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Foundation
public actor OutputMuteController: OutputMuter {
private let control: any OutputMuteControl
private let delay: Duration
private let clock: any Clock<Duration>
private let log: @Sendable (String) -> Void

// A pending arm that wakes to find this changed belongs to a session already over.
Expand All @@ -15,10 +16,12 @@ public actor OutputMuteController: OutputMuter {
public init(
control: any OutputMuteControl,
delay: Duration = .milliseconds(200),
clock: any Clock<Duration> = ContinuousClock(),
log: @escaping @Sendable (String) -> Void = { _ in }
) {
self.control = control
self.delay = delay
self.clock = clock
self.log = log
}

Expand All @@ -29,7 +32,7 @@ public actor OutputMuteController: OutputMuter {
latestSession = session
generation += 1
let mine = generation
try? await Task.sleep(for: delay)
try? await clock.sleep(for: delay)
guard generation == mine else { return }
guard restore == nil else {
// An earlier session muted it and its end has not come; this session's end restores it.
Expand Down
2 changes: 1 addition & 1 deletion Sources/PladderRefine/S1MiniPolisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public actor S1MiniPolisher: ReportingRefiner {
private var loading: Task<(any PromptCompleter)?, Never>?
// llama.cpp ignores cancellation, so a load `unload()` gave up on still reads weights;
// the next load waits for it rather than holding two models at once.
private var abandoned: Task<(any PromptCompleter)?, Never>?
private(set) var abandoned: Task<(any PromptCompleter)?, Never>?
// Bumped by `unload()`, so a load it interrupted cannot store its model.
private var generation = 0
private static let log = Logger(subsystem: "de.dinooo13.pladder", category: "polish")
Expand Down
16 changes: 11 additions & 5 deletions Tests/PladderCoreTests/CoordinatorRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,16 @@ import Testing
@Test func aPressRightAfterACancelKeepsItsUtterance() async {
let engine = FakeStreamingEngine()
let (c, output, capture, _) = await makeStreamingCoordinator(style: .compact, engine: engine)
await capture.setStopDelay(.milliseconds(150))
let stopGate = Gate()
await capture.setStopGate(stopGate)
await c.startIdle()
await c.hotkeyPressed()
let cancel = Task { await c.cancelRecording() }
#expect(await waitUntil { c.state == .idle })
await c.hotkeyPressed()
let press = Task { await c.hotkeyPressed() }
#expect(await waitUntil { c.state.isRecording })
await stopGate.open()
await press.value
#expect(c.state.isRecording)
await cancel.value
c.hotkeyReleased()
Expand Down Expand Up @@ -92,7 +96,8 @@ import Testing

@Test func aPressCancelledWhileItWaitsLeavesTheMicrophoneAlone() async {
let (c, _, capture) = makeCoordinator()
await capture.setStopDelay(.milliseconds(150))
let stopGate = Gate()
await capture.setStopGate(stopGate)
await c.startIdle()
await c.hotkeyPressed()
let cancel = Task { await c.cancelRecording() }
Expand All @@ -103,6 +108,7 @@ import Testing
#expect(await waitUntil { c.state == .idle })
let press = Task { await c.hotkeyPressed() }
#expect(await waitUntil { c.state.isRecording })
await stopGate.open()
_ = await (cancel.value, cancelledPress.value, cancelPress.value, press.value)
#expect(c.state.isRecording)
#expect(await capture.startCount == 2)
Expand Down Expand Up @@ -290,7 +296,7 @@ import Testing
let (c, output, _) = makeCoordinator(clock: clock)
await c.startIdle()
await c.hotkeyPressed()
#expect(await waitUntil { clock.sleeperCount >= 2 })
#expect(await clock.waitForSleepers(2))
clock.advance(by: c.maximumDuration - .seconds(1))
#expect(c.state.isRecording)
clock.advance(by: .seconds(1))
Expand Down Expand Up @@ -354,7 +360,7 @@ import Testing
await c.hotkeyPressed()
#expect(await waitUntil { warmPasses() == 1 })
for expected in 2...4 {
#expect(await waitUntil { clock.sleeperCount >= 2 })
#expect(await clock.waitForSleepers(2))
clock.advance(by: c.warmupInterval)
#expect(await waitUntil { warmPasses() == expected })
}
Expand Down
66 changes: 28 additions & 38 deletions Tests/PladderCoreTests/DictationCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ actor FakeCapture: AudioCapture {
var startCount = 0
var stopCount = 0
var levelContinuation: AsyncStream<Float>.Continuation?
var stopDelay: Duration = .zero
var stopGate: Gate?

func start() async throws -> AsyncStream<Float> {
startCount += 1
Expand All @@ -33,15 +33,15 @@ actor FakeCapture: AudioCapture {
func stop() async -> CapturedAudio {
stopCount += 1
levelContinuation?.finish()
if stopDelay > .zero { try? await Task.sleep(for: stopDelay) }
await stopGate?.wait()
return CapturedAudio(samples: samplesToReturn)
}

func warmUp() async throws {}

func setSamples(_ s: [Float]) { samplesToReturn = s }
func setDrainSamples(_ s: [Float]) { drainSamples = s }
func setStopDelay(_ d: Duration) { stopDelay = d }
func setStopGate(_ gate: Gate) { stopGate = gate }
func emitLevel(_ l: Float) { levelContinuation?.yield(l) }
}

Expand Down Expand Up @@ -126,20 +126,20 @@ final class FakeRefiner: TranscriptRefiner, @unchecked Sendable {
private var _calls: [String] = []
private var _prepareCount = 0
private let result: String?
private let delay: Duration
private let gate: Gate?
var calls: [String] { lock.withLock { _calls } }
var prepareCount: Int { lock.withLock { _prepareCount } }

init(result: String? = "polished", delay: Duration = .zero) {
init(result: String? = "polished", gate: Gate? = nil) {
self.result = result
self.delay = delay
self.gate = gate
}

func prepare() async { lock.withLock { _prepareCount += 1 } }

func refine(_ text: String) async -> String? {
lock.withLock { _calls.append(text) }
if delay > .zero { try? await Task.sleep(for: delay) }
await gate?.wait()
return result
}
}
Expand Down Expand Up @@ -184,15 +184,15 @@ actor CountingEngine: TranscriptionEngine {
nonisolated let displayName = "Counting"
private(set) var status: EngineStatus = .unloaded
private let recorder = Recorder<Int>()
private let delay: Duration
private let warmGate: Gate?

init(delay: Duration = .zero) { self.delay = delay }
init(warmGate: Gate? = nil) { self.warmGate = warmGate }
nonisolated var calls: [Int] { recorder.all }

func load() async throws { status = .ready }
func transcribe(_ samples: [Float]) async throws -> Transcript {
recorder.append(samples.count)
if delay > .zero { try? await Task.sleep(for: delay) }
if samples.count == 8_000 { await warmGate?.wait() }
return Transcript(text: "counted", audioDuration: Double(samples.count) / CapturedAudio.sampleRate, processingTime: 0, engineID: id)
}
func unload() { status = .unloaded }
Expand Down Expand Up @@ -357,10 +357,10 @@ extension EngineRegistry.Entry {

@MainActor
func makeCountingCoordinator(
engineDelay: Duration = .zero,
warmGate: Gate? = nil,
clock: any Clock<Duration> = ContinuousClock()
) -> (DictationCoordinator, FakeOutput, FakeCapture, CountingEngine) {
let engine = CountingEngine(delay: engineDelay)
let engine = CountingEngine(warmGate: warmGate)
var settings = DictationSettings(engineID: CountingEngine.engineID)
settings.appendTrailingSpace = false
let (coordinator, output, capture) = makeCoordinator(engines: [.serving(engine)], settings: settings, clock: clock)
Expand Down Expand Up @@ -389,26 +389,6 @@ func makeStreamingCoordinator(
return (coordinator, output, capture, engine)
}

@MainActor
func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool {
let deadline = ContinuousClock.now + timeout
while ContinuousClock.now < deadline {
if condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return condition()
}

// A dropped recording is idle at once; its microphone stops on a task after.
func eventually(_ timeout: Duration = .seconds(2), _ condition: () async -> Bool) async -> Bool {
let deadline = ContinuousClock.now + timeout
while ContinuousClock.now < deadline {
if await condition() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return await condition()
}

extension DictationCoordinator {
func startIdle(sourceLocation: SourceLocation = #_sourceLocation) async {
start()
Expand Down Expand Up @@ -652,12 +632,15 @@ final class EventLog: @unchecked Sendable {
let a = FakeHotkey()
let b = FakeHotkey()
let (c, _, capture) = makeCoordinator(hotkeyMonitor: a)
await capture.setStopDelay(.milliseconds(50))
let stopGate = Gate()
await capture.setStopGate(stopGate)
await c.startIdle()
await c.press(a)
c.update(c.settings, standInHotkey: c.standInHotkey, monitor: b)
#expect(c.state == .idle)
b.press()
#expect(await waitUntil { c.state.isRecording })
await stopGate.open()
#expect(await waitUntil { c.handledHotkeyEvents == 2 })
#expect(c.state.isRecording)
#expect(await capture.startCount == 2)
Expand Down Expand Up @@ -851,9 +834,14 @@ final class EventLog: @unchecked Sendable {
}

@Test func releaseDuringWarmupStillInserts() async {
let (c, output, _, _) = makeCountingCoordinator(engineDelay: .milliseconds(200))
let warmGate = Gate()
let (c, output, _, engine) = makeCountingCoordinator(warmGate: warmGate)
await c.startIdle()
await c.dictate()
await c.hotkeyPressed()
#expect(await waitUntil { engine.calls == [8_000] })
c.hotkeyReleased()
await warmGate.open()
await c.inFlight?.value
#expect(output.inserted.count == 1)
#expect(c.state == .idle)
}
Expand Down Expand Up @@ -1090,7 +1078,8 @@ final class EventLog: @unchecked Sendable {
}

@Test func polishingStateIsPublishedWhileTheModelRuns() async {
let refiner = FakeRefiner(delay: .milliseconds(200))
let gate = Gate()
let refiner = FakeRefiner(gate: gate)
let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner)
await c.startIdle()
await c.hotkeyPressed()
Expand All @@ -1099,6 +1088,7 @@ final class EventLog: @unchecked Sendable {
#expect(await waitUntil { c.state == .polishing })
#expect(c.willPolish)
#expect(output.inserted.isEmpty)
await gate.open()
await c.inFlight?.value
#expect(c.state == .idle)
#expect(!c.willPolish)
Expand Down Expand Up @@ -1327,7 +1317,7 @@ final class EventLog: @unchecked Sendable {
await c.press(hotkey)
hotkey.release()
#expect(await waitUntil { c.isLatched })
#expect(await waitUntil { clock.sleeperCount >= 2 })
#expect(await clock.waitForSleepers(2))
clock.advance(by: c.maximumDuration)
await c.cycleFinished()
#expect(output.inserted.count == 1)
Expand Down Expand Up @@ -1464,7 +1454,7 @@ final class EventLog: @unchecked Sendable {
#expect(await waitUntil { c.state == .idle })
#expect(hotkey.cancelKeyEnabled == [true, false, true, false])
await c.hotkeyPressed()
#expect(await waitUntil { clock.sleeperCount >= 2 })
#expect(await clock.waitForSleepers(2))
clock.advance(by: c.maximumDuration)
#expect(await waitUntil { !c.state.isRecording })
await c.inFlight?.value
Expand Down
11 changes: 6 additions & 5 deletions Tests/PladderCoreTests/EngineLoaderTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import PladderTestSupport
import Testing
@testable import PladderCore

Expand Down Expand Up @@ -63,7 +64,8 @@ import Testing

await first.open()
#expect(await first.waitUntilLoaded())
// Time for the superseded load task to resume on the main actor.
// Negative path: a superseded load leaves nothing to wait for, so this gives its task
// time to resume on the main actor.
try? await Task.sleep(for: .milliseconds(50))
#expect(loader.engine.id == second.id)
#expect(loader.status == .downloading(progress: 0.5))
Expand All @@ -82,6 +84,7 @@ private actor GatedEngine: TranscriptionEngine {
private var progress: Double
private var isOpen = false
private var waiters: [CheckedContinuation<Void, Never>] = []
private let loaded = Gate()

init(id: EngineID, progress: Double) {
self.id = id
Expand All @@ -94,13 +97,11 @@ private actor GatedEngine: TranscriptionEngine {
await withCheckedContinuation { waiters.append($0) }
}
status = .ready
await loaded.open()
}

func waitUntilLoaded() async -> Bool {
for _ in 0..<100 {
if status == .ready { return true }
try? await Task.sleep(for: .milliseconds(10))
}
await loaded.wait()
return status == .ready
}

Expand Down
28 changes: 17 additions & 11 deletions Tests/PladderCoreTests/OrderedTasksTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,31 @@ import Testing

private actor Downloads {
private(set) var running = false
private let windDown: Gate

init(windDown: Gate) { self.windDown = windDown }

func ensure() { running = true }
// Winding down, as a cancelled URLSession task does, and only then marked stopped.
func cancel() async {
await windDown.wait()
running = false
// Winding down, as a cancelled URLSession task does.
try? await Task.sleep(for: .milliseconds(5))
}
}

@MainActor
@Suite(.timeLimit(.minutes(1))) struct OrderedTasksTests {
@Test func aCancelThenEnsureLeavesTheDownloadRunning() async {
for _ in 0..<50 {
let downloads = Downloads()
await downloads.ensure()
let tasks = OrderedTasks()
tasks.enqueue { await downloads.cancel() }
tasks.enqueue { await downloads.ensure() }
await tasks.drained()
#expect(await downloads.running)
}
let windDown = Gate()
let downloads = Downloads(windDown: windDown)
await downloads.ensure()
let tasks = OrderedTasks()
tasks.enqueue { await downloads.cancel() }
tasks.enqueue { await downloads.ensure() }
await windDown.untilSomeoneWaits()
await windDown.open()
await tasks.drained()
#expect(await downloads.running)
}

@Test func eachPieceStartsAfterThePreviousOneFinished() async {
Expand All @@ -37,6 +42,7 @@ private actor Downloads {
}
tasks.enqueue { order.append("second") }
await gate.untilSomeoneWaits()
// Gives a wrongly started second piece time to show.
try? await Task.sleep(for: .milliseconds(10))
#expect(order.all.isEmpty)
await gate.open()
Expand Down
Loading
Loading