craft-native 0.0.115 → 0.0.118

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.
@@ -5,6 +5,14 @@
5
5
  {{PERMISSIONS}}
6
6
  {{HEALTH_CONNECT_QUERIES}}
7
7
 
8
+ <!-- craft.speech: from Android 11 an app cannot see, and so cannot bind,
9
+ a text-to-speech engine it has not declared it will look for. -->
10
+ <queries>
11
+ <intent>
12
+ <action android:name="android.intent.action.TTS_SERVICE" />
13
+ </intent>
14
+ </queries>
15
+
8
16
  <!-- Camera feature (optional) -->
9
17
  <uses-feature android:name="android.hardware.camera" android:required="false" />
10
18
  <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
@@ -36,6 +36,11 @@ import android.provider.Settings
36
36
  import android.speech.RecognitionListener
37
37
  import android.speech.RecognizerIntent
38
38
  import android.speech.SpeechRecognizer
39
+ import android.speech.tts.TextToSpeech
40
+ import android.speech.tts.UtteranceProgressListener
41
+ import android.media.AudioAttributes
42
+ import android.media.AudioFocusRequest
43
+ import android.media.AudioManager
39
44
  import android.util.DisplayMetrics
40
45
  import android.webkit.JavascriptInterface
41
46
  import android.webkit.WebView
@@ -199,6 +204,17 @@ internal object CraftPermissionPolicy {
199
204
  isGranted(Manifest.permission.ACCESS_COARSE_LOCATION)
200
205
  }
201
206
 
