@@ -6,6 +6,20 @@ import kotlinx.coroutines.CoroutineScope
66import kotlinx.coroutines.channels.Channel
77import kotlinx.coroutines.flow.MutableSharedFlow
88import kotlinx.io.files.Path
9+ import platform.AVFAudio.AVAudioSession
10+ import platform.AVFAudio.AVAudioSessionCategoryOptionDuckOthers
11+ import platform.AVFAudio.AVAudioSessionCategoryPlayback
12+ import platform.AVFAudio.AVSpeechSynthesizer
13+ import platform.AVFAudio.AVSpeechSynthesizerDelegateProtocol
14+ import platform.AVFAudio.AVAudioPlayer
15+ import platform.AVFAudio.AVSpeechUtterance
16+ import platform.AVFAudio.setActive
17+ import platform.AudioToolbox.AudioServicesPlayAlertSound
18+ import platform.AudioToolbox.AudioServicesPlaySystemSound
19+ import platform.AudioToolbox.AudioServicesPlaySystemSoundWithCompletion
20+ import platform.Foundation.NSBundle
21+ import platform.Foundation.NSURL
22+ import platform.darwin.NSObject
923
1024class JSCPrivatePKJSInterface (
1125 private val jsPath : Path ,
@@ -20,6 +34,57 @@ class JSCPrivatePKJSInterface(
2034 notificationConfigFlow : NotificationConfigFlow ,
2135): PrivatePKJSInterface(jsRunner, device, scope, outgoingAppMessages, logMessages, jsTokenUtil, remoteTimelineEmulator, httpInterceptorManager, notificationConfigFlow), RegisterableJsInterface {
2236 private val logger = Logger .withTag(" JSCPrivatePKJSInterface" )
37+
38+ private var isLooping = false
39+ private var loopType = " "
40+ private var loopText = " "
41+ private var currentSystemSoundId: UInt = 0u
42+ private var soundGeneration = 0L
43+ private var avPlayer: AVAudioPlayer ? = null
44+ private var voiceLoopCount = 0
45+ private val maxLoops = 3
46+
47+ private val speechDelegate = object : NSObject (), AVSpeechSynthesizerDelegateProtocol {
48+ override fun speechSynthesizer (synthesizer : AVSpeechSynthesizer , didFinishSpeechUtterance : AVSpeechUtterance ) {
49+ if (isLooping && loopType == " voice_loop" ) {
50+ voiceLoopCount++
51+ if (voiceLoopCount < maxLoops) {
52+ val utterance = AVSpeechUtterance (string = loopText)
53+ synthesizer.speakUtterance(utterance)
54+ } else {
55+ isLooping = false
56+ loopType = " "
57+ }
58+ }
59+ }
60+ }
61+
62+ private val synthesizer = AVSpeechSynthesizer ().apply {
63+ delegate = speechDelegate
64+ }
65+
66+ init {
67+ try {
68+ setupAudioSession()
69+ // Pre-warm the speech synthesizer with a silent utterance to eliminate first-time delay
70+ val silentUtterance = AVSpeechUtterance (string = " " )
71+ silentUtterance.setVolume(0.0f )
72+ synthesizer.speakUtterance(silentUtterance)
73+ prewarmRingtone()
74+ } catch (e: Exception ) {
75+ logger.w(e) { " Failed to prime audio session" }
76+ }
77+ }
78+
79+ private fun prewarmRingtone () {
80+ val path = NSBundle .mainBundle.pathForResource(" opening" , " mp3" ) ? : return
81+ val url = NSURL .fileURLWithPath(path)
82+ val player = AVAudioPlayer (contentsOfURL = url, error = null ) ? : return
83+ player.numberOfLoops = (maxLoops - 1 ).toLong()
84+ player.volume = 1.0f
85+ player.prepareToPlay()
86+ avPlayer = player
87+ }
2388
2489 override val interf = mapOf (
2590 " sendAppMessageString" to this ::sendAppMessageString,
@@ -46,6 +111,10 @@ class JSCPrivatePKJSInterface(
46111 " getActivePebbleWatchInfo" to this ::getActivePebbleWatchInfo,
47112 " insertTimelinePin" to this ::insertTimelinePin,
48113 " deleteTimelinePin" to this ::deleteTimelinePin,
114+ " playSound" to this ::playSound,
115+ " playSoundById" to this ::playSoundById,
116+ " playDefaultRingtone" to this ::playDefaultRingtone,
117+ " stopSound" to this ::stopSound,
49118 )
50119
51120 override val name: String = " _Pebble"
@@ -95,9 +164,157 @@ class JSCPrivatePKJSInterface(
95164 " getActivePebbleWatchInfo" -> getActivePebbleWatchInfo()
96165 " insertTimelinePin" -> { insertTimelinePin(args[0 ].toString()); null }
97166 " deleteTimelinePin" -> { deleteTimelinePin(args[0 ].toString()); null }
167+ " playSound" -> { playSound(args.getOrNull(0 )?.toString() ? : " " , args.getOrNull(1 )?.toString() ? : " " ); null }
168+ " playSoundById" -> { playSoundById(args.getOrNull(0 )?.toString() ? : " " ); null }
169+ " playDefaultRingtone" -> { playDefaultRingtone(); null }
170+ " stopSound" -> { stopSound(); null }
98171 else -> error(" Unknown method: $method " )
99172 }
100173
174+ private fun playLoopingSystemSound (soundId : UInt , remainingLoops : Int ) {
175+ val generation = soundGeneration
176+ if (! isLooping || currentSystemSoundId != soundId) return
177+ if (remainingLoops <= 0 ) {
178+ isLooping = false
179+ currentSystemSoundId = 0u
180+ return
181+ }
182+ AudioServicesPlaySystemSoundWithCompletion (soundId) {
183+ if (isLooping && currentSystemSoundId == soundId && generation == soundGeneration) {
184+ playLoopingSystemSound(soundId, remainingLoops - 1 )
185+ }
186+ }
187+ }
188+
189+ override fun stopSound () {
190+ super .stopSound()
191+ soundGeneration++
192+ isLooping = false
193+ loopType = " "
194+ loopText = " "
195+ currentSystemSoundId = 0u
196+ voiceLoopCount = 0
197+ avPlayer?.stop()
198+ avPlayer?.prepareToPlay()
199+ if (synthesizer.isSpeaking()) {
200+ synthesizer.stopSpeakingAtBoundary(platform.AVFAudio .AVSpeechBoundary .AVSpeechBoundaryImmediate )
201+ }
202+ }
203+
204+ private fun setupAudioSession () {
205+ val session = AVAudioSession .sharedInstance()
206+ session.setCategory(AVAudioSessionCategoryPlayback , withOptions = AVAudioSessionCategoryOptionDuckOthers , error = null )
207+ session.setActive(true , error = null )
208+ }
209+
210+ private fun playVoice (text : String , loop : Boolean ) {
211+ setupAudioSession()
212+ val alertText = if (text.isNotBlank()) text else " Pebble Alert"
213+ if (loop) {
214+ isLooping = true
215+ loopType = " voice_loop"
216+ loopText = alertText
217+ voiceLoopCount = 0
218+ }
219+ val utterance = AVSpeechUtterance (string = alertText)
220+ utterance.setVolume(1.0f ) // Ensure maximum volume for the utterance itself
221+ synthesizer.speakUtterance(utterance)
222+ }
223+
224+ private fun triggerSystemSound (soundId : UInt , loop : Boolean ) {
225+ if (loop) {
226+ isLooping = true
227+ currentSystemSoundId = soundId
228+ playLoopingSystemSound(soundId, maxLoops)
229+ } else {
230+ if (soundId == 1304u ) AudioServicesPlayAlertSound (soundId)
231+ else AudioServicesPlaySystemSound (soundId)
232+ }
233+ }
234+
235+ override fun playSound (type : String , text : String ) {
236+ super .playSound(type, text)
237+ try {
238+ val cleanType = type.replace(" \u0000 " , " " ).replace(" \" " , " " ).trim()
239+ val cleanText = text.replace(" \u0000 " , " " ).replace(" \" " , " " ).trim()
240+
241+ logger.v { " playSound implementation - type: '$cleanType ' (len: ${cleanType.length} ), text: '$cleanText '" }
242+
243+ // Reset any previous loop
244+ stopSound()
245+
246+ when (cleanType) {
247+ " alarm" , " alarm_loop" -> {
248+ logger.v { " Matched alarm branch" }
249+ triggerSystemSound(1304u , cleanType.endsWith(" _loop" ))
250+ }
251+ " sms" , " sms_loop" -> {
252+ logger.v { " Matched sms branch" }
253+ triggerSystemSound(1000u , cleanType.endsWith(" _loop" ))
254+ }
255+ " voice_loop" -> {
256+ logger.v { " Matched voice_loop branch" }
257+ playVoice(cleanText, true )
258+ }
259+ " voice" , " " -> {
260+ logger.v { " Matched voice branch" }
261+ playVoice(cleanText, false )
262+ }
263+ else -> {
264+ logger.v { " No match found for '$cleanType ', falling back to voice" }
265+ playVoice(cleanText, false )
266+ }
267+ }
268+ } catch (e: Exception ) {
269+ logger.e(e) { " Error playing sound" }
270+ }
271+ }
272+
273+ override fun playSoundById (id : String ) {
274+ super .playSoundById(id)
275+ try {
276+ val cleanId = id.replace(" \u0000 " , " " ).replace(" \" " , " " ).trim()
277+ if (cleanId.isEmpty()) return
278+ val soundId = cleanId.toUIntOrNull() ? : run {
279+ logger.w { " Invalid sound ID: $cleanId " }
280+ return
281+ }
282+ stopSound()
283+ triggerSystemSound(soundId, loop = false )
284+ } catch (e: Exception ) {
285+ logger.e(e) { " Error playing sound by ID" }
286+ }
287+ }
288+
289+ override fun playDefaultRingtone () {
290+ super .playDefaultRingtone()
291+ try {
292+ stopSound()
293+ setupAudioSession()
294+ val player = avPlayer ? : run {
295+ val path = NSBundle .mainBundle.pathForResource(" opening" , " mp3" ) ? : run {
296+ logger.e { " opening.mp3 not found in bundle" }
297+ return
298+ }
299+ val url = NSURL .fileURLWithPath(path)
300+ val p = AVAudioPlayer (contentsOfURL = url, error = null ) ? : run {
301+ logger.e { " Failed to create AVAudioPlayer" }
302+ return
303+ }
304+ p.apply {
305+ numberOfLoops = (maxLoops - 1 ).toLong()
306+ volume = 1.0f
307+ }
308+ }
309+ avPlayer = player
310+ player.currentTime = 0.0
311+ player.play()
312+ logger.v { " Playing default ringtone in loop" }
313+ } catch (e: Exception ) {
314+ logger.e(e) { " Error playing default ringtone" }
315+ }
316+ }
317+
101318 override fun close () {
102319 // No-op
103320 }
0 commit comments