@@ -105,18 +105,36 @@ final class SherpaService: @unchecked Sendable {
105105
106106 // MARK: - Properties
107107
108- /// The active C recognizer (created when a model is loaded)
109- private var recognizer : OpaquePointer ?
108+ /// A request retains its native model even if Settings unloads or replaces it.
109+ /// The per-model lock serializes native decodes without blocking state reads.
110+ private final class LoadedRecognizer : @unchecked Sendable {
111+ let pointer : OpaquePointer
112+ let size : ModelSize
113+ let decodeLock = NSLock ( )
114+
115+ init ( pointer: OpaquePointer , size: ModelSize ) {
116+ self . pointer = pointer
117+ self . size = size
118+ }
110119
111- /// Which model is currently loaded
112- private var loadedSize : ModelSize ?
120+ deinit {
121+ SherpaOnnxDestroyOfflineRecognizer ( pointer)
122+ }
123+ }
113124
114- /// Serializes recognizer lifecycle against decoding
125+ private var loadedRecognizer : LoadedRecognizer ?
126+ private var loadGeneration = UUID ( )
115127 private let recognizerLock = NSLock ( )
116128
117- var isModelLoaded : Bool { recognizer != nil }
129+ var isModelLoaded : Bool { snapshot ( ) != nil }
130+ var loadedModelName : String ? { snapshot ( ) ? . size. rawValue }
118131
119- var loadedModelName : String ? { loadedSize? . rawValue }
132+ /// Retain the model atomically so its lifetime includes all request segments.
133+ private func snapshot( ) -> LoadedRecognizer ? {
134+ recognizerLock. lock ( )
135+ defer { recognizerLock. unlock ( ) }
136+ return loadedRecognizer
137+ }
120138
121139 deinit {
122140 unloadModel ( )
@@ -147,7 +165,7 @@ final class SherpaService: @unchecked Sendable {
147165 language: String ? ,
148166 onPhaseChange: ( ( String ) -> Void ) ? = nil
149167 ) async throws {
150- unloadModel ( )
168+ let generation = clearModel ( )
151169
152170 guard let size = modelName. flatMap ( ModelSize . init ( rawValue: ) ) ,
153171 let spec = SherpaModelCatalog . spec ( for: size) else {
@@ -191,38 +209,49 @@ final class SherpaService: @unchecked Sendable {
191209 throw SherpaError . initializationFailed ( reason: " sherpa-onnx rejected the model files at \( directory. path) " )
192210 }
193211
194- adopt ( recognizer: created, size: size)
212+ guard !Task. isCancelled else {
213+ SherpaOnnxDestroyOfflineRecognizer ( created)
214+ throw CancellationError ( )
215+ }
216+ guard adopt ( recognizer: created, size: size, generation: generation) else {
217+ throw CancellationError ( )
218+ }
195219
196220 let elapsed = CFAbsoluteTimeGetCurrent ( ) - startTime
197221 VocaLogger . info ( . sherpaService, " ONNX model loaded in \( String ( format: " %.2f " , elapsed) ) s " )
198222 }
199223
200- /// Take ownership of a freshly created recognizer.
201- ///
202- /// Destroys whatever was installed before rather than overwriting it:
203- /// the pointer is native memory, so dropping the reference would leak the
204- /// model. Loads are serialized upstream, but this keeps the object safe
205- /// on its own terms.
206- private func adopt( recognizer created: OpaquePointer , size: ModelSize ) {
224+ /// Atomically install the model; existing requests retain their previous model.
225+ private func adopt( recognizer created: OpaquePointer , size: ModelSize , generation: UUID ) -> Bool {
226+ let model = LoadedRecognizer ( pointer: created, size: size)
207227 recognizerLock. lock ( )
208- defer { recognizerLock . unlock ( ) }
209- if let existing = recognizer {
210- SherpaOnnxDestroyOfflineRecognizer ( existing )
228+ guard loadGeneration == generation else {
229+ recognizerLock . unlock ( )
230+ return false
211231 }
212- recognizer = created
213- loadedSize = size
232+ let previous = loadedRecognizer
233+ loadedRecognizer = model
234+ recognizerLock. unlock ( )
235+ // Release native memory outside the state lock.
236+ withExtendedLifetime ( previous) { }
237+ return true
214238 }
215239
216- /// Unload the current model and free memory
240+ /// Remove the active model. In-flight requests release it when decoding finishes.
217241 func unloadModel( ) {
242+ _ = clearModel ( )
243+ }
244+
245+ /// Invalidate pending loads as well as removing the active model.
246+ private func clearModel( ) -> UUID {
218247 recognizerLock. lock ( )
219- if let recognizer {
220- SherpaOnnxDestroyOfflineRecognizer ( recognizer)
221- VocaLogger . info ( . sherpaService, " ONNX model unloaded " )
222- }
223- recognizer = nil
224- loadedSize = nil
248+ let generation = UUID ( )
249+ loadGeneration = generation
250+ let previous = loadedRecognizer
251+ loadedRecognizer = nil
225252 recognizerLock. unlock ( )
253+ withExtendedLifetime ( previous) { }
254+ return generation
226255 }
227256
228257 // MARK: - Transcription
@@ -236,13 +265,10 @@ final class SherpaService: @unchecked Sendable {
236265 audioData: [ Float ] ,
237266 language: String ? = nil
238267 ) async throws -> VocaTranscription {
239- guard isModelLoaded, let size = loadedSize else {
240- throw SherpaError . modelNotLoaded
241- }
268+ guard let request = snapshot ( ) else { throw SherpaError . modelNotLoaded }
269+ let size = request. size
242270
243- guard !audioData. isEmpty else {
244- throw SherpaError . emptyAudio
245- }
271+ try SherpaAudioPreparation . validate ( audioData)
246272
247273 let audioLengthSeconds = Double ( audioData. count) / 16000.0
248274 VocaLogger . info ( . sherpaService, " ONNX transcribing \( String ( format: " %.1f " , audioLengthSeconds) ) s of audio... " )
@@ -264,36 +290,32 @@ final class SherpaService: @unchecked Sendable {
264290 segments = [ audioData]
265291 }
266292
267- let decoded : ( text: String , lang: String ) ? = await withCheckedContinuation { continuation in
268- DispatchQueue . global ( qos: . userInitiated) . async { [ weak self] in
269- guard let self else {
270- continuation. resume ( returning: nil )
271- return
272- }
273- var pieces : [ String ] = [ ]
274- var detected = " "
275- for segment in segments {
276- guard let result = self . decodeLocked ( samples: segment) else {
277- continuation. resume ( returning: nil )
278- return
293+ let decoded : ( text: String , lang: String ) = try await withCheckedThrowingContinuation { continuation in
294+ DispatchQueue . global ( qos: . userInitiated) . async {
295+ do {
296+ // Keep one recognizer for the entire recording, including all segments.
297+ request. decodeLock. lock ( )
298+ defer { request. decodeLock. unlock ( ) }
299+ var pieces : [ String ] = [ ]
300+ var detected = " "
301+ for segment in segments {
302+ let samples = SherpaAudioPreparation . prepare ( segment)
303+ guard !samples. isEmpty else { continue }
304+ let result = try Self . decode ( samples: samples, recognizer: request. pointer)
305+ let piece = result. text. trimmingCharacters ( in: . whitespacesAndNewlines)
306+ if !piece. isEmpty { pieces. append ( piece) }
307+ if detected. isEmpty { detected = result. lang }
279308 }
280- let piece = result. text. trimmingCharacters ( in: . whitespacesAndNewlines)
281- if !piece. isEmpty { pieces. append ( piece) }
282- if detected. isEmpty { detected = result. lang }
309+ let joinLanguage = detected. isEmpty ? ( language ?? " " ) : detected
310+ continuation. resume ( returning: (
311+ Self . joinTranscriptPieces ( pieces, language: joinLanguage) , detected
312+ ) )
313+ } catch {
314+ continuation. resume ( throwing: error)
283315 }
284- // Prefer the model's detected language; fall back to the caller's
285- // preference so CJK SenseVoice output is not space-joined.
286- let joinLanguage = detected. isEmpty ? ( language ?? " " ) : detected
287- continuation. resume (
288- returning: ( Self . joinTranscriptPieces ( pieces, language: joinLanguage) , detected)
289- )
290316 }
291317 }
292318
293- guard let decoded else {
294- throw SherpaError . transcriptionFailed ( reason: " The model was unloaded during transcription. " )
295- }
296-
297319 let elapsed = CFAbsoluteTimeGetCurrent ( ) - startTime
298320 let text = decoded. text. trimmingCharacters ( in: . whitespacesAndNewlines)
299321
@@ -326,16 +348,12 @@ final class SherpaService: @unchecked Sendable {
326348 return pieces. joined ( separator: cjk ? " " : " " )
327349 }
328350
329- /// Run one decode against the active recognizer. Returns nil if no model
330- /// is loaded. Called off the main thread; holds the lock so the
331- /// recognizer cannot be destroyed mid-decode.
332- private func decodeLocked( samples: [ Float ] ) -> ( text: String , lang: String ) ? {
333- recognizerLock. lock ( )
334- defer { recognizerLock. unlock ( ) }
335-
336- guard let recognizer,
337- let stream = SherpaOnnxCreateOfflineStream ( recognizer) else {
338- return nil
351+ /// Decode while the caller holds the recognizer lock for the whole request.
352+ private static func decode(
353+ samples: [ Float ] , recognizer: OpaquePointer
354+ ) throws -> ( text: String , lang: String ) {
355+ guard let stream = SherpaOnnxCreateOfflineStream ( recognizer) else {
356+ throw SherpaError . transcriptionFailed ( reason: " Could not create an ONNX audio stream. " )
339357 }
340358 defer { SherpaOnnxDestroyOfflineStream ( stream) }
341359
@@ -345,7 +363,7 @@ final class SherpaService: @unchecked Sendable {
345363 SherpaOnnxDecodeOfflineStream ( recognizer, stream)
346364
347365 guard let result = SherpaOnnxGetOfflineStreamResult ( stream) else {
348- return ( " " , " " )
366+ throw SherpaError . transcriptionFailed ( reason : " The ONNX decoder returned no result. " )
349367 }
350368 defer { SherpaOnnxDestroyOfflineRecognizerResult ( result) }
351369
0 commit comments