craft-native 0.0.181 → 0.0.183

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,18 +48,16 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
48
48
 
49
49
  /// Says `text`; `done` hears true once it was spoken, false when it was
50
50
  /// stopped or interrupted. With `interrupt` false it waits its turn behind
51
- /// what is being said. Main thread.
52
- func speak(_ text: String, language: String?, rate: Double?, interrupt: Bool, done: @escaping (Bool) -> Void) {
51
+ /// what is being said. `pitch` is 0.5 to 2 (1 is the voice's own), and
52
+ /// `preDelay` is seconds of quiet before the words, after whatever was
53
+ /// said before them. Main thread.
54
+ func speak(_ text: String, language: String?, rate: Double?, pitch: Double? = nil, preDelay: Double? = nil, interrupt: Bool, done: @escaping (Bool) -> Void) {
53
55
  pendingRelease?.cancel()
54
56
  pendingRelease = nil
55
57
  if interrupt, cancelPending() { retireSynthesizer() }
56
- let utterance = AVSpeechUtterance(string: text)
57
- utterance.rate = Self.utteranceRate(rate ?? 1)
58
- // An unknown language finds no voice, and a nil voice is the device's own.
59
- if let language, !language.isEmpty, let voice = AVSpeechSynthesisVoice(language: language) {
60
- utterance.voice = voice
61
- }
58
+ let utterance = Self.utterance(text, language: language, rate: rate, pitch: pitch, preDelay: preDelay)
62
59
  pending.append((utterance, done))
60
+ holdsSession = true
63
61
  sessionQueue.async {
64
62
  self.claimSession()
65
63
  DispatchQueue.main.async {
@@ -70,6 +68,74 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
70
68
  }
71
69
  }
72
70
 
71
+ /// Whether speech has the audio session, or is about to: a sound or a
72
+ /// video starting meanwhile leaves the category to speech, which hands it
73
+ /// over when it lets go (`CraftNativeSounds.reclaimSessionIfPlaying`).
74
+ /// Main thread.
75
+ private(set) var holdsSession = false
76
+
77
+ /// One utterance, set up the same way for a native screen and the page
78
+ /// (CraftApp.swift): the rate on AVSpeechUtterance's scale, the pitch,
79
+ /// the quiet before it, and the best voice installed for the language.
80
+ static func utterance(_ text: String, language: String?, rate: Double?, pitch: Double?, preDelay: Double?) -> AVSpeechUtterance {
81
+ let utterance = AVSpeechUtterance(string: text)
82
+ utterance.rate = utteranceRate(rate ?? 1)
83
+ let wantedPitch = pitch.flatMap { $0.isFinite ? $0 : nil } ?? 1
84
+ utterance.pitchMultiplier = Float(min(max(wantedPitch, 0.5), 2))
85
+ let quiet = preDelay.flatMap { $0.isFinite ? $0 : nil } ?? 0
86
+ utterance.preUtteranceDelay = min(max(quiet, 0), 5)
87
+ // An unknown language finds no voice, and a nil voice is the device's own.
88
+ if let voice = bestVoice(for: language) { utterance.voice = voice }
89
+ return utterance
90
+ }
91
+
92
+ private static var voices: [String: AVSpeechSynthesisVoice] = [:]
93
+ private static var voicesObserver: NSObjectProtocol?
94
+
95
+ /// The best voice on this phone for `language` (the device's own when
96
+ /// nil): a premium voice when one is installed, else an enhanced one,
97
+ /// else the compact voice iOS would pick. Among voices of one quality the
98
+ /// one sharing the default voice's name wins ("Anna (Enhanced)" over
99
+ /// another enhanced voice when Anna is the default), so a download in
100
+ /// Settings changes how a voice sounds, not who it is. Novelty voices and
101
+ /// the person's own Personal Voice are never picked. Remembered per
102
+ /// language until the installed voices change. Main thread.
103
+ static func bestVoice(for language: String?) -> AVSpeechSynthesisVoice? {
104
+ let wanted = (language?.isEmpty == false ? language! : AVSpeechSynthesisVoice.currentLanguageCode())
105
+ if let known = voices[wanted] { return known }
106
+ if voicesObserver == nil {
107
+ if #available(iOS 17.0, *) {
108
+ voicesObserver = NotificationCenter.default.addObserver(forName: AVSpeechSynthesizer.availableVoicesDidChangeNotification, object: nil, queue: .main) { _ in
109
+ CraftNativeSpeech.voices.removeAll()
110
+ }
111
+ }
112
+ }
113
+ guard let standard = AVSpeechSynthesisVoice(language: wanted) else { return nil }
114
+ let name = Self.baseName(standard.name)
115
+ let candidates = AVSpeechSynthesisVoice.speechVoices().filter { voice in
116
+ guard voice.language == standard.language else { return false }
117
+ if #available(iOS 17.0, *) {
118
+ if voice.voiceTraits.contains(.isNoveltyVoice) || voice.voiceTraits.contains(.isPersonalVoice) { return false }
119
+ }
120
+ return true
121
+ }
122
+ let best = candidates.max { lhs, rhs in
123
+ let left = (lhs.quality.rawValue, Self.baseName(lhs.name) == name ? 1 : 0, lhs.identifier == standard.identifier ? 1 : 0)
124
+ let right = (rhs.quality.rawValue, Self.baseName(rhs.name) == name ? 1 : 0, rhs.identifier == standard.identifier ? 1 : 0)
125
+ if left != right { return left < right }
126
+ // Stable between launches: the same voice every time.
127
+ return lhs.identifier > rhs.identifier
128
+ } ?? standard
129
+ let chosen = best.quality.rawValue >= standard.quality.rawValue ? best : standard
130
+ voices[wanted] = chosen
131
+ return chosen
132
+ }
133
+
134
+ /// "Anna (Enhanced)" and "Anna (Premium)" are both Anna.
135
+ private static func baseName(_ name: String) -> String {
136
+ name.components(separatedBy: " (").first ?? name
137
+ }
138
+
73
139
  func stop() {
74
140
  // When something was sounding, its didCancel hands the session back
75
141
  // once the audio has stopped; deactivating before that fails as busy.
@@ -95,8 +161,13 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
95
161
  synthesizer = fresh
96
162
  }
97
163
 
98
- /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale: the
99
- /// same mapping as the page's speech, so a cue sounds alike in both.
164
+ /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale, for
165
+ /// a native screen and the page alike.
166
+ ///
167
+ /// Apple's scale is not linear in speed: the default sits at 0.5 and the
168
+ /// maximum is far faster than twice that. So a slower rate scales the
169
+ /// default down in proportion, and a faster one climbs toward the maximum
170
+ /// at half the slope, which keeps 2 brisk but intelligible.
100
171
  static func utteranceRate(_ rate: Double) -> Float {
101
172
  let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
102
173
  let normal = AVSpeechUtteranceDefaultSpeechRate
@@ -129,11 +200,25 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
129
200
  guard let self else { return }
130
201
  self.pendingRelease = nil
131
202
  guard self.pending.isEmpty, !self.synthesizer.isSpeaking else { return }
203
+ let soundsPlaying = CraftNativeSounds.isPlaying
132
204
  self.sessionQueue.async {
133
- guard let previous = self.sessionToRestore else { return }
205
+ guard let previous = self.sessionToRestore else {
206
+ // The claim failed: there is nothing to hand back.
207
+ DispatchQueue.main.async {
208
+ guard self.pending.isEmpty else { return }
209
+ self.holdsSession = false
210
+ CraftNativeSounds.reclaimSessionIfPlaying(handedBack: nil)
211
+ }
212
+ return
213
+ }
134
214
  let session = AVAudioSession.sharedInstance()
135
215
  do {
136
- try session.setActive(false, options: .notifyOthersOnDeactivation)
216
+ // Deactivating ends the ducking. A video or a sound still
217
+ // playing keeps it busy; the category put back below
218
+ // carries no ducking, so the music comes back up anyway.
219
+ if !soundsPlaying {
220
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
221
+ }
137
222
  } catch {
138
223
  // Busy: the last of the audio has not drained, or the app
139
224
  // plays something of its own. Once more shortly covers the first.
@@ -144,7 +229,11 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
144
229
  }
145
230
  self.sessionToRestore = nil
146
231
  try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
147
- DispatchQueue.main.async { CraftNativeSounds.reclaimSessionIfPlaying() }
232
+ DispatchQueue.main.async {
233
+ // A cue that came in meanwhile claims the session again.
234
+ if self.pending.isEmpty { self.holdsSession = false }
235
+ CraftNativeSounds.reclaimSessionIfPlaying(handedBack: previous)
236
+ }
148
237
  }
149
238
  }
150
239
  pendingRelease = work