207
+ private data class SpeechRequest(
208
+ val text: String,
209
+ val rate: Float,
210
+ val language: String?,
211
+ val callbackId: Int,
212
+ val generation: Int
213
+ )
214
+
215
+ /** The navigation it belongs to, so a page that has gone hears nothing. */
216
+ private data class PendingUtterance(val callbackId: Int, val generation: Int)
217
+
202
218
  private data class PendingPermissionRequest(
203
219
  val callbackId: Int,
204
220
  val permission: String,
@@ -279,6 +295,19 @@ class CraftBridge(
279
295
  // Keep awake
280
296
  private var isKeepingAwake = false
281
297
 
298
+ // Speech synthesis, all of it touched on the main thread only. The engine
299
+ // is bound on the first speak rather than here, so an app that never
300
+ // speaks never starts a TTS service. Requests made while it binds wait in
301
+ // queuedSpeech; each utterance handed to it waits in pendingUtterances,
302
+ // under the id the engine reports back, for the page call it answers.
303
+ private var textToSpeech: TextToSpeech? = null
304
+ private var textToSpeechReady = false
305
+ private val queuedSpeech = mutableListOf<SpeechRequest>()
306
+ private val pendingUtterances = linkedMapOf<String, PendingUtterance>()
307
+ private var nextUtteranceId = 0L
308
+ private var speechFocusRequest: AudioFocusRequest? = null
309
+ private val speechHandler = Handler(Looper.getMainLooper())
310
+
282
311
  // Billing clients for in-app purchases
283
312
  private var productBillingClient: BillingClient? = null
284
313
  private var restoreBillingClient: BillingClient? = null
@@ -388,6 +417,7 @@ class CraftBridge(
388
417
  networkStatus: true,
389
418
  appReview: true,
390
419
  flashlight: {{ENABLE_CAMERA}} && ${hasFlashlight()},
420
+ speech: true,
391
421
  openURL: true,
392
422
  vibrationPattern: true,
393
423
  appState: true,
@@ -1642,6 +1672,66 @@ class CraftBridge(
1642
1672
  return Promise.resolve().then(function() { CraftAndroid.openPermissionSettings(); });
1643
1673
  }
1644
1674
  };
1675
+ // Speech synthesis. speak() settles when the utterance ends:
1676
+ // true when it was spoken, false when something stopped it
1677
+ // or the device has no engine to speak with. No timeout, as
1678
+ // on iOS: a long sentence at a slow rate outlasts any
1679
+ // deadline that would be fair to a short cue.
1680
+ var speechRequestSequence = 0;
1681
+ var speechRequests = Object.create(null);
1682
+ var speechRuntimeClosed = false;
1683
+ window.__craftSpeechResult = function(id, spoken) {
1684
+ if (speechRuntimeClosed) return;
1685
+ var pending = speechRequests[id];
1686
+ if (!pending) return;
1687
+ delete speechRequests[id];
1688
+ pending.resolve(spoken === true);
1689
+ };
1690
+ window.__craftSpeechError = function(id, message, code) {
1691
+ if (speechRuntimeClosed) return;
1692
+ var pending = speechRequests[id];
1693
+ if (!pending) return;
1694
+ delete speechRequests[id];
1695
+ var error = new Error(message);
1696
+ error.code = code || 'CRAFT_ERROR';
1697
+ error.bridge = true;
1698
+ pending.reject(error);
1699
+ };
1700
+ window.__craftRejectSpeechRequests = function(message) {
1701
+ speechRuntimeClosed = true;
1702
+ Object.keys(speechRequests).forEach(function(id) {
1703
+ var pending = speechRequests[id];
1704
+ delete speechRequests[id];
1705
+ pending.reject(new Error(message || 'Android bridge closed'));
1706
+ });
1707
+ };
1708
+ craft.speech = {
1709
+ speak: function(text, options) {
1710
+ if (speechRuntimeClosed) {
1711
+ return Promise.reject(new Error('Android bridge is closed'));
1712
+ }
1713
+ options = options || {};
1714
+ var settings = JSON.stringify({
1715
+ rate: typeof options.rate === 'number' ? options.rate : null,
1716
+ language: typeof options.language === 'string' ? options.language : null,
1717
+ interrupt: options.interrupt !== false
1718
+ });
1719
+ return new Promise(function(resolve, reject) {
1720
+ speechRequestSequence += 1;
1721
+ var id = speechRequestSequence;
1722
+ speechRequests[id] = {resolve: resolve, reject: reject};
1723
+ try {
1724
+ CraftAndroid.speak(text == null ? '' : String(text), settings, id);
1725
+ } catch (error) {
1726
+ delete speechRequests[id];
1727
+ reject(error);
1728
+ }
1729
+ });
1730
+ },
1731
+ stop: function() {
1732
+ return Promise.resolve().then(function() { CraftAndroid.stopSpeaking(); });
1733
+ }
1734
+ };
1645
1735
  craft.location = {
1646
1736
  getCurrentPosition: craft.getCurrentPosition,
1647
1737
  watchPosition: craft.watchPosition,
@@ -3556,6 +3646,189 @@ class CraftBridge(
3556
3646
  return true
3557
3647
  }
3558
3648
 
3649
+ // ==================== Speech Synthesis ====================
3650
+
3651
+ /**
3652
+ * Say [text], and answer the page's call [callbackId] once it has been
3653
+ * said (`true`) or stopped (`false`).
3654
+ *
3655
+ * The audio is marked as navigation guidance and asks for transient,
3656
+ * may-duck focus, which is what makes the music dip under the cue rather
3657
+ * than stop, and come back once [abandonSpeechFocus] runs. The WebView's
3658
+ * own `speechSynthesis` has no say in either.
3659
+ */
3660
+ @JavascriptInterface
3661
+ fun speak(text: String?, optionsJson: String?, callbackId: Int) {
3662
+ activity.runOnUiThread {
3663
+ if (closed) return@runOnUiThread
3664
+ if (text.isNullOrBlank()) {
3665
+ rejectSpeech(callbackId, "speak needs some text to say", "INVALID_ARGUMENT")
3666
+ return@runOnUiThread
3667
+ }
3668
+ val options = runCatching { JSONObject(optionsJson ?: "{}") }.getOrElse { JSONObject() }
3669
+ val rate = if (options.isNull("rate")) 1.0 else options.optDouble("rate", 1.0)
3670
+ val request = SpeechRequest(
3671
+ text = text,
3672
+ // 1 is normal on both scales, so this only bounds it.
3673
+ rate = (if (rate.isFinite()) rate else 1.0).coerceIn(0.5, 2.0).toFloat(),
3674
+ language = if (options.isNull("language")) null else options.optString("language").ifBlank { null },
3675
+ callbackId = callbackId,
3676
+ generation = navigationGeneration
3677
+ )
3678
+ if (options.optBoolean("interrupt", true)) cancelPendingSpeech()
3679
+
3680
+ val engine = textToSpeech
3681
+ if (engine != null && textToSpeechReady) {
3682
+ startUtterance(engine, request)
3683
+ } else {
3684
+ queuedSpeech.add(request)
3685
+ bindTextToSpeech()
3686
+ }
3687
+ }
3688
+ }
3689
+
3690
+ @JavascriptInterface
3691
+ fun stopSpeaking(): Boolean {
3692
+ activity.runOnUiThread {
3693
+ if (closed) return@runOnUiThread
3694
+ cancelPendingSpeech()
3695
+ abandonSpeechFocus()
3696
+ }
3697
+ return true
3698
+ }
3699
+
3700
+ private val speechAudioAttributes: AudioAttributes by lazy {
3701
+ AudioAttributes.Builder()
3702
+ .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
3703
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
3704
+ .build()
3705
+ }
3706
+
3707
+ /** Main thread. Binding takes a moment, and finishes in the listener. */
3708
+ private fun bindTextToSpeech() {
3709
+ if (textToSpeech != null) return
3710
+ var engine: TextToSpeech? = null
3711
+ // Posted, never run inline: an engine that cannot bind reports so
3712
+ // from inside its own constructor, before `engine` has been assigned,
3713
+ // and runOnUiThread on the main thread would run it right there.
3714
+ engine = TextToSpeech(activity.applicationContext) { status ->
3715
+ speechHandler.post { onTextToSpeechBound(engine, status == TextToSpeech.SUCCESS) }
3716
+ }
3717
+ textToSpeech = engine
3718
+ }
3719
+
3720
+ private fun onTextToSpeechBound(engine: TextToSpeech?, succeeded: Boolean) {
3721
+ // An engine that close() or a failed earlier bind has since replaced.
3722
+ if (closed || engine == null || engine !== textToSpeech) return
3723
+ val waiting = queuedSpeech.toList()
3724
+ queuedSpeech.clear()
3725
+ if (!succeeded) {
3726
+ // No engine, or none that would start. Each call settles as
3727
+ // "nothing was said", and the next speak tries binding again.
3728
+ runCatching { engine.shutdown() }
3729
+ textToSpeech = null
3730
+ waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
3731
+ return
3732
+ }
3733
+ engine.setAudioAttributes(speechAudioAttributes)
3734
+ engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
3735
+ override fun onStart(utteranceId: String?) {}
3736
+ override fun onDone(utteranceId: String?) {
3737
+ activity.runOnUiThread { utteranceEnded(utteranceId, true) }
3738
+ }
3739
+ @Deprecated("Deprecated in Java")
3740
+ override fun onError(utteranceId: String?) {
3741
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3742
+ }
3743
+ override fun onError(utteranceId: String?, errorCode: Int) {
3744
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3745
+ }
3746
+ override fun onStop(utteranceId: String?, interrupted: Boolean) {
3747
+ activity.runOnUiThread { utteranceEnded(utteranceId, false) }
3748
+ }
3749
+ })
3750
+ textToSpeechReady = true
3751
+ waiting.forEach { startUtterance(engine, it) }
3752
+ }
3753
+
3754
+ private fun startUtterance(engine: TextToSpeech, request: SpeechRequest) {
3755
+ if (!requestSpeechFocus()) {
3756
+ // A call, or something else that may not be talked over.
3757
+ settleSpeech(request.callbackId, request.generation, false)
3758
+ return
3759
+ }
3760
+ // Both apply to the speak calls after them, so each utterance sets
3761
+ // its own rather than inheriting the last one's.
3762
+ engine.setSpeechRate(request.rate)
3763
+ val locale = request.language?.let { Locale.forLanguageTag(it) }
3764
+ if (locale == null || engine.setLanguage(locale) < TextToSpeech.LANG_AVAILABLE) {
3765
+ engine.setLanguage(Locale.getDefault())
3766
+ }
3767
+
3768
+ nextUtteranceId += 1
3769
+ val utteranceId = "craft-speech-$nextUtteranceId"
3770
+ pendingUtterances[utteranceId] = PendingUtterance(request.callbackId, request.generation)
3771
+ if (engine.speak(request.text, TextToSpeech.QUEUE_ADD, null, utteranceId) != TextToSpeech.SUCCESS) {
3772
+ pendingUtterances.remove(utteranceId)
3773
+ settleSpeech(request.callbackId, request.generation, false)
3774
+ if (pendingUtterances.isEmpty()) abandonSpeechFocus()
3775
+ }
3776
+ }
3777
+
3778
+ private fun utteranceEnded(utteranceId: String?, spoken: Boolean) {
3779
+ val pending = utteranceId?.let { pendingUtterances.remove(it) }
3780
+ if (pending != null) settleSpeech(pending.callbackId, pending.generation, spoken)
3781
+ if (pendingUtterances.isEmpty() && queuedSpeech.isEmpty()) abandonSpeechFocus()
3782
+ }
3783
+
3784
+ /**
3785
+ * Stop the engine and settle every call waiting on it, false.
3786
+ *
3787
+ * Settled here rather than in `onStop`, so that a request still waiting
3788
+ * for the engine to bind, which the engine has never heard of, settles
3789
+ * too.
3790
+ */
3791
+ private fun cancelPendingSpeech() {
3792
+ val waiting = pendingUtterances.values.toList() +
3793
+ queuedSpeech.map { PendingUtterance(it.callbackId, it.generation) }
3794
+ pendingUtterances.clear()
3795
+ queuedSpeech.clear()
3796
+ if (textToSpeechReady) runCatching { textToSpeech?.stop() }
3797
+ waiting.forEach { settleSpeech(it.callbackId, it.generation, false) }
3798
+ }
3799
+
3800
+ private fun requestSpeechFocus(): Boolean {
3801
+ if (speechFocusRequest != null) return true
3802
+ val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return true
3803
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
3804
+ .setAudioAttributes(speechAudioAttributes)
3805
+ .build()
3806
+ if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_FAILED) return false
3807
+ speechFocusRequest = request
3808
+ return true
3809
+ }
3810
+
3811
+ private fun abandonSpeechFocus() {
3812
+ val request = speechFocusRequest ?: return
3813
+ speechFocusRequest = null
3814
+ val manager = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
3815
+ runCatching { manager.abandonAudioFocusRequest(request) }
3816
+ }
3817
+
3818
+ /** An answer for a page that has since navigated away is dropped. */
3819
+ private fun settleSpeech(callbackId: Int, generation: Int, spoken: Boolean) {
3820
+ if (generation != navigationGeneration) return
3821
+ evaluateJavascriptUnlessClosed(
3822
+ "window.__craftSpeechResult && window.__craftSpeechResult($callbackId, $spoken)"
3823
+ )
3824
+ }
3825
+
3826
+ private fun rejectSpeech(callbackId: Int, message: String, code: String) {
3827
+ evaluateJavascriptUnlessClosed(
3828
+ "window.__craftSpeechError && window.__craftSpeechError($callbackId, ${jsQuote(message)}, ${jsQuote(code)})"
3829
+ )
3830
+ }
3831
+
3559
3832
  // ==================== Orientation Lock ====================
