From 77e72365fd20a73d548dda68e35a02ffd83cf08c Mon Sep 17 00:00:00 2001 From: Francesco Giannicola Date: Thu, 11 Jun 2026 18:45:10 +0200 Subject: [PATCH] fix(ios): harden dictation stop paths, keyboard teardown, and level metering - DictationController.finishRecording is now idempotent (guard on .recording) so racing stop paths (double tap, interruption + route change, interruption vs user stop) can't both finalize; a generation counter discards in-flight starts/transcripts after cancellation - AudioRecorder.stop clears captured samples after reading them so a second stop can't return the same audio twice - KeyboardViewController.viewWillDisappear cancels active dictation via new DictationController.cancel(), so a dismissed keyboard stops the mic, ends the live preview, and drops the pending transcript - LiveTranscriber ignores late recognition results from a stale request (identity check against the current request) so stopped sessions can't republish interim text - Level metering: publishes throttled to ~15 Hz via a new pure RateLimiter helper, RMS computed with x*x instead of pow, and the Waveform child now observes the recorder itself so per-tick re-renders no longer hit the whole keyboard - Tests: RateLimiterTests cover the new throttling logic Co-Authored-By: Claude Fable 5 --- ios/ScribaKeyboard/AudioRecorder.swift | 27 +++++++++--- ios/ScribaKeyboard/DictationController.swift | 31 +++++++++++++ ios/ScribaKeyboard/KeyboardView.swift | 13 ++++-- .../KeyboardViewController.swift | 8 ++++ ios/ScribaKeyboard/LiveTranscriber.swift | 12 ++++-- ios/Shared/RateLimiter.swift | 20 +++++++++ ios/Tests/RateLimiterTests.swift | 43 +++++++++++++++++++ ios/project.yml | 1 + 8 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 ios/Shared/RateLimiter.swift create mode 100644 ios/Tests/RateLimiterTests.swift diff --git a/ios/ScribaKeyboard/AudioRecorder.swift b/ios/ScribaKeyboard/AudioRecorder.swift index 708eaf3..c29fb05 100644 --- a/ios/ScribaKeyboard/AudioRecorder.swift +++ b/ios/ScribaKeyboard/AudioRecorder.swift @@ -32,6 +32,8 @@ final class AudioRecorder: ObservableObject { private let lock = NSLock() private var pcmSamples = [Int16]() private var sessionObservers: [NSObjectProtocol] = [] + /// Caps level publishes at ~15 Hz; touched only from the serial tap callback. + private var levelLimiter = RateLimiter(interval: 1.0 / 15.0) enum RecorderError: Error { case microphoneDenied @@ -90,7 +92,13 @@ final class AudioRecorder: ObservableObject { try? AVAudioSession.sharedInstance().setActive( false, options: [.notifyOthersOnDeactivation]) publish(isRecording: false, level: 0) - let samples = lock.withLock { pcmSamples } + // Take the samples and clear them, so a second stop (e.g. a racing + // interruption) can't return the same audio twice. + let samples = lock.withLock { + let taken = pcmSamples + pcmSamples.removeAll(keepingCapacity: true) + return taken + } return WAVEncoder.encode(samples: samples, sampleRate: Int(targetSampleRate)) } @@ -164,12 +172,19 @@ final class AudioRecorder: ObservableObject { var samples = [Int16](repeating: 0, count: frames) for i in 0.. Bool { + guard now - last >= interval else { return false } + last = now + return true + } +} diff --git a/ios/Tests/RateLimiterTests.swift b/ios/Tests/RateLimiterTests.swift new file mode 100644 index 0000000..028b951 --- /dev/null +++ b/ios/Tests/RateLimiterTests.swift @@ -0,0 +1,43 @@ +import XCTest + +final class RateLimiterTests: XCTestCase { + func testFirstCallFires() { + var limiter = RateLimiter(interval: 1.0 / 15.0) + XCTAssertTrue(limiter.shouldFire(at: 0)) + } + + func testCallsWithinIntervalAreSuppressed() { + var limiter = RateLimiter(interval: 0.1) + XCTAssertTrue(limiter.shouldFire(at: 0)) + XCTAssertFalse(limiter.shouldFire(at: 0.05)) + XCTAssertFalse(limiter.shouldFire(at: 0.099)) + } + + func testFiresAgainAfterInterval() { + var limiter = RateLimiter(interval: 0.1) + XCTAssertTrue(limiter.shouldFire(at: 0)) + XCTAssertTrue(limiter.shouldFire(at: 0.1)) + XCTAssertFalse(limiter.shouldFire(at: 0.15)) + XCTAssertTrue(limiter.shouldFire(at: 0.25)) + } + + func testSuppressedCallsDoNotResetTheWindow() { + var limiter = RateLimiter(interval: 0.1) + XCTAssertTrue(limiter.shouldFire(at: 0)) + // Hammering it during the window must not push the next fire back. + XCTAssertFalse(limiter.shouldFire(at: 0.03)) + XCTAssertFalse(limiter.shouldFire(at: 0.06)) + XCTAssertFalse(limiter.shouldFire(at: 0.09)) + XCTAssertTrue(limiter.shouldFire(at: 0.1)) + } + + func testCapsBurstToExpectedRate() { + // 50 buffer callbacks over one second → roughly 15 publishes at 15 Hz + // (a bit fewer in practice: 20 ms callbacks quantize fires to every + // 80 ms, i.e. 13), and never the full 50. + var limiter = RateLimiter(interval: 1.0 / 15.0) + let fires = (0..<50).filter { limiter.shouldFire(at: Double($0) / 50.0) } + XCTAssertLessThanOrEqual(fires.count, 16) + XCTAssertGreaterThanOrEqual(fires.count, 12) + } +} diff --git a/ios/project.yml b/ios/project.yml index 583290c..031d62a 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -49,6 +49,7 @@ targets: - Shared/WAVEncoder.swift - Shared/FormURLEncoding.swift - Shared/Credentials.swift + - Shared/RateLimiter.swift - Scriba/Auth/PKCE.swift - Scriba/Auth/OAuthCallback.swift - ScribaKeyboard/FieldMode.swift