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
12 changes: 10 additions & 2 deletions Sources/App/VoiceInputSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,18 @@ struct VoiceInputSettings {
var espressoModelPath: String { processing.espressoModelPath }

@MainActor
init(settings: AppSettings, inputLanguage: InputLanguage? = nil) {
init(
settings: AppSettings,
inputLanguage: InputLanguage? = nil,
bundleIdentifier: String? = nil
) {
processing = TextProcessingOptions(settings: settings, inputLanguage: inputLanguage)
speech = SpeechEngineProvider.Selection(settings: settings, inputLanguage: inputLanguage)
dictionary = PersonalDictionary.shared.snapshot(settings: settings)
dictionary = PersonalDictionary.shared.snapshot(
settings: settings,
bundleIdentifier: bundleIdentifier,
languageCode: (inputLanguage ?? settings.inputLanguage).whisperCode
)
outputMode = settings.outputMode
enableInstantInsert = settings.enableInstantInsert
enableMemory = settings.enableMemory
Expand Down
20 changes: 18 additions & 2 deletions Sources/App/VoicePipeline+Processing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ extension VoicePipeline {
showNoSpeechDetected(reason: "recorded audio energy below threshold")
return
}
guard await recordingContainsSpeech(audioURL) else {
showNoSpeechDetected(reason: "recorded audio has no speech evidence")
return
}

let preparedRaw = try await transcribePreparedText(
audioURL: audioURL,
Expand Down Expand Up @@ -86,6 +90,15 @@ extension VoicePipeline {
}
}

private func recordingContainsSpeech(_ audioURL: URL?) async -> Bool {
#if DEBUG
if let speechActivityOverrideForTesting {
return await speechActivityOverrideForTesting(audioURL)
}
#endif
return await SpeechActivityClassifier.containsSpeech(at: audioURL)
}

private func transcribePreparedText(
audioURL: URL?,
audioActivity: AudioCaptureActivity,
Expand All @@ -103,8 +116,11 @@ extension VoicePipeline {
Log.info("[VoicePipeline] ASR stage finished in \(String(format: "%.2f", elapsed))s")

try Task.checkCancellation()
guard let prepared = TranscriptionSanitizer.prepare(raw, audioActivity: audioActivity) else {
showNoSpeechDetected(reason: "transcription has no meaningful content: \(raw)")
guard let prepared = TranscriptionSanitizer.prepare(
raw, audioActivity: audioActivity,
recognitionPhrases: settings.dictionary.recognitionPhrases
) else {
showNoSpeechDetected(reason: "transcription has no meaningful content")
throw VoicePipelineStop.noSpeech
}

Expand Down
8 changes: 6 additions & 2 deletions Sources/App/VoicePipeline+Recording.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ extension VoicePipeline {
sessionLease = lease
var started = false
defer { if !started { releaseSession(lease) } }
let snapshot = VoiceInputSettings(settings: appState.settings)
let snapshot = VoiceInputSettings(
settings: appState.settings, bundleIdentifier: targetApp?.bundleIdentifier
)
sessionSettings = snapshot
recordingLanguage = snapshot.inputLanguage.whisperCode
recordingStreaming = snapshot.streamingEnabled
Expand Down Expand Up @@ -89,7 +91,9 @@ extension VoicePipeline {
let streamingEnabled = recordingStreaming && (currentEngine?.supportsStreaming ?? false)
recordingStreaming = streamingEnabled
currentEngine?.configureRecognition(
context: SpeechRecognitionContext(phrases: vocabularySnapshot.recognitionPhrases)
context: SpeechRecognitionContext(phrases: currentEngine is QwenNativeASREngine
? vocabularySnapshot.personalRecognitionPhrases
: vocabularySnapshot.recognitionPhrases)
)
if streamingEnabled {
currentEngine?.startListening(language: language) { [weak self] partialText in
Expand Down
3 changes: 3 additions & 0 deletions Sources/App/VoicePipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ final class VoicePipeline {
var engineLoadBarrier: (() async -> Void)?
/// Test-only observation point for whether the remote capture path is used.
var remoteCaptureSpy: RemoteMicCaptureSpy?
#if DEBUG
var speechActivityOverrideForTesting: ((URL?) async -> Bool)?
#endif

var currentEngine: (any SpeechEngine)? { engineOverride ?? sessionEngine }

Expand Down
115 changes: 115 additions & 0 deletions Sources/Audio/SpeechActivityClassifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import AVFoundation
import Foundation
import SoundAnalysis

enum SpeechActivityClassifier {
static let minimumSpeechConfidence = 0.6
private static let windowSeconds = 0.5
private static let minimumFileSeconds = 0.75

static func containsSpeech(at audioURL: URL?) async -> Bool {
guard let audioURL, !Task.isCancelled else { return false }
var paddedURL: URL?
defer {
if let paddedURL { try? FileManager.default.removeItem(at: paddedURL) }
}

do {
let analysisURL = try preparedURL(audioURL, paddedURL: &paddedURL)
let request = try SNClassifySoundRequest(classifierIdentifier: .version1)
request.windowDuration = CMTime(seconds: windowSeconds, preferredTimescale: 16_000)
let observer = SpeechClassificationObserver()
let analyzer = try SNAudioFileAnalyzer(url: analysisURL)
try analyzer.add(request, withObserver: observer)
let completed = await withTaskCancellationHandler {
await analyzer.analyze()
} onCancel: {
analyzer.cancelAnalysis()
}
let result = observer.result
return completed && !Task.isCancelled && !result.failed
&& result.windows > 0 && result.maxSpeech >= minimumSpeechConfidence
} catch {
Log.error("[SpeechActivity] classification failed")
return false
}
}

private static func preparedURL(_ url: URL, paddedURL: inout URL?) throws -> URL {
let source = try AVAudioFile(forReading: url)
let format = source.processingFormat
guard format.sampleRate > 0, source.length > 0 else { throw SpeechActivityError.invalidAudio }
let requiredFrames = Int(ceil(minimumFileSeconds * format.sampleRate))
guard source.length < requiredFrames else { return url }
guard requiredFrames <= Int(UInt32.max),
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(requiredFrames)) else {
throw SpeechActivityError.invalidAudio
}
try source.read(into: buffer)
let readFrames = Int(buffer.frameLength)
guard readFrames > 0, readFrames < requiredFrames else { throw SpeechActivityError.invalidAudio }

let channels = Int(format.channelCount)
if let data = buffer.floatChannelData {
for channel in 0..<channels {
for frame in readFrames..<requiredFrames { data[channel][frame] = 0 }
}
} else if let data = buffer.int16ChannelData {
for channel in 0..<channels {
for frame in readFrames..<requiredFrames { data[channel][frame] = 0 }
}
} else if let data = buffer.int32ChannelData {
for channel in 0..<channels {
for frame in readFrames..<requiredFrames { data[channel][frame] = 0 }
}
} else {
throw SpeechActivityError.invalidAudio
}
buffer.frameLength = AVAudioFrameCount(requiredFrames)
let target = FileManager.default.temporaryDirectory
.appendingPathComponent("utter-speech-\(UUID().uuidString).wav")
paddedURL = target
let output = try AVAudioFile(
forWriting: target,
settings: format.settings,
commonFormat: format.commonFormat,
interleaved: format.isInterleaved
)
try output.write(from: buffer)
return target
}
}

private enum SpeechActivityError: Error {
case invalidAudio
}

private final class SpeechClassificationObserver: NSObject, SNResultsObserving {
private let lock = NSLock()
private var windows = 0
private var maxSpeech = 0.0
private var failed = false

var result: (windows: Int, maxSpeech: Double, failed: Bool) {
lock.lock()
defer { lock.unlock() }
return (windows, maxSpeech, failed)
}

func request(_ request: SNRequest, didProduce result: SNResult) {
guard let result = result as? SNClassificationResult else { return }
let speech = result.classifications.first(where: { $0.identifier == "speech" })?.confidence ?? 0
lock.lock()
windows += 1
maxSpeech = max(maxSpeech, speech)
lock.unlock()
}

func request(_ request: SNRequest, didFailWithError error: Error) {
lock.lock()
failed = true
lock.unlock()
}

func requestDidComplete(_ request: SNRequest) {}
}
10 changes: 8 additions & 2 deletions Sources/Integration/InputSessionCoordinator+AudioFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ extension InputSessionCoordinator {
guard let snapshot = self.requestSettings else { throw CancellationError() }
let vocabulary = snapshot.dictionary
let engine = try await self.loadSpeechEngine()
engine.configureRecognition(context: SpeechRecognitionContext(phrases: vocabulary.recognitionPhrases))
engine.configureRecognition(context: self.recognitionContext(engine: engine, snapshot: vocabulary))
let active = ActiveSession(
sessionID: sessionID, clientID: clientID, engine: engine,
languageCode: effective.languageCode, mode: effective.mode,
Expand All @@ -33,9 +33,15 @@ extension InputSessionCoordinator {
try self.service.emitAudioReceived(sessionID: sessionID, clientID: clientID)
try await self.service.beginProcessing(sessionID: sessionID, clientID: clientID)
try self.checkCurrent()
guard await self.recordingContainsSpeech(audioURL) else {
throw IntegrationError.noSpeechDetected
}
try self.checkCurrent()
let raw = try await engine.transcribe(audioURL: audioURL, language: effective.languageCode)
try self.checkCurrent()
let transcript = try self.prepareTranscript(raw, audioActivity: nil)
let transcript = try self.prepareTranscript(
raw, audioActivity: nil, dictionarySnapshot: vocabulary
)
try self.service.emitTranscriptFinal(sessionID: sessionID, clientID: clientID, text: transcript)
let text = try await self.outputText(for: transcript, active: active)
try self.checkCurrent()
Expand Down
22 changes: 22 additions & 0 deletions Sources/Integration/InputSessionCoordinator+Dictionary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

@MainActor
extension InputSessionCoordinator {
func recognitionContext(
engine: any SpeechEngine,
snapshot: PersonalDictionarySnapshot
) -> SpeechRecognitionContext {
return SpeechRecognitionContext(phrases: engine is QwenNativeASREngine
? snapshot.personalRecognitionPhrases
: snapshot.recognitionPhrases)
}

func recordingContainsSpeech(_ audioURL: URL?) async -> Bool {
#if DEBUG
if let speechActivityOverrideForTesting {
return await speechActivityOverrideForTesting(audioURL)
}
#endif
return await SpeechActivityClassifier.containsSpeech(at: audioURL)
}
}
6 changes: 5 additions & 1 deletion Sources/Integration/InputSessionCoordinator+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ extension InputSessionCoordinator {
let reservation = try ownership.acquire()
lease = reservation
owner = (sessionID, clientID)
requestSettings = VoiceInputSettings(settings: settings, inputLanguage: session.request.language)
requestSettings = VoiceInputSettings(
settings: settings,
inputLanguage: session.request.language,
bundleIdentifier: service.integrationClient(id: clientID)?.bundleIdentifier
)
return session
}

Expand Down
26 changes: 22 additions & 4 deletions Sources/Integration/InputSessionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ final class InputSessionCoordinator {
var activeSession: ActiveSession?
var requestSettings: VoiceInputSettings?
var pendingHistory: (() -> Void)?
#if DEBUG
var speechActivityOverrideForTesting: ((URL?) async -> Bool)?
#endif

var isBusy: Bool { lease != nil }

Expand Down Expand Up @@ -66,7 +69,7 @@ final class InputSessionCoordinator {
let engine = try await loadSpeechEngine()
let vocabularySnapshot = snapshot.dictionary
engine.configureRecognition(
context: SpeechRecognitionContext(phrases: vocabularySnapshot.recognitionPhrases)
context: recognitionContext(engine: engine, snapshot: vocabularySnapshot)
)

if effective.streamingEnabled, engine.supportsStreaming {
Expand Down Expand Up @@ -170,6 +173,10 @@ final class InputSessionCoordinator {
guard audioCapture.lastActivity.hasMeaningfulAudio else {
throw IntegrationError.noSpeechDetected
}
guard await recordingContainsSpeech(audioCapture.lastRecordingURL) else {
throw IntegrationError.noSpeechDetected
}
try checkCurrent()

let raw: String
if active.streamingEnabled {
Expand All @@ -185,7 +192,10 @@ final class InputSessionCoordinator {
}

try checkCurrent()
let transcript = try prepareTranscript(raw, audioActivity: audioCapture.lastActivity)
let transcript = try prepareTranscript(
raw, audioActivity: audioCapture.lastActivity,
dictionarySnapshot: active.snapshot?.dictionary
)

try service.emitTranscriptFinal(
sessionID: active.sessionID,
Expand All @@ -202,8 +212,16 @@ final class InputSessionCoordinator {
audioCapture.cleanupLastRecording()
}

func prepareTranscript(_ raw: String, audioActivity: AudioCaptureActivity?) throws -> String {
guard let transcript = TranscriptionSanitizer.prepare(raw, audioActivity: audioActivity) else {
func prepareTranscript(
_ raw: String,
audioActivity: AudioCaptureActivity?,
dictionarySnapshot: PersonalDictionarySnapshot? = nil
) throws -> String {
let vocabulary = dictionarySnapshot ?? requestSettings?.dictionary
guard let transcript = TranscriptionSanitizer.prepare(
raw, audioActivity: audioActivity,
recognitionPhrases: vocabulary?.recognitionPhrases ?? []
) else {
throw IntegrationError.noSpeechDetected
}
return transcript
Expand Down
3 changes: 1 addition & 2 deletions Sources/Output/CorrectionCaptureService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ final class CorrectionCaptureService {
inserted: session.seed.insertedText,
userFinal: session.latestFinalText,
sourceRecordID: session.recordID,
languageCode: session.seed.context.inputLanguage.whisperCode
?? session.seed.context.inputLanguage.rawValue,
languageCode: session.seed.context.inputLanguage.whisperCode,
bundleIdentifier: session.seed.context.bundleIdentifier
) {
PersonalDictionary.shared.recordLearnedCandidate(candidate)
Expand Down
6 changes: 6 additions & 0 deletions Sources/Output/TextInserter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ enum InsertResult {
@MainActor
final class TextInserter {
var recentInsertionAnchor: RecentInsertionAnchor?
#if DEBUG
var insertOverrideForTesting: ((String) -> InsertResult)?
#endif

func insert(text: String, targetApp: NSRunningApplication? = nil) async -> InsertResult {
guard !Task.isCancelled else { return .probablyFailed(reason: L("error.operation_failed")) }
#if DEBUG
if let insertOverrideForTesting { return insertOverrideForTesting(text) }
#endif
guard AXIsProcessTrusted() else {
Log.error("[TextInserter] no AX trust")
return .probablyFailed(reason: "Accessibility permission not granted")
Expand Down
1 change: 1 addition & 0 deletions Sources/Processing/CorrectionCandidateClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ enum CorrectionCandidateClassifier {
languageCode: String?,
bundleIdentifier: String?
) -> LearnedCorrectionCandidate? {
guard !LearnedCorrectionPolicy.isUnsafeSource(inserted) else { return nil }
guard let diff = CorrectionEditDiff.between(inserted, userFinal) else { return nil }
var original = diff.beforeSegment.trimmingCharacters(in: .whitespacesAndNewlines)
var replacement = diff.afterSegment.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
Loading
Loading