@@ -337,12 +426,26 @@ final class CraftNativeSounds {
337
426
  RunLoop.main.add(timer, forMode: .common)
338
427
  }
339
428
 
429
+ /// Whether a sound, a native screen's Web Audio or a video is playing.
430
+ static var isPlaying: Bool { playing > 0 }
431
+ /// A sound started while speech held the session: speech's category was
432
+ /// left alone (it plays too, ducking the music under the cue), and the
433
+ /// category to put back later is the one speech hands back.
434
+ private static var claimedUnderSpeech = false
435
+
340
436
  /// Takes the session for one more playing sound. Native screens' Web
341
- /// Audio (CraftWebAudio.swift) counts here too, so speech hands the
342
- /// session back to whichever of them still plays.
437
+ /// Audio (CraftWebAudio.swift) and videos (CraftNativeScreen.swift) count
438
+ /// here too, so speech hands the session back to whichever of them still
439
+ /// plays. Main thread.
343
440
  static func claimSession(respectSilentSwitch: Bool) {
344
441
  playing += 1
345
- category = respectSilentSwitch ? .ambient : .playback
442
+ // Playback outranks ambient: a video heard through the silent switch
443
+ // is not muted by a breathing exercise's rain starting after it.
444
+ category = respectSilentSwitch && (playing == 1 || category == .ambient) ? .ambient : .playback
445
+ if CraftNativeSpeech.shared.holdsSession {
446
+ if sessionToRestore == nil { claimedUnderSpeech = true }
447
+ return
448
+ }
346
449
  let session = AVAudioSession.sharedInstance()
347
450
  if sessionToRestore == nil { sessionToRestore = (session.category, session.mode, session.categoryOptions) }
348
451
  do {
@@ -355,14 +458,28 @@ final class CraftNativeSounds {
355
458
 
356
459
  static func releaseSession() {
357
460
  playing = max(0, playing - 1)
358
- guard playing == 0, let previous = sessionToRestore else { return }
461
+ guard playing == 0 else { return }
462
+ if claimedUnderSpeech {
463
+ // Speech still holds the session and puts its own back.
464
+ claimedUnderSpeech = false
465
+ return
466
+ }
467
+ guard let previous = sessionToRestore else { return }
359
468
  sessionToRestore = nil
469
+ if CraftNativeSpeech.shared.holdsSession { return }
360
470
  try? AVAudioSession.sharedInstance().setCategory(previous.0, mode: previous.1, options: previous.2)
361
471
  }
362
472
 
363
- /// Speech handed the session back with the category it found, which may
364
- /// predate a sound started while it spoke: take it again for the sound.
365
- static func reclaimSessionIfPlaying() {
473
+ /// Speech handed the session back with the category it found
474
+ /// (`handedBack`, nil when it never took it), which may predate a sound
475
+ /// started while it spoke: take it again for the sound. Main thread.
476
+ static func reclaimSessionIfPlaying(handedBack: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?) {
477
+ if claimedUnderSpeech {
478
+ claimedUnderSpeech = false
479
+ let session = AVAudioSession.sharedInstance()
480
+ sessionToRestore = handedBack ?? (session.category, session.mode, session.categoryOptions)
481
+ if playing > 0 { try? session.setActive(true) }
482
+ }
366
483
  guard playing > 0 else { return }
367
484
  try? AVAudioSession.sharedInstance().setCategory(category, mode: .default, options: [.mixWithOthers])
368
485
  }
@@ -522,7 +522,8 @@ enum CraftStatusBar {
522
522
  }
523
523
  guard let root = CraftPresenter.keyWindow()?.rootViewController else { return false }
524
524
  teach(root)
525
- UIView.animate(withDuration: 0.2) { root.setNeedsStatusBarAppearanceUpdate() }
525
+ // In step with a screen's animated change (craft.animation.next) when one runs.
526
+ UIView.animate(withDuration: CraftNativeAnimationClock.duration ?? 0.2) { root.setNeedsStatusBarAppearanceUpdate() }
526
527
  return true
527
528
  }
528
529
 
package/dist/mobile.js CHANGED
@@ -688,6 +688,7 @@ var speech = {
688
688
  return new Promise((resolve) => {
689
689
  const utterance = new Utterance(text);
690
690
  utterance.rate = speechRate(options.rate);
691
+ utterance.pitch = speechRate(options.pitch);
691
692
  if (options.language)
692
693
  utterance.lang = options.language;
693
694
  const settle = (spoken) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.181",
3
+ "version": "0.0.183",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",