craft-native 0.0.164 → 0.0.167

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.
@@ -13,12 +13,32 @@ import UserNotifications
13
13
  /// `craft.speech` on a native screen: AVSpeechSynthesizer, set up the way the
14
14
  /// page's own speech is (CraftApp.swift). The music ducks rather than stops,
15
15
  /// the silent switch does not mute a cue, and the session is handed back once
16
- /// nothing is left to say.
16
+ /// nothing has been said for a moment.
17
+ ///
18
+ /// Three things a workout's countdown ("3", "2", "1", a second apart) needs:
19
+ /// - An interrupting cue is never lost. AVSpeechSynthesizer can drop an
20
+ /// utterance handed to it straight after `stopSpeaking(at: .immediate)`,
21
+ /// so a cue that cuts another off speaks on a fresh synthesizer while the
22
+ /// stopped one winds down.
23
+ /// - The session stays claimed for `releaseAfterSilence` after the last word,
24
+ /// so the music ducks once for a countdown instead of swelling back up
25
+ /// between every number, and the next number does not wait on the audio
26
+ /// hardware again.
27
+ /// - Activating and deactivating the session blocks until the hardware
28
+ /// answers, so it happens on a queue of its own, never the main thread the
29
+ /// screens' JavaScript and their timers run on.
17
30
  final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
18
31
  static let shared = CraftNativeSpeech()
32
+ /// How long the session is kept after the last word.
33
+ static let releaseAfterSilence: TimeInterval = 1.5
19
34
 
20
- private let synthesizer = AVSpeechSynthesizer()
35
+ private var synthesizer = AVSpeechSynthesizer()
36
+ /// Synthesizers stopped by an interruption, kept until they report it.
37
+ private var retired: [AVSpeechSynthesizer] = []
21
38
  private var pending: [(utterance: AVSpeechUtterance, done: (Bool) -> Void)] = []
39
+ private var pendingRelease: DispatchWorkItem?
40
+ /// The audio session's work, in order. `sessionToRestore` is only touched here.
41
+ private let sessionQueue = DispatchQueue(label: "dev.craft.native.speech-session")
22
42
  private var sessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
23
43
 
24
44
  private override init() {
@@ -27,9 +47,12 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
27
47
  }
28
48
 
29
49
  /// Says `text`; `done` hears true once it was spoken, false when it was
30
- /// stopped or interrupted. Main thread.
50
+ /// stopped or interrupted. With `interrupt` false it waits its turn behind
51
+ /// what is being said. Main thread.
31
52
  func speak(_ text: String, language: String?, rate: Double?, interrupt: Bool, done: @escaping (Bool) -> Void) {
32
- if interrupt { cancelPending() }
53
+ pendingRelease?.cancel()
54
+ pendingRelease = nil
55
+ if interrupt, cancelPending() { retireSynthesizer() }
33
56
  let utterance = AVSpeechUtterance(string: text)
34
57
  utterance.rate = Self.utteranceRate(rate ?? 1)
35
58
  // An unknown language finds no voice, and a nil voice is the device's own.
@@ -37,8 +60,14 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
37
60
  utterance.voice = voice
38
61
  }
39
62
  pending.append((utterance, done))
40
- claimSession()
41
- synthesizer.speak(utterance)
63
+ sessionQueue.async {
64
+ self.claimSession()
65
+ DispatchQueue.main.async {
66
+ // Stopped while the session was being set up: say nothing.
67
+ guard self.pending.contains(where: { $0.utterance === utterance }) else { return }
68
+ self.synthesizer.speak(utterance)
69
+ }
70
+ }
42
71
  }
43
72
 
44
73
  func stop() {
@@ -58,6 +87,14 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
58
87
  return sounding
59
88
  }
60
89
 
90
+ /// The stopped synthesizer winds down on its own; the next cue gets a new one.
91
+ private func retireSynthesizer() {
92
+ retired.append(synthesizer)
93
+ let fresh = AVSpeechSynthesizer()
94
+ fresh.delegate = self
95
+ synthesizer = fresh
96
+ }
97
+
61
98
  /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale: the
62
99
  /// same mapping as the page's speech, so a cue sounds alike in both.
63
100
  static func utteranceRate(_ rate: Double) -> Float {
@@ -69,6 +106,7 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
69
106
  return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
70
107
  }
71
108
 
109
+ /// Session queue.
72
110
  private func claimSession() {
73
111
  guard sessionToRestore == nil else { return }
74
112
  let session = AVAudioSession.sharedInstance()
@@ -83,37 +121,52 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
83
121
  }
84
122
  }
85
123
 
