Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions ios/ScribaKeyboard/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -164,12 +172,19 @@ final class AudioRecorder: ObservableObject {
var samples = [Int16](repeating: 0, count: frames)
for i in 0..<frames { samples[i] = channel[0][i] }

// RMS level (0...1) for the waveform.
let rms = sqrt(
samples.reduce(0.0) { $0 + pow(Double($1) / 32767.0, 2) } / Double(frames))
let normalized = Float(min(1.0, rms * 4))

lock.withLock { pcmSamples.append(contentsOf: samples) }

// Publish an RMS level (0...1) for the waveform, throttled to ~15 Hz so
// the UI isn't re-evaluated for every audio buffer (~25-50/sec). Only
// accessed from the (serial) tap callback.
guard levelLimiter.shouldFire(at: CFAbsoluteTimeGetCurrent()) else { return }
var sumOfSquares = 0.0
for sample in samples {
let x = Double(sample) / 32767.0
sumOfSquares += x * x
}
let rms = sqrt(sumOfSquares / Double(frames))
let normalized = Float(min(1.0, rms * 4))
publish(level: normalized)
}

Expand Down
31 changes: 31 additions & 0 deletions ios/ScribaKeyboard/DictationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ final class DictationController: ObservableObject {
// and install a second audio tap (an uncatchable crash).
private var isStarting = false

// Bumped by `cancel()` so an in-flight start or transcription that crosses a
// cancellation (keyboard dismissed mid-dictation) discards its result.
private var generation = 0

init() {
// If the system cuts the recording short (call, another app, AirPods
// removed), finalize what we captured rather than losing it.
Expand Down Expand Up @@ -83,8 +87,15 @@ final class DictationController: ObservableObject {
if isStarting || state == .recording { return }
isStarting = true
defer { isStarting = false }
let gen = generation
do {
try await recorder.start()
// Cancelled (keyboard dismissed) while the recorder was starting —
// don't leave the mic hot.
guard gen == generation else {
_ = recorder.stop()
return
}
live.start() // live preview; no-op if speech permission isn't granted
state = .recording
} catch AudioRecorder.RecorderError.microphoneDenied {
Expand All @@ -95,6 +106,12 @@ final class DictationController: ObservableObject {
}

private func finishRecording() async {
// Idempotency: two stop paths can race (double tap on stop, an
// interruption firing alongside a route change, an interruption racing a
// user stop). We're @MainActor with no suspension point before the state
// flips below, so this guard serializes them — only the first proceeds.
guard state == .recording else { return }
let gen = generation
live.stop()
let audio = recorder.stop()
// A header-only WAV (no captured samples) isn't worth a round-trip — it'd
Expand All @@ -106,16 +123,30 @@ final class DictationController: ObservableObject {
state = .transcribing
do {
let transcript = try await TranscriptionClient().transcribe(audio: audio)
guard gen == generation else { return } // cancelled mid-transcription
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { onTranscript?(transcript) }
state = .idle
} catch let error as TranscriptionError {
guard gen == generation else { return }
setError(error.errorDescription ?? "Transcription failed")
} catch {
guard gen == generation else { return }
setError("Transcription failed")
}
}

/// Aborts any in-flight dictation (e.g. the keyboard is being dismissed):
/// stops the recorder so the mic doesn't stay hot, ends the live preview, and
/// discards any pending transcript instead of inserting it later.
func cancel() {
generation += 1
guard state != .idle else { return }
live.stop()
if state == .recording { _ = recorder.stop() }
state = .idle
}

/// Resets a transient error back to idle (e.g. after showing it briefly).
func clearError() {
if case .error = state { state = .idle }
Expand Down
13 changes: 9 additions & 4 deletions ios/ScribaKeyboard/KeyboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import UIKit
/// / return). Text insertion itself is handled by the host view controller.
struct KeyboardView: View {
@ObservedObject var dictation: DictationController
@ObservedObject var recorder: AudioRecorder
// Deliberately NOT @ObservedObject: the recorder publishes the input level
// many times a second, and only the Waveform child should re-render for it.
let recorder: AudioRecorder
@ObservedObject var context: KeyboardContext
@ObservedObject var live: LiveTranscriber

Expand Down Expand Up @@ -105,7 +107,7 @@ struct KeyboardView: View {
// (head-truncated so the latest words stay visible); fall back to
// the waveform until the first words arrive.
if live.interim.isEmpty {
Waveform(level: recorder.level)
Waveform(recorder: recorder)
} else {
Text(live.interim)
.foregroundColor(.white)
Expand Down Expand Up @@ -204,9 +206,12 @@ struct KeyboardView: View {
}
}

/// A lightweight bar waveform driven by the recorder's normalized level.
/// A lightweight bar waveform driven by the recorder's normalized level. It
/// observes the recorder itself so each level tick re-renders only these bars,
/// not the whole keyboard.
private struct Waveform: View {
var level: Float
@ObservedObject var recorder: AudioRecorder
private var level: Float { recorder.level }
private let bars = 13

var body: some View {
Expand Down
8 changes: 8 additions & 0 deletions ios/ScribaKeyboard/KeyboardViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ final class KeyboardViewController: UIInputViewController {
context.update(from: textDocumentProxy)
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// The keyboard is going away mid-dictation: stop the mic (otherwise the
// audio engine keeps it hot), end the live preview, and drop any pending
// transcript so it isn't inserted into whatever field comes next.
dictation.cancel()
}

override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
// The host field (and its keyboard type / secure flag) can change as the
Expand Down
12 changes: 8 additions & 4 deletions ios/ScribaKeyboard/LiveTranscriber.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,14 @@ final class LiveTranscriber: ObservableObject {
lock.withLock { self.request = request }

task = recognizer.recognitionTask(with: request) { [weak self] result, _ in
guard let text = result?.bestTranscription.formattedString else {
return
}
self?.publish(text)
guard let self,
let text = result?.bestTranscription.formattedString,
// A late result can arrive after `stop()` (or after a new session
// started); only the current request may publish, so a stale one
// can't resurrect old interim text.
self.lock.withLock({ self.request === request })
else { return }
self.publish(text)
}
}

Expand Down
20 changes: 20 additions & 0 deletions ios/Shared/RateLimiter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// Pure rate limiter: caps how often a periodic event fires (e.g. throttling
/// audio-level publishes to the UI). Not thread-safe — use from one queue.
struct RateLimiter {
private let interval: TimeInterval
private var last: TimeInterval = -.infinity

init(interval: TimeInterval) {
self.interval = interval
}

/// Returns true (and arms the next window) if at least `interval` seconds
/// have passed since the last accepted fire.
mutating func shouldFire(at now: TimeInterval) -> Bool {
guard now - last >= interval else { return false }
last = now
return true
}
}
43 changes: 43 additions & 0 deletions ios/Tests/RateLimiterTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading