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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public final class T3KeyboardCommandsModule: Module {
public final class T3KeyboardCommandsView: ExpoView {
let onCommand = EventDispatcher()
private var enabledCommands = Set<String>()
private var dictationHeld = false

public override var canBecomeFirstResponder: Bool { true }

Expand Down Expand Up @@ -50,16 +51,25 @@ public final class T3KeyboardCommandsView: ExpoView {

public override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if holdsToTalk, Self.isDictationChord(presses) {
onCommand(["command": "dictationHoldStart"])
if !dictationHeld {
dictationHeld = true
onCommand(["command": "dictationHoldStart"])
}
return
}

super.pressesBegan(presses, with: event)
}

public override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if holdsToTalk, Self.isDictationChord(presses) {
onCommand(["command": "dictationHoldEnd"])
// The modifier can be released before D. Matching the original chord at
// key-up misses that order and leaves capture running indefinitely.
if dictationHeld, presses.contains(where: { press in
guard let key = press.key else { return false }
return key.charactersIgnoringModifiers.lowercased() == "d"
|| !key.modifierFlags.contains(.alternate)
}) {
endDictationHold()
return
}

Expand All @@ -69,14 +79,20 @@ public final class T3KeyboardCommandsView: ExpoView {
public override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
// A cancelled press still has to stop the recording, or releasing the key
// outside the app leaves the microphone running.
if holdsToTalk, Self.isDictationChord(presses) {
onCommand(["command": "dictationHoldEnd"])
if dictationHeld {
endDictationHold()
return
}

super.pressesCancelled(presses, with: event)
}

private func endDictationHold() {
guard dictationHeld else { return }
dictationHeld = false
onCommand(["command": "dictationHoldEnd"])
}

/// Option plus D, matched on the unmodified character so a layout that puts
/// something else on that key still works. Command is deliberately not part
/// of it: iPadOS binds Command plus Option plus D to showing the dock.
Expand All @@ -91,6 +107,7 @@ public final class T3KeyboardCommandsView: ExpoView {

func setEnabledCommands(_ commands: [String]) {
enabledCommands = Set(commands)
if !holdsToTalk { endDictationHold() }
if isFirstResponder {
resignFirstResponder()
}
Expand Down Expand Up @@ -136,6 +153,7 @@ public final class T3KeyboardCommandsView: ExpoView {

public override func didMoveToWindow() {
super.didMoveToWindow()
if window == nil { endDictationHold() }
reclaimFirstResponderIfAvailable()
}

Expand Down
12 changes: 8 additions & 4 deletions apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2682,12 +2682,16 @@ private final class SourceTextView: UITextView {
backgroundColor = theme.background
tintColor = .systemBlue
sourceLayout.numberColor = theme.mutedText
sourceLayout.numberFont = .monospacedSystemFont(ofSize: style.lineNumberFontSize,
weight: style.lineNumberFontWeight)
sourceLayout.numberFont = .monospacedSystemFont(
ofSize: style.lineNumberFontSize,
weight: style.lineNumberFontWeight
)
let digits = (String(lines.count) as NSString).size(withAttributes: [.font: sourceLayout.numberFont]).width
sourceLayout.gutterWidth = max(style.gutterWidth, digits + 24)
textContainerInset = UIEdgeInsets(top: 8, left: sourceLayout.gutterWidth,
bottom: 96 + safeAreaInsets.bottom, right: 16)
textContainerInset = UIEdgeInsets(
top: 8, left: sourceLayout.gutterWidth,
bottom: 96 + safeAreaInsets.bottom, right: 16
)
wraps = style.contentWidth < 32_000
textContainer.widthTracksTextView = wraps
textContainer.size = CGSize(width: wraps ? max(1, bounds.width - textContainerInset.left - 16) : 32_000,
Expand Down
26 changes: 18 additions & 8 deletions apps/mobile/modules/t3-voice/ios/BackgroundActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,27 @@ import UIKit
/// the promise never settles, and the composer is left in a phase it cannot
/// leave. The assertion does not make the work unlimited; it makes the work
/// finish or stop cleanly instead of vanishing.
enum BackgroundActivity {
static func begin(_ name: String) async -> UIBackgroundTaskIdentifier {
await MainActor.run {
UIApplication.shared.beginBackgroundTask(withName: name, expirationHandler: nil)
@MainActor
final class BackgroundActivity {
private var identifier: UIBackgroundTaskIdentifier = .invalid

static func begin(
_ name: String,
onExpiration: @escaping @Sendable () -> Void
) -> BackgroundActivity {
let activity = BackgroundActivity()
activity.identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak activity] in
// iOS requires ending the assertion when time expires. Keeping it open
// until inference finishes can terminate the process and lose the draft.
onExpiration()
Task { @MainActor in activity?.end() }
}
return activity
}

static func end(_ identifier: UIBackgroundTaskIdentifier) async {
func end() {
guard identifier != .invalid else { return }
await MainActor.run {
UIApplication.shared.endBackgroundTask(identifier)
}
UIApplication.shared.endBackgroundTask(identifier)
identifier = .invalid
}
}
9 changes: 8 additions & 1 deletion apps/mobile/modules/t3-voice/ios/FluidAudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,15 @@ actor FluidAudioEngine {
func transcribe(
audioPath: String,
locale: String?,
speakerFiltering: Bool
speakerFiltering: Bool,
model: (id: String, folder: URL),
diarizerFolder: URL?
) async throws -> VoiceTranscriptionOutput {
// Preparation may have finished minutes ago, before a memory warning.
try await prepare(modelId: model.id, modelFolder: model.folder)
if speakerFiltering, let diarizerFolder {
try await prepareDiarizer(modelFolder: diarizerFolder)
}
guard let asrManager else {
throw VoiceEngineError.modelUnavailable("No speech model is loaded.")
}
Expand Down
18 changes: 14 additions & 4 deletions apps/mobile/modules/t3-voice/ios/T3VoiceModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,25 @@ public class T3VoiceModule: Module {

AsyncFunction("transcribe") { (operationId: String, modelId: String, audioPath: String, locale: String?, speakerFiltering: Bool, promise: Promise) in
self.run(operationId: operationId, promise: promise) {
guard let folder = try Self.resolveModelFolder(modelId) else {
throw VoiceEngineError.modelUnavailable("Model \(modelId) is not installed.")
}
let before = DeviceMemory.footprint()
defer { Self.reportRunCost(stage: "transcribe", modelId: modelId, before: before) }

guard FluidAudioEngine.asrVersion(forModelId: modelId) != nil else {
let text = try await self.engine.transcribe(audioPath: audioPath, locale: locale)
let text = try await self.engine.transcribe(
audioPath: audioPath, locale: locale, modelId: modelId, modelFolder: folder
)
return Self.encode(VoiceTranscriptionOutput(text: text, speakerFiltering: .notRequested))
}

let output = try await self.fluidAudio.transcribe(
audioPath: audioPath,
locale: locale,
speakerFiltering: speakerFiltering
speakerFiltering: speakerFiltering,
model: (id: modelId, folder: folder),
diarizerFolder: speakerFiltering ? try Self.resolveModelFolder(FluidAudioEngine.diarizerModelId) : nil
)
return Self.encode(output)
}
Expand Down Expand Up @@ -270,10 +277,13 @@ public class T3VoiceModule: Module {
// Inference outlives a home-button press often enough that this matters:
// without the assertion the promise never settles and the composer is
// stuck in a phase it cannot leave.
let assertion = await BackgroundActivity.begin("T3Voice.\(operationId)")
defer { Task { await BackgroundActivity.end(assertion) } }
let assertion = await BackgroundActivity.begin("T3Voice.\(operationId)") {
Task { await self.operations.cancel(operationId) }
}
defer { Task { await assertion.end() } }

do {
try Task.checkCancellation()
let value = try await work()
try Task.checkCancellation()
promise.resolve(value)
Expand Down
21 changes: 11 additions & 10 deletions apps/mobile/modules/t3-voice/ios/WhisperKitEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ actor WhisperKitEngine {
/// Set while a load is in flight so concurrent callers await the same work
/// instead of loading a second copy of the same multi-hundred-megabyte model.
private var loadTask: Task<WhisperKit, Error>?
private var loadingModelId: String?

func prepare(modelId: String, modelFolder: URL) async throws -> WhisperKit {
if let whisperKit, loadedModelId == modelId {
return whisperKit
}

if let loadTask, loadedModelId == modelId {
if let loadTask, loadingModelId == modelId {
return try await loadTask.value
}

Expand All @@ -50,31 +51,31 @@ actor WhisperKitEngine {
return try await WhisperKit(config)
}

loadedModelId = modelId
loadingModelId = modelId
loadTask = task

do {
let loaded = try await task.value
whisperKit = loaded
loadedModelId = modelId
loadTask = nil
loadingModelId = nil
return loaded
} catch {
loadTask = nil
if whisperKit == nil {
loadedModelId = nil
}
loadingModelId = nil
throw error
}
}

func transcribe(audioPath: String, locale: String?) async throws -> String {
guard let whisperKit else {
throw VoiceEngineError.modelUnavailable("No speech model is loaded.")
}
func transcribe(audioPath: String, locale: String?, modelId: String, modelFolder: URL) async throws -> String {
// Memory pressure can evict the model while the microphone is running.
let whisperKit = try await prepare(modelId: modelId, modelFolder: modelFolder)
try Task.checkCancellation()

let options = DecodingOptions(
task: .transcribe,
language: locale,
language: locale.flatMap { Locale(identifier: $0).language.languageCode?.identifier },
skipSpecialTokens: true
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,13 @@ export function ComposerDictationStartAction(props: {
const openSettings = props.state.phase === "error" && props.state.errorAction === "settings";
return (
<VoiceActionButton
accessibilityLabel={openSettings ? "Open microphone settings" : "Start dictation"}
accessibilityLabel={
openSettings
? "Open microphone settings"
: props.state.phase === "error"
? "Retry dictation"
: "Start dictation"
}
disabled={props.disabled}
icon="mic"
onPress={
Expand Down
21 changes: 15 additions & 6 deletions apps/mobile/src/features/voice-input/useVoiceInputController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export function useVoiceInputController(input: {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const persistPreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" });
const persistPreferencesRef = useRef(persistPreferences);
persistPreferencesRef.current = persistPreferences;
const preferences = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null;
const cleanupSettings = useMemo(
() => (preferences ? resolveVoiceCleanupSettings(preferences) : null),
Expand Down Expand Up @@ -180,10 +183,10 @@ export function useVoiceInputController(input: {
const settings = cleanupSettingsRef.current;
return settings ? getLocalVoiceCleanup(settings) : null;
},
persistPendingTranscript: (pending) => {
persistPendingTranscript: async (pending) => {
// Stamped here, not in the controller: the timestamp exists only so a
// later launch can tell this record from one a dead session left behind.
savePreferencesRef.current({
await persistPreferencesRef.current({
voicePendingTranscript: { ...pending, capturedAt: Date.now() },
});
},
Expand Down Expand Up @@ -256,7 +259,9 @@ export function useVoiceInputController(input: {
const sampleRecording = () => {
if (controller.currentState.phase !== "recording") return;
const status = recorder.getStatus();
if (!status.isRecording) return;
const isRecording = recorder.isRecording;
void controller.handleRecordingProgress({ ...status, isRecording });
if (!isRecording) return;

const level = normalizeVoiceInputDecibels(status.metering);
const history = audioLevelsRef.current;
Expand Down Expand Up @@ -315,10 +320,14 @@ export function useVoiceInputController(input: {
return true;
}, [canStart, controller]);

// Releasing the keys ends the recording. Holding through a phase that is not
// recording is not an error; there is simply nothing to stop.
// Key release also queues a stop during microphone startup, so a quick hold
// cannot leave capture running after the keys are already up.
const endHold = useCallback(() => {
if (controller.currentState.phase !== "recording") return false;
if (
controller.currentState.phase !== "recording" &&
controller.currentState.phase !== "preparing"
)
return false;
void controller.stop();
return true;
}, [controller]);
Expand Down
31 changes: 23 additions & 8 deletions docs/internals/voice-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,33 @@ transcript is committed.

## Durability

**The recording audio is deleted after the cleanup stage, not after transcription.**

**The raw transcript is written to disk before cleanup starts.** Cleanup loads a
**Failed transcription retains the recording for an explicit retry.** The microphone action retries
that file without reopening capture. Dismissing the error or leaving the composer releases it;
successful insertion deletes it. Audio is currently retained only for the mounted controller, not
across process termination.

Expo can pause an `AVAudioRecorder` without a completion event. The metering loop checks the native
`isRecording` property and asks the controller to finish the captured file when capture stops.
Backgrounding and recorder errors also finish the captured audio, with an interruption notice.
The captured URI takes precedence over the recorder's current URI because a media-services reset
can replace the recorder with a new, empty file. Speech engines ensure their model is loaded again
when transcription begins, since a memory warning can evict it during a long recording.

**The raw transcript's disk write is awaited before cleanup starts.** Cleanup loads a
multi-hundred-megabyte model, and an allocation that gets the app jetsam-killed raises no error to
catch. On the next launch the record is offered back into the draft it belongs to, matched on owner,
and discarded once the user accepts or dismisses it.
and discarded once the user accepts or dismisses it. A failed recovery write skips cleanup and
commits raw text. Normal resource release does not clear an uncommitted transcript, including when
navigation or a changed draft prevented insertion.

Cleanup degrades to the raw transcript on a throw, a cancel, a rewrite the model never finished,
empty output, or an output-to-input length ratio outside a defined band. A local model given a
transcript it does not understand will answer it, translate it, or apologize; all three miss the
ratio. The timeout is enforced natively, between generated tokens, because nothing in JS can
interrupt a running model.
empty output, an output-to-input length ratio outside 0.85–1.6, or a changed final three words
(ignoring punctuation and capitalization). The ending check is deliberately conservative and can
reject a legitimate spelling correction. These checks are heuristics, not proof of semantic
equivalence. A local model can answer, translate, or summarize a transcript; length and ending
checks catch some of these failures. The timeout is enforced natively, between generated tokens,
because nothing in JS can interrupt a running model. Native background assertions end on expiration
and request cancellation of the associated operation.

**A rewrite carries whether the model finished it, and an unfinished one is never committed.**
Generation stops at the model's end of turn, at the output-token cap, or at the timeout. The last two
Expand Down
17 changes: 11 additions & 6 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,13 @@ Recording starts right away. The first dictation after opening the app may still
speech model when you finish talking; if so the composer says so, and the wait is only ever once
per model. With a hardware keyboard, hold `Option+D` to talk. **Settings, Voice Input** switches that to press once to start and again to finish, or turns it off.

A recording can be up to five minutes long. Canceling voice input, leaving the screen, or an audio
interruption discards the new recording and keeps your existing draft and attachments. PseudoCode
deletes the local audio file when it is done with it. It sends only the normal message text when you
submit the draft.
A recording can be up to five minutes long. If the microphone is interrupted or the app moves to
the background, PseudoCode finishes transcribing the audio captured so far and tells you that
recording stopped. If transcription fails, tap the microphone to retry the saved recording.
Dismissing that error discards the saved audio. Canceling voice input or leaving the screen during
recording discards the new recording and keeps your existing draft and attachments. PseudoCode
deletes the local audio file after successful transcription and cleanup. It sends only the normal
message text when you submit the draft.

### Choosing a speech model

Expand All @@ -273,8 +276,10 @@ Losing your own words would be worse than leaving a stray voice in.
### Cleaning up transcripts

Turn on **Clean up transcripts** and a language model on your device rewrites what you said as
written text: punctuation, capitalization, and obvious mishearings fixed, filler words removed. It
does not answer, summarize, or add anything.
written text: punctuation, capitalization, and obvious mishearings fixed, filler words removed.
If cleanup times out, produces an incomplete rewrite, or changes the ending, PseudoCode keeps the
original transcription and tells you. A correction to the last few words can also trigger this
conservative fallback.

Three cleanup models are available, trading speed for quality. You can edit the instructions they
follow and reset them to the default at any time.
Expand Down
Loading
Loading