86
- private func releaseSessionIfSilent(retry: Bool = true) {
87
- guard pending.isEmpty, let previous = sessionToRestore else { return }
88
- let session = AVAudioSession.sharedInstance()
89
- do {
90
- try session.setActive(false, options: .notifyOthersOnDeactivation)
91
- } catch {
92
- // Busy: the last of the audio has not drained, or the app plays
93
- // something of its own. Once more shortly covers the first.
94
- if retry {
95
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.releaseSessionIfSilent(retry: false) }
96
- return
124
+ /// Hand the session back once nothing has been said for a moment. Main thread.
125
+ private func releaseSessionIfSilent(retry: Bool = true, after delay: TimeInterval = CraftNativeSpeech.releaseAfterSilence) {
126
+ guard pending.isEmpty else { return }
127
+ pendingRelease?.cancel()
128
+ let work = DispatchWorkItem { [weak self] in
129
+ guard let self else { return }
130
+ self.pendingRelease = nil
131
+ guard self.pending.isEmpty, !self.synthesizer.isSpeaking else { return }
132
+ self.sessionQueue.async {
133
+ guard let previous = self.sessionToRestore else { return }
134
+ let session = AVAudioSession.sharedInstance()
135
+ do {
136
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
137
+ } catch {
138
+ // Busy: the last of the audio has not drained, or the app
139
+ // plays something of its own. Once more shortly covers the first.
140
+ if retry {
141
+ DispatchQueue.main.async { self.releaseSessionIfSilent(retry: false, after: 0.5) }
142
+ return
143
+ }
144
+ }
145
+ self.sessionToRestore = nil
146
+ try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
147
+ DispatchQueue.main.async { CraftNativeSounds.reclaimSessionIfPlaying() }
97
148
  }
98
149
  }
99
- sessionToRestore = nil
100
- try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
101
- CraftNativeSounds.reclaimSessionIfPlaying()
150
+ pendingRelease = work
151
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
102
152
  }
103
153
 
104
- private func finished(_ utterance: AVSpeechUtterance, spoken: Bool) {
154
+ private func finished(_ synthesizer: AVSpeechSynthesizer, _ utterance: AVSpeechUtterance, spoken: Bool) {
105
155
  if let index = pending.firstIndex(where: { $0.utterance === utterance }) {
106
156
  pending.remove(at: index).done(spoken)
107
157
  }
158
+ if synthesizer !== self.synthesizer, !synthesizer.isSpeaking {
159
+ retired.removeAll { $0 === synthesizer }
160
+ }
108
161
  releaseSessionIfSilent()
109
162
  }
110
163
 
111
164
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
112
- DispatchQueue.main.async { self.finished(utterance, spoken: true) }
165
+ DispatchQueue.main.async { self.finished(synthesizer, utterance, spoken: true) }
113
166
  }
114
167
 
115
168
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
116
- DispatchQueue.main.async { self.finished(utterance, spoken: false) }
169
+ DispatchQueue.main.async { self.finished(synthesizer, utterance, spoken: false) }
117
170
  }
118
171
  }
119
172
 
package/dist/mobile.js CHANGED
@@ -1369,6 +1369,12 @@ var pushNotifications = {
1369
1369
  return onCraftEvent("craftNotificationReceived", (detail) => callback(detail));
1370
1370
  }
1371
1371
  };
1372
+ function healthHeartRateSamples(detail) {
1373
+ const samples = detail?.samples;
1374
+ if (!Array.isArray(samples))
1375
+ return [];
1376
+ return samples.filter((sample) => typeof sample?.bpm === "number" && Number.isFinite(sample.bpm) && typeof sample.startDate === "number" && typeof sample.endDate === "number");
1377
+ }
1372
1378
  var health = {
1373
1379
  supports(method) {
1374
1380
  return typeof getCraftRoot()?.health?.[method] === "function";
@@ -1432,6 +1438,29 @@ var health = {
1432
1438
  if (!craft?.health?.getMindfulSessions)
1433
1439
  throw new Error("Reading mindful sessions is unavailable");
1434
1440
  return craft.health.getMindfulSessions(options);
1441
+ },
1442
+ async startHeartRateUpdates(options = {}) {
1443
+ const craft = getCraftRoot();
1444
+ if (!craft?.health?.startHeartRateUpdates)
1445
+ throw new Error("Heart-rate updates are unavailable");
1446
+ return Boolean(await craft.health.startHeartRateUpdates(options));
1447
+ },
1448
+ async stopHeartRateUpdates() {
1449
+ const craft = getCraftRoot();
1450
+ if (!craft?.health?.stopHeartRateUpdates)
1451
+ throw new Error("Heart-rate updates are unavailable");
1452
+ return Boolean(await craft.health.stopHeartRateUpdates());
1453
+ },
1454
+ onHeartRate(callback) {
1455
+ if (typeof window === "undefined" || typeof getCraftRoot()?.health?.startHeartRateUpdates !== "function")
1456
+ return () => {};
1457
+ const listener = (event) => {
1458
+ const samples = healthHeartRateSamples(event.detail);
1459
+ if (samples.length)
1460
+ callback(samples);
1461
+ };
1462
+ window.addEventListener("craftHealthHeartRate", listener);
1463
+ return () => window.removeEventListener("craftHealthHeartRate", listener);
1435
1464
  }
1436
1465
  };
1437
1466
  async function updateLiveActivity(idOrState, state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.164",
3
+ "version": "0.0.167",
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>",