3560
3833
 
3561
3834
  @JavascriptInterface
@@ -4077,7 +4350,8 @@ class CraftBridge(
4077
4350
  runCatching {
4078
4351
  webView.evaluateJavascript(
4079
4352
  "window.__craftRejectPendingPromises && window.__craftRejectPendingPromises('Android bridge closed');" +
4080
- "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed')",
4353
+ "window.__craftRejectPermissionRequests && window.__craftRejectPermissionRequests('Android bridge closed');" +
4354
+ "window.__craftRejectSpeechRequests && window.__craftRejectSpeechRequests('Android bridge closed')",
4081
4355
  null
4082
4356
  )
4083
4357
  }
@@ -4144,6 +4418,16 @@ class CraftBridge(
4144
4418
  runCatching { database?.close() }
4145
4419
  database = null
4146
4420
 
4421
+ // The page's calls were rejected above; this only silences the
4422
+ // engine and gives the music back its volume.
4423
+ queuedSpeech.clear()
4424
+ pendingUtterances.clear()
4425
+ runCatching { textToSpeech?.stop() }
4426
+ runCatching { textToSpeech?.shutdown() }
4427
+ textToSpeech = null
4428
+ textToSpeechReady = false
4429
+ abandonSpeechFocus()
4430
+
4147
4431
  if (isFlashlightOn) setFlashlight(false)
4148
4432
  activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
4149
4433
  isKeepingAwake = false
@@ -687,6 +687,34 @@ export declare const keepAwake: {
687
687
  enable(): Promise<void>;
688
688
  disable(): Promise<void>;
689
689
  };
690
+ export interface SpeechOptions {
691
+ /** Speaking rate, 0.5 to 2. 1 is the voice's normal pace. */
692
+ rate?: number;
693
+ /** BCP-47 language tag, such as 'en-US'. Defaults to the device's language. */
694
+ language?: string;
695
+ /** Stop whatever is being said first. Defaults to true; false queues behind it. */
696
+ interrupt?: boolean;
697
+ }
698
+ /**
699
+ * Short spoken cues: "Rest, 15 seconds. Up next: Dead Bug".
700
+ *
701
+ * Inside a Craft app this is native text-to-speech, which ducks the person's
702
+ * music rather than stopping it and, on iOS, plays with the silent switch on.
703
+ * In a browser it falls back to the Web Speech API, which does neither, and
704
+ * where there is no speech at all it answers `false` rather than throwing, so
705
+ * a cue never breaks the flow around it.
706
+ */
707
+ export declare const speech: {
708
+ isAvailable(): boolean;
709
+ /**
710
+ * Speak `text`. Resolves when the utterance ends: `true` when it was
711
+ * spoken, `false` when it was stopped, interrupted or could not be spoken.
712
+ * Rejects with code `INVALID_ARGUMENT` when `text` is empty.
713
+ */
714
+ speak(text: string, options?: SpeechOptions): Promise<boolean>;
715
+ /** Stop speaking at once. Every pending `speak()` resolves `false`. */
716
+ stop(): Promise<void>;
717
+ };
690
718
  export declare const deepLinks: {
691
719
  getInitialURL(): Promise<string | null>;
692
720
  /**
@@ -852,6 +880,7 @@ declare const mobile: {
852
880
  lifecycle: typeof lifecycle;
853
881
  notifications: typeof notifications;
854
882
  keepAwake: typeof keepAwake;
883
+ speech: typeof speech;
855
884
  deepLinks: typeof deepLinks;
856
885
  network: typeof network;
857
886
  appReview: typeof appReview;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.115";
3306
+ var version = "0.0.118";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.115";
313
+ var version = "0.0.118";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
@@ -5284,6 +5284,7 @@ var notifications = {
5284
5284
  }
5285
5285
  }
5286
5286
  };
5287
+ var speakingOnTheWeb = new Set;
5287
5288
  function detectPlatform2() {
5288
5289
  if (typeof window === "undefined")
5289
5290
  return "linux";
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.115";
61
+ var version = "0.0.118";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -5044,6 +5044,7 @@ var notifications = {
5044
5044
  }
5045
5045
  }
5046
5046
  };
5047
+ var speakingOnTheWeb = new Set;
5047
5048
  function detectPlatform2() {
5048
5049
  if (typeof window === "undefined")
5049
5050
  return "linux";
@@ -722,6 +722,24 @@ struct CraftWebView: UIViewRepresentable {
722
722
  // Keep awake
723
723
  private var isKeepingAwake = false
724
724
 
725
+ // Speech synthesis. One synthesizer for the life of the web view, so a
726
+ // cue can interrupt the one before it; each utterance it has not
727
+ // finished yet is held beside the call waiting on it.
728
+ private lazy var speechSynthesizer: AVSpeechSynthesizer = {
729
+ let synthesizer = AVSpeechSynthesizer()
730
+ synthesizer.delegate = self
731
+ return synthesizer
732
+ }()
733
+ private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
734
+ /// What the session was before speech claimed it; nil while speech
735
+ /// does not hold it.
736
+ private var speechAudioSessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
737
+ /// Activating and deactivating the audio session blocks until the
738
+ /// audio hardware answers, which iOS flags as a hang risk on the main
739
+ /// thread. They run here instead, in order, and the session state
740
+ /// above is only touched here.
741
+ private let speechAudioQueue = DispatchQueue(label: "craft.speech.audio")
742
+
725
743
  // Orientation lock
726
744
  private var lockedOrientation: UIInterfaceOrientationMask?
727
745
 
@@ -1058,6 +1076,16 @@ struct CraftWebView: UIViewRepresentable {
1058
1076
  } else {
1059
1077
  rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT")
1060
1078
  }
1079
+ // Speech synthesis. Ungated, like the flashlight: speaking needs no
1080
+ // permission and no entitlement.
1081
+ case "speak":
1082
+ if let text = body["text"] as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1083
+ speak(text, body: body, callbackId: callbackId)
1084
+ } else {
1085
+ rejectCallback(callbackId, error: "speak needs some text to say", code: "INVALID_ARGUMENT")
1086
+ }
1087
+ case "stopSpeaking":
1088
+ stopSpeaking(callbackId: callbackId)
1061
1089
  // Vibrate pattern
1062
1090
  case "vibrate":
1063
1091
  if let pattern = body["pattern"] as? [Int] {
@@ -1850,6 +1878,7 @@ struct CraftWebView: UIViewRepresentable {
1850
1878
  orientationLock: \(config.enableOrientationLock),
1851
1879
  deepLinks: \(config.enableDeepLinks),
1852
1880
  flashlight: true,
1881
+ speech: true,
1853
1882
  network: true,
1854
1883
  deviceInfo: true,
1855
1884
  badge: true,
@@ -2788,6 +2817,37 @@ struct CraftWebView: UIViewRepresentable {
2788
2817
  });
2789
2818
  },
2790
2819
 
2820
+ // Speech synthesis. speak() settles when the utterance ends:
2821
+ // true when it was spoken, false when something stopped it.
2822
+ // No timeout, because a long sentence at a slow rate can
2823
+ // outlast any deadline that would be fair to a short one.
2824
+ speech: {
2825
+ speak: function(text, options) {
2826
+ var self = window.craft;
2827
+ var id = 'cb_' + (++self._callbackId);
2828
+ options = options || {};
2829
+ window.webkit.messageHandlers.craft.postMessage({
2830
+ action: 'speak',
2831
+ text: text == null ? '' : String(text),
2832
+ rate: typeof options.rate === 'number' ? options.rate : null,
2833
+ language: typeof options.language === 'string' ? options.language : null,
2834
+ interrupt: options.interrupt !== false,
2835
+ callbackId: id
2836
+ });
2837
+ return new Promise(function(resolve, reject) {
2838
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2839
+ });
2840
+ },
2841
+ stop: function() {
2842
+ var self = window.craft;
2843
+ var id = 'cb_' + (++self._callbackId);
2844
+ window.webkit.messageHandlers.craft.postMessage({action: 'stopSpeaking', callbackId: id});
2845
+ return new Promise(function(resolve, reject) {
2846
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2847
+ });
2848
+ }
2849
+ },
2850
+
2791
2851
  // Orientation Lock
2792
2852
  lockOrientation: function(orientation) {
2793
2853
  var self = this;
@@ -4762,6 +4822,153 @@ struct CraftWebView: UIViewRepresentable {
4762
4822
  }
4763
4823
  }
4764
4824
 
4825
+ // MARK: - Speech Synthesis
4826
+ //
4827
+ // Native rather than the page's own `speechSynthesis`, which in a
4828
+ // WKWebView plays through the default soloAmbient session: it stops
4829
+ // the person's music and the silent switch mutes it. A workout cue has
4830
+ // to do neither, so the session is set up here for as long as there is
4831
+ // something to say, and handed back when there is not.
4832
+
4833
+ private func speak(_ text: String, body: [String: Any], callbackId: String?) {
4834
+ DispatchQueue.main.async {
4835
+ if body["interrupt"] as? Bool ?? true {
4836
+ self.cancelPendingSpeech()
4837
+ }
4838
+
4839
+ let utterance = AVSpeechUtterance(string: text)
4840
+ utterance.rate = Self.utteranceRate(body["rate"] as? Double ?? 1)
4841
+ // An unknown language finds no voice, and a nil voice is the
4842
+ // device's own, which is the default anyway.
4843
+ if let language = body["language"] as? String, !language.isEmpty,
4844
+ let voice = AVSpeechSynthesisVoice(language: language) {
4845
+ utterance.voice = voice
4846
+ }
4847
+
4848
+ self.pendingUtterances.append((utterance, callbackId))
4849
+ self.claimAudioSessionForSpeech {
4850
+ // Stopped while the session was being set up: say nothing.
4851
+ guard self.pendingUtterances.contains(where: { $0.utterance === utterance }) else { return }
4852
+ self.speechSynthesizer.speak(utterance)
4853
+ }
4854
+ }
4855
+ }
4856
+
4857
+ private func stopSpeaking(callbackId: String?) {
4858
+ DispatchQueue.main.async {
4859
+ // When something was playing, its didCancel releases the
4860
+ // session once the audio has actually stopped; deactivating
4861
+ // before that fails as busy and leaves the music ducked.
4862
+ if !self.cancelPendingSpeech() { self.releaseAudioSessionIfSilent() }
4863
+ self.resolveCallback(callbackId, result: true)
4864
+ }
4865
+ }
4866
+
4867
+ /// Stop what is being said and settle every call waiting on it, false.
4868
+ /// Returns whether the synthesizer was making a sound.
4869
+ ///
4870
+ /// Settled here rather than in `didCancel`, because the delegate hears
4871
+ /// about the utterance that was playing and nothing about the ones
4872
+ /// queued behind it, whose calls would otherwise never settle.
4873
+ @discardableResult
4874
+ private func cancelPendingSpeech() -> Bool {
4875
+ let waiting = pendingUtterances
4876
+ pendingUtterances.removeAll()
4877
+ let synthesizer = speechSynthesizer
4878
+ let wasSounding = synthesizer.isSpeaking || synthesizer.isPaused
4879
+ if wasSounding { synthesizer.stopSpeaking(at: .immediate) }
4880
+ for entry in waiting {
4881
+ resolveCallback(entry.callbackId, result: false)
4882
+ }
4883
+ return wasSounding
4884
+ }
4885
+
4886
+ /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale.
4887
+ ///
4888
+ /// Apple's scale is not linear in speed: the default sits at 0.5 and
4889
+ /// the maximum is far faster than twice that. So a slower rate scales
4890
+ /// the default down in proportion, and a faster one climbs toward the
4891
+ /// maximum at half the slope, which keeps 2 brisk but intelligible.
4892
+ private static func utteranceRate(_ rate: Double) -> Float {
4893
+ let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
4894
+ let normal = AVSpeechUtteranceDefaultSpeechRate
4895
+ let mapped = requested <= 1
4896
+ ? normal * requested
4897
+ : normal + (AVSpeechUtteranceMaximumSpeechRate - normal) * (requested - 1) / 2
4898
+ return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
4899
+ }
4900
+
4901
+ /// Duck the music rather than stop it, and play through the silent
4902
+ /// switch.
4903
+ ///
4904
+ /// `.playback` is what the switch does not mute, `.duckOthers` lowers
4905
+ /// the music under the cue, and the spoken-audio option pauses a
4906
+ /// podcast rather than talking over it. Left alone while recognition
4907
+ /// or a recording holds the session: switching it to playback would
4908
+ /// cut their microphone off mid-take.
4909
+ ///
4910
+ /// Set up on the speech queue, then `start` runs on the main thread.
4911
+ private func claimAudioSessionForSpeech(then start: @escaping () -> Void) {
4912
+ let microphoneBusy = audioEngine.isRunning || (audioRecorder?.isRecording ?? false)
4913
+ speechAudioQueue.async {
4914
+ if self.speechAudioSessionToRestore == nil && !microphoneBusy {
4915
+ let session = AVAudioSession.sharedInstance()
4916
+ let previous = (session.category, session.mode, session.categoryOptions)
4917
+ do {
4918
+ try session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers])
4919
+ try session.setActive(true)
4920
+ self.speechAudioSessionToRestore = previous
4921
+ } catch {
4922
+ // Still speak, through whatever session the app has: a
4923
+ // cue that does not duck beats one that never plays.
4924
+ print("[Craft] Speech audio session failed: \(error.localizedDescription)")
4925
+ }
4926
+ }
4927
+ DispatchQueue.main.async(execute: start)
4928
+ }
4929
+ }
4930
+
4931
+ /// Hand the audio back once nothing is left to say, so the music
4932
+ /// returns to full volume instead of staying ducked, and put back the
4933
+ /// category the app had, so the page's own media plays as before.
4934
+ ///
4935
+ /// Checked on the main thread, where the queue of utterances lives, and
4936
+ /// done on the speech queue after any setup already asked for, so a
4937
+ /// cue that arrives meanwhile reclaims the session after this.
4938
+ private func releaseAudioSessionIfSilent(retry: Bool = true) {
4939
+ guard pendingUtterances.isEmpty else { return }
4940
+ speechAudioQueue.async {
4941
+ guard let previous = self.speechAudioSessionToRestore else { return }
4942
+ let session = AVAudioSession.sharedInstance()
4943
+ do {
4944
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
4945
+ } catch {
4946
+ // Busy: the synthesizer's audio has not quite drained, or
4947
+ // the page has its own media playing. Once more shortly
4948
+ // covers the first; for the second, staying active is
4949
+ // right, and the ducking still ends when the music app
4950
+ // hears nothing more.
4951
+ if retry {
4952
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
4953
+ self.releaseAudioSessionIfSilent(retry: false)
4954
+ }
4955
+ return
4956
+ }
4957
+ print("[Craft] Speech audio session release failed: \(error.localizedDescription)")
4958
+ }
4959
+ self.speechAudioSessionToRestore = nil
4960
+ try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
4961
+ }
4962
+ }
4963
+
4964
+ fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
4965
+ if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
4966
+ let entry = pendingUtterances.remove(at: index)
4967
+ resolveCallback(entry.callbackId, result: spoken)
4968
+ }
4969
+ releaseAudioSessionIfSilent()
4970
+ }
4971
+
4765
4972
  // MARK: - Orientation Lock
4766
4973
  private func lockOrientation(_ orientation: String, callbackId: String?) {
4767
4974
  var mask: UIInterfaceOrientationMask = .all
@@ -6698,6 +6905,20 @@ extension URL {
6698
6905
  }
6699
6906
 
6700
6907
  // MARK: - Contact Picker Delegate
6908
+ // MARK: - AVSpeechSynthesizerDelegate
6909
+ //
6910
+ // The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
6911
+ // main because every piece of speech state is touched there and nowhere else.
6912
+ extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
6913
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
6914
+ DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
6915
+ }
6916
+
6917
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
6918
+ DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
6919
+ }
6920
+ }
6921
+
6701
6922
  extension CraftWebView.Coordinator: CNContactPickerDelegate {
6702
6923
  func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
6703
6924
  let contactData = formatContact(contact)
package/dist/mobile.js CHANGED
@@ -559,6 +559,63 @@ var keepAwake = {
559
559
  webWakeLock = null;
560
560
  }
561
561
  };
562
+ function getWebSpeech() {
563
+ if (typeof window === "undefined")
564
+ return;
565
+ const scope = window;
566
+ if (!scope.speechSynthesis || typeof scope.SpeechSynthesisUtterance !== "function")
567
+ return;
568
+ return { synthesis: scope.speechSynthesis, Utterance: scope.SpeechSynthesisUtterance };
569
+ }
570
+ var speakingOnTheWeb = new Set;
571
+ function speechRate(rate) {
572
+ return typeof rate === "number" && Number.isFinite(rate) ? Math.min(Math.max(rate, 0.5), 2) : 1;
573
+ }
574
+ var speech = {
575
+ isAvailable() {
576
+ return Boolean(getCraftRoot()?.speech?.speak) || getWebSpeech() !== undefined;
577
+ },
578
+ async speak(text, options = {}) {
579
+ const native = getCraftRoot()?.speech;
580
+ const web = native?.speak ? undefined : getWebSpeech();
581
+ if (!native?.speak && !web)
582
+ return false;
583
+ if (typeof text !== "string" || !text.trim()) {
584
+ throw Object.assign(new Error("speech.speak needs some text to say"), { code: "INVALID_ARGUMENT" });
585
+ }
586
+ if (native?.speak)
587
+ return await native.speak(text, options) === true;
588
+ const { synthesis, Utterance } = web;
589
+ if (options.interrupt !== false)
590
+ synthesis.cancel();
591
+ return new Promise((resolve) => {
592
+ const utterance = new Utterance(text);
593
+ utterance.rate = speechRate(options.rate);
594
+ if (options.language)
595
+ utterance.lang = options.language;
596
+ const settle = (spoken) => {
597
+ speakingOnTheWeb.delete(utterance);
598
+ resolve(spoken);
599
+ };
600
+ utterance.onend = () => settle(true);
601
+ utterance.onerror = () => settle(false);
602
+ speakingOnTheWeb.add(utterance);
603
+ try {
604
+ synthesis.speak(utterance);
605
+ } catch {
606
+ settle(false);
607
+ }
608
+ });
609
+ },
610
+ async stop() {
611
+ const native = getCraftRoot()?.speech;
612
+ if (native?.stop) {
613
+ await native.stop();
614
+ return;
615
+ }
616
+ getWebSpeech()?.synthesis.cancel();
617
+ }
618
+ };
562
619
  var deepLinks = {
563
620
  async getInitialURL() {
564
621
  return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.());
@@ -797,6 +854,7 @@ function getCraftMobile() {
797
854
  }
798
855
  export {
799
856
  watchConnectivity,
857
+ speech,
800
858
  share,
801
859
  secureStorage,
802
860
  pushNotifications,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.115",
3
+ "version": "0.0.118",
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>",