@lovelace_lol/polymer 0.0.1
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.
- package/README.md +226 -0
- package/dist/index.d.ts +2603 -0
- package/dist/index.js +1031 -0
- package/dist/test.cjs +1519 -0
- package/dist/worker/agency-worker.js +972 -0
- package/dist/worker/manifest.edn +1 -0
- package/package.json +57 -0
- package/shadow-cljs.edn +173 -0
- package/src/polymer/animation/agency.cljs +84 -0
- package/src/polymer/animation/domain.cljs +110 -0
- package/src/polymer/animation/planner.cljs +88 -0
- package/src/polymer/animation/runtime.cljs +137 -0
- package/src/polymer/animation/scheduler.cljs +178 -0
- package/src/polymer/animation/service.cljs +981 -0
- package/src/polymer/animation/state.cljs +112 -0
- package/src/polymer/blink/agency.cljs +131 -0
- package/src/polymer/blink/planner.cljs +115 -0
- package/src/polymer/blink/scheduler.cljs +90 -0
- package/src/polymer/blink/snippet.cljs +50 -0
- package/src/polymer/blink/state.cljs +102 -0
- package/src/polymer/camera_context/agency.cljs +132 -0
- package/src/polymer/camera_context/domain.cljs +186 -0
- package/src/polymer/camera_context/planner.cljs +50 -0
- package/src/polymer/camera_context/scheduler.cljs +113 -0
- package/src/polymer/camera_context/state.cljs +121 -0
- package/src/polymer/character.cljs +471 -0
- package/src/polymer/conversation/agency.cljs +83 -0
- package/src/polymer/conversation/domain.cljs +35 -0
- package/src/polymer/conversation/effector.cljs +130 -0
- package/src/polymer/conversation/planner.cljs +178 -0
- package/src/polymer/conversation/scheduler.cljs +202 -0
- package/src/polymer/conversation/service.cljs +329 -0
- package/src/polymer/conversation/state.cljs +186 -0
- package/src/polymer/core.cljs +172 -0
- package/src/polymer/eye_head/agency.cljs +75 -0
- package/src/polymer/eye_head/domain.cljs +133 -0
- package/src/polymer/eye_head/goap.cljs +106 -0
- package/src/polymer/eye_head/scheduler.cljs +176 -0
- package/src/polymer/eye_head/service.cljs +259 -0
- package/src/polymer/eye_head/snippet.cljs +92 -0
- package/src/polymer/eye_head/state.cljs +167 -0
- package/src/polymer/gaze/agency.cljs +85 -0
- package/src/polymer/gaze/domain.cljs +211 -0
- package/src/polymer/gaze/goap.cljs +208 -0
- package/src/polymer/gaze/scheduler.cljs +184 -0
- package/src/polymer/gaze/state.cljs +202 -0
- package/src/polymer/gesture/agency.cljs +185 -0
- package/src/polymer/gesture/goap.cljs +461 -0
- package/src/polymer/gesture/scheduler.cljs +122 -0
- package/src/polymer/gesture/snippet.cljs +198 -0
- package/src/polymer/gesture/state.cljs +273 -0
- package/src/polymer/lipsync/agency.cljs +593 -0
- package/src/polymer/lipsync/articulation/jaw.cljs +258 -0
- package/src/polymer/lipsync/articulation/snippet.cljs +605 -0
- package/src/polymer/lipsync/articulation/tongue.cljs +418 -0
- package/src/polymer/lipsync/articulation/visemes.cljs +429 -0
- package/src/polymer/lipsync/goap.cljs +132 -0
- package/src/polymer/lipsync/pronunciation/effector.cljs +51 -0
- package/src/polymer/lipsync/scheduler.cljs +302 -0
- package/src/polymer/lipsync/state.cljs +202 -0
- package/src/polymer/nlp/phonemes.cljs +164 -0
- package/src/polymer/prosodic/agency.cljs +196 -0
- package/src/polymer/prosodic/goap.cljs +122 -0
- package/src/polymer/prosodic/scheduler.cljs +99 -0
- package/src/polymer/prosodic/snippet.cljs +106 -0
- package/src/polymer/prosodic/state.cljs +139 -0
- package/src/polymer/runtime/embody.cljs +117 -0
- package/src/polymer/runtime/embody_wasm.cljs +177 -0
- package/src/polymer/runtime/rust_embody_host.cljs +645 -0
- package/src/polymer/society.cljs +239 -0
- package/src/polymer/stream.cljs +130 -0
- package/src/polymer/transcription/agency.cljs +69 -0
- package/src/polymer/transcription/domain.cljs +68 -0
- package/src/polymer/transcription/effector.cljs +611 -0
- package/src/polymer/transcription/planner.cljs +203 -0
- package/src/polymer/transcription/scheduler.cljs +225 -0
- package/src/polymer/transcription/service.cljs +209 -0
- package/src/polymer/transcription/state.cljs +239 -0
- package/src/polymer/tts/agency.cljs +503 -0
- package/src/polymer/tts/azure.cljs +290 -0
- package/src/polymer/tts/planner.cljs +178 -0
- package/src/polymer/tts/runtime.cljs +624 -0
- package/src/polymer/tts/scheduler.cljs +162 -0
- package/src/polymer/tts/state.cljs +191 -0
- package/src/polymer/worker.cljs +70 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
(ns polymer.tts.agency
|
|
2
|
+
(:require [polymer.stream :as stream]
|
|
3
|
+
[polymer.tts.azure :as azure]
|
|
4
|
+
[polymer.tts.planner :as planner]
|
|
5
|
+
[polymer.tts.runtime :refer [azure-cache-key
|
|
6
|
+
azure-voice-name
|
|
7
|
+
backend-url
|
|
8
|
+
callback-event
|
|
9
|
+
cleanup-audio!
|
|
10
|
+
clear-display-media-reference!
|
|
11
|
+
custom-provider
|
|
12
|
+
dispose-playback-resources!
|
|
13
|
+
ensure-web-speech-display-media-reference!
|
|
14
|
+
get-playback-reference-track
|
|
15
|
+
js-error
|
|
16
|
+
load-azure-voices!
|
|
17
|
+
load-web-speech-voices!
|
|
18
|
+
normalize-voices
|
|
19
|
+
now-ms
|
|
20
|
+
plan-failure-message
|
|
21
|
+
play-azure-audio!
|
|
22
|
+
prime-audio!
|
|
23
|
+
remember-cache
|
|
24
|
+
scalar->azure-percent
|
|
25
|
+
scalar->azure-pitch-percent
|
|
26
|
+
speak-web-speech!
|
|
27
|
+
speech-synthesis*
|
|
28
|
+
synthesize-azure!
|
|
29
|
+
stop-web-speech-handle!]]
|
|
30
|
+
[polymer.tts.scheduler :as scheduler]
|
|
31
|
+
[polymer.tts.state :as state]))
|
|
32
|
+
|
|
33
|
+
;; TTS owns speech-provider side effects and emits plain data facts for LipSync.
|
|
34
|
+
;; The impure browser/backend/audio calls live in `polymer.tts.runtime`; this
|
|
35
|
+
;; namespace owns command planning, session state, and callback routing.
|
|
36
|
+
|
|
37
|
+
(defn create-tts-agency
|
|
38
|
+
"Create a TTS agency that owns provider sessions and emits LipSync facts."
|
|
39
|
+
[config]
|
|
40
|
+
(let [initial-config (js->clj config :keywordize-keys true)
|
|
41
|
+
input-stream (stream/create-stream)
|
|
42
|
+
event-stream (stream/create-stream)
|
|
43
|
+
emit-input (:emit input-stream)
|
|
44
|
+
emit-event (:emit event-stream)
|
|
45
|
+
state-atom (atom (state/default-state initial-config))
|
|
46
|
+
resources (atom {:audio nil
|
|
47
|
+
:audioUrl nil
|
|
48
|
+
:audioSource nil
|
|
49
|
+
:webSpeechHandle nil
|
|
50
|
+
:audioContext nil
|
|
51
|
+
:playbackReferenceDestination nil
|
|
52
|
+
:mediaElementSource nil
|
|
53
|
+
:playbackGain nil
|
|
54
|
+
:displayMediaReferenceStream nil
|
|
55
|
+
:displayMediaReferenceTrack nil})
|
|
56
|
+
session-scheduler (scheduler/create-scheduler)
|
|
57
|
+
azure-cache (atom {})
|
|
58
|
+
voice-load-seq (atom 0)
|
|
59
|
+
voice-load-requests (atom {})
|
|
60
|
+
playback-reference-listeners (atom #{})
|
|
61
|
+
disposed? (atom false)]
|
|
62
|
+
(letfn [(session-id []
|
|
63
|
+
;; Every provider callback captures the session id active when
|
|
64
|
+
;; it was registered. Replacements/stops increment the token so
|
|
65
|
+
;; late callbacks cannot mutate state or emit stale LipSync data.
|
|
66
|
+
(:sessionId @state-atom))
|
|
67
|
+
|
|
68
|
+
(active-session? [id]
|
|
69
|
+
(and (not @disposed?) (= id (session-id))))
|
|
70
|
+
|
|
71
|
+
(notify-playback-reference-track! []
|
|
72
|
+
;; Host-facing handle for barge-in. Stream messages stay plain data;
|
|
73
|
+
;; MediaStreamTrack is exposed only through this imperative API.
|
|
74
|
+
(let [track (get-playback-reference-track resources)]
|
|
75
|
+
(doseq [listener @playback-reference-listeners]
|
|
76
|
+
(listener track))
|
|
77
|
+
(emit-event {:type "ttsPlaybackReferenceChanged"
|
|
78
|
+
:agency "tts"
|
|
79
|
+
:available (boolean track)
|
|
80
|
+
:at (now-ms)})
|
|
81
|
+
track))
|
|
82
|
+
|
|
83
|
+
(emit-status! []
|
|
84
|
+
;; Status events are coarse facts, not animation ticks. The
|
|
85
|
+
;; agency emits word/viseme progress through streams instead of
|
|
86
|
+
;; making a host poll mutable state.
|
|
87
|
+
(emit-event {:type "ttsStatusChanged"
|
|
88
|
+
:agency "tts"
|
|
89
|
+
:state (state/visible-state @state-atom)}))
|
|
90
|
+
|
|
91
|
+
(emit-error! [message]
|
|
92
|
+
(swap! state-atom state/record-error message)
|
|
93
|
+
(emit-event {:type "error" :agency "tts" :message message})
|
|
94
|
+
(emit-status!))
|
|
95
|
+
|
|
96
|
+
(emit-lipsync! [command]
|
|
97
|
+
;; This is an event-stream bridge inside Polymer. The agency
|
|
98
|
+
;; system routes this command to LipSync, which then emits
|
|
99
|
+
;; animation intent for the Animation agency.
|
|
100
|
+
(emit-event {:type "lipSync.command"
|
|
101
|
+
:agency "tts"
|
|
102
|
+
:command command}))
|
|
103
|
+
|
|
104
|
+
(active-speech? []
|
|
105
|
+
(contains? #{"loading" "speaking"} (:status @state-atom)))
|
|
106
|
+
|
|
107
|
+
(next-voice-load! [engine]
|
|
108
|
+
(let [request-id (swap! voice-load-seq inc)]
|
|
109
|
+
(swap! voice-load-requests assoc engine request-id)
|
|
110
|
+
request-id))
|
|
111
|
+
|
|
112
|
+
(active-voice-load? [engine request-id]
|
|
113
|
+
(and (not @disposed?)
|
|
114
|
+
(= request-id (get @voice-load-requests engine))))
|
|
115
|
+
|
|
116
|
+
(cancel-voice-loads! []
|
|
117
|
+
(swap! voice-load-requests empty))
|
|
118
|
+
|
|
119
|
+
(stop-web-speech! []
|
|
120
|
+
(when-let [handle (:webSpeechHandle @resources)]
|
|
121
|
+
(stop-web-speech-handle! handle))
|
|
122
|
+
(swap! resources assoc :webSpeechHandle nil))
|
|
123
|
+
|
|
124
|
+
(configure-lipsync! [engine command]
|
|
125
|
+
;; TTS knows provider timing and user speech controls; LipSync knows
|
|
126
|
+
;; how those controls become mouth/jaw/tongue animation.
|
|
127
|
+
(let [config (:config @state-atom)]
|
|
128
|
+
(emit-lipsync! {:type "configure"
|
|
129
|
+
:config {:intensity (:lipsyncIntensity config)
|
|
130
|
+
:jawScale (:jawScale config)
|
|
131
|
+
:tongueScale (:tongueScale config)
|
|
132
|
+
:speechRate (or (:rate command) (:rate config))
|
|
133
|
+
:visualLeadMs (or (:visualLeadMs command) (:visualLeadMs config))
|
|
134
|
+
:wordDriftThresholdSec (if (= engine "azure")
|
|
135
|
+
(:azureDriftThresholdSec config)
|
|
136
|
+
(:webSpeechDriftThresholdSec config))}})))
|
|
137
|
+
|
|
138
|
+
(stop-session! [reason]
|
|
139
|
+
((:stop-active-session session-scheduler) reason)
|
|
140
|
+
(stop-web-speech!)
|
|
141
|
+
(cleanup-audio! resources)
|
|
142
|
+
(emit-lipsync! {:type "stop" :reason reason})
|
|
143
|
+
(swap! state-atom state/record-stopped (now-ms))
|
|
144
|
+
(emit-event {:type "ttsSpeechStopped"
|
|
145
|
+
:agency "tts"
|
|
146
|
+
:reason reason
|
|
147
|
+
:stoppedAt (:endedAt @state-atom)})
|
|
148
|
+
(emit-status!))
|
|
149
|
+
|
|
150
|
+
(finish-session! [id]
|
|
151
|
+
(when (active-session? id)
|
|
152
|
+
(stop-session! "completed")
|
|
153
|
+
(emit-event {:type "ttsSpeechEnded"
|
|
154
|
+
:agency "tts"
|
|
155
|
+
:endedAt (:endedAt @state-atom)})))
|
|
156
|
+
|
|
157
|
+
(word-boundary! [id word observed-elapsed-sec host-elapsed-sec]
|
|
158
|
+
(when (and (active-session? id) (pos? (count (or word ""))))
|
|
159
|
+
(let [word-index (:wordIndex @state-atom)]
|
|
160
|
+
(swap! state-atom state/record-word-boundary word-index)
|
|
161
|
+
(emit-lipsync! {:type "wordBoundary"
|
|
162
|
+
:word word
|
|
163
|
+
:wordIndex word-index
|
|
164
|
+
:observedElapsedSec observed-elapsed-sec
|
|
165
|
+
:hostElapsedSec host-elapsed-sec})
|
|
166
|
+
(emit-event {:type "ttsWordBoundary"
|
|
167
|
+
:agency "tts"
|
|
168
|
+
:word word
|
|
169
|
+
:wordIndex word-index
|
|
170
|
+
:observedElapsedSec observed-elapsed-sec
|
|
171
|
+
:hostElapsedSec host-elapsed-sec}))))
|
|
172
|
+
|
|
173
|
+
(start-web-speech! [id command snippet-name]
|
|
174
|
+
(let [config (:config @state-atom)
|
|
175
|
+
text (:text command)
|
|
176
|
+
plan {:text text
|
|
177
|
+
:voiceName (or (:voiceName command) (:voiceName config))
|
|
178
|
+
:rate (or (:rate command) (:rate config))
|
|
179
|
+
:pitch (or (:pitch command) (:pitch config))
|
|
180
|
+
:volume (or (:volume command) (:volume config))
|
|
181
|
+
:onAudioStarted (fn [_event]
|
|
182
|
+
(when (active-session? id)
|
|
183
|
+
(configure-lipsync! "webSpeech" command)
|
|
184
|
+
(emit-lipsync! {:type "startText"
|
|
185
|
+
:name snippet-name
|
|
186
|
+
:text text
|
|
187
|
+
:source "webSpeech"})
|
|
188
|
+
(emit-lipsync! {:type "audioStarted"
|
|
189
|
+
:name snippet-name
|
|
190
|
+
:audioTimeSec 0})
|
|
191
|
+
(swap! state-atom state/record-speaking (now-ms))
|
|
192
|
+
(emit-event {:type "ttsSpeechStarted"
|
|
193
|
+
:agency "tts"
|
|
194
|
+
:engine "webSpeech"
|
|
195
|
+
:name snippet-name
|
|
196
|
+
:startedAt (:startedAt @state-atom)})
|
|
197
|
+
(emit-status!)))
|
|
198
|
+
:onBoundary (fn [event]
|
|
199
|
+
(let [payload (callback-event event)]
|
|
200
|
+
(word-boundary! id
|
|
201
|
+
(:word payload)
|
|
202
|
+
(:observedElapsedSec payload)
|
|
203
|
+
(:hostElapsedSec payload))))
|
|
204
|
+
:onEnd (fn [] (finish-session! id))
|
|
205
|
+
:onError (fn [error] (emit-error! (.-message error)))
|
|
206
|
+
:scheduleStartFallback
|
|
207
|
+
(fn [delay-ms callback]
|
|
208
|
+
((:schedule-start-fallback session-scheduler) id delay-ms callback))
|
|
209
|
+
:cancelStartFallback
|
|
210
|
+
(fn []
|
|
211
|
+
((:cancel-start-fallback session-scheduler) id))}]
|
|
212
|
+
(-> (speak-web-speech! config plan)
|
|
213
|
+
(.then (fn [handle]
|
|
214
|
+
(if (active-session? id)
|
|
215
|
+
(swap! resources assoc :webSpeechHandle handle)
|
|
216
|
+
(stop-web-speech-handle! handle))))
|
|
217
|
+
(.catch (fn [error]
|
|
218
|
+
(when (active-session? id)
|
|
219
|
+
(emit-error! (.-message error))))))))
|
|
220
|
+
|
|
221
|
+
(start-azure! [id command snippet-name]
|
|
222
|
+
(let [config (:config @state-atom)
|
|
223
|
+
text (:text command)
|
|
224
|
+
voice-name (azure-voice-name config command)
|
|
225
|
+
style (let [raw (or (:style command) (:azureStyle config))]
|
|
226
|
+
(when (and (string? raw) (pos? (count (.trim raw)))) raw))
|
|
227
|
+
rate (or (:rate command) (:rate config))
|
|
228
|
+
pitch (or (:pitch command) (:pitch config))
|
|
229
|
+
volume (or (:volume command) (:volume config))
|
|
230
|
+
cache-key (azure-cache-key text voice-name style
|
|
231
|
+
(scalar->azure-percent rate)
|
|
232
|
+
(scalar->azure-pitch-percent pitch))
|
|
233
|
+
cached (get @azure-cache cache-key)
|
|
234
|
+
synth-promise (if cached
|
|
235
|
+
(js/Promise.resolve (clj->js cached))
|
|
236
|
+
(synthesize-azure! config (assoc command
|
|
237
|
+
:voiceName voice-name
|
|
238
|
+
:style style
|
|
239
|
+
:rate rate
|
|
240
|
+
:pitch pitch)))]
|
|
241
|
+
;; Prime synchronously while the click/user gesture is still
|
|
242
|
+
;; available. Synthesis may finish later, but the element is
|
|
243
|
+
;; already allowed to play in stricter browsers.
|
|
244
|
+
;; Prime HTMLAudio for autoplay unlock only. Do not advertise the
|
|
245
|
+
;; playback-reference track until real Azure audio is routed, or
|
|
246
|
+
;; barge-in would arm against a silent muted unlock clip.
|
|
247
|
+
(prime-audio! resources volume)
|
|
248
|
+
(-> synth-promise
|
|
249
|
+
(.then (fn [raw]
|
|
250
|
+
;; Provider/backend shapes are normalized before
|
|
251
|
+
;; any playback or LipSync command is emitted.
|
|
252
|
+
(let [payload (azure/normalize-azure-synthesis
|
|
253
|
+
(if (map? raw)
|
|
254
|
+
raw
|
|
255
|
+
(js->clj raw :keywordize-keys true)))]
|
|
256
|
+
(when-not cached
|
|
257
|
+
(swap! azure-cache remember-cache cache-key payload (:azureCacheLimit config)))
|
|
258
|
+
;; Audio is required. Visemes help LipSync but must not
|
|
259
|
+
;; block playback when Azure returns audio alone.
|
|
260
|
+
(if (pos? (count (str (:audioBase64 payload))))
|
|
261
|
+
payload
|
|
262
|
+
(throw (js-error "Azure TTS returned no usable audio payload"))))))
|
|
263
|
+
(.then (fn [payload]
|
|
264
|
+
(when (active-session? id)
|
|
265
|
+
(play-azure-audio! config resources
|
|
266
|
+
(assoc payload
|
|
267
|
+
:volume volume)))))
|
|
268
|
+
(.then (fn [playback]
|
|
269
|
+
(when (active-session? id)
|
|
270
|
+
(let [payload (if cached cached (get @azure-cache cache-key))
|
|
271
|
+
duration-sec (or (:durationSec payload) (aget playback "durationSec") 0)
|
|
272
|
+
word-timings (:wordTimings payload)]
|
|
273
|
+
(configure-lipsync! "azure" command)
|
|
274
|
+
(emit-lipsync! (azure/azure-synthesis->lipsync-command
|
|
275
|
+
snippet-name
|
|
276
|
+
text
|
|
277
|
+
(assoc payload :durationSec duration-sec)
|
|
278
|
+
config))
|
|
279
|
+
(emit-lipsync! {:type "audioStarted"
|
|
280
|
+
:name snippet-name
|
|
281
|
+
:audioTimeSec 0})
|
|
282
|
+
(swap! state-atom state/record-speaking (now-ms))
|
|
283
|
+
(emit-event {:type "ttsSpeechStarted"
|
|
284
|
+
:agency "tts"
|
|
285
|
+
:engine "azure"
|
|
286
|
+
:name snippet-name
|
|
287
|
+
:startedAt (:startedAt @state-atom)})
|
|
288
|
+
(emit-status!)
|
|
289
|
+
(notify-playback-reference-track!)
|
|
290
|
+
(when-let [clock (or (:clock playback)
|
|
291
|
+
(when (aget playback "clock")
|
|
292
|
+
(js->clj (aget playback "clock") :keywordize-keys true)))]
|
|
293
|
+
((:schedule-boundaries session-scheduler)
|
|
294
|
+
id
|
|
295
|
+
active-session?
|
|
296
|
+
clock
|
|
297
|
+
word-timings
|
|
298
|
+
(fn [event]
|
|
299
|
+
(word-boundary! id
|
|
300
|
+
(:word event)
|
|
301
|
+
(:observedElapsedSec event)
|
|
302
|
+
nil))))
|
|
303
|
+
(when-let [audio (or (:audio playback) (aget playback "audio"))]
|
|
304
|
+
(set! (.-onended audio) #(finish-session! id)))))))
|
|
305
|
+
(.catch (fn [error]
|
|
306
|
+
(when (active-session? id)
|
|
307
|
+
(emit-error! (.-message error))))))))
|
|
308
|
+
|
|
309
|
+
(speak! [command]
|
|
310
|
+
(let [config (:config @state-atom)
|
|
311
|
+
engine (or (:engine command) (:engine config))
|
|
312
|
+
text (:text command)
|
|
313
|
+
snippet-name (or (:name command) (str "tts_" engine "_" (.now js/Date)))
|
|
314
|
+
;; Provider planning sees only capability facts. It does not
|
|
315
|
+
;; receive JS handles or secrets, and it never performs side
|
|
316
|
+
;; effects.
|
|
317
|
+
world {:backendUrl (backend-url config command)
|
|
318
|
+
:hasAzureSynthesize (boolean (custom-provider config :azureSynthesize))
|
|
319
|
+
:hasWebSpeech (boolean (or (custom-provider config :webSpeechSpeak)
|
|
320
|
+
(speech-synthesis*)))}
|
|
321
|
+
plan (planner/plan-speech (assoc command :engine engine) config world)]
|
|
322
|
+
(swap! state-atom (fn [state]
|
|
323
|
+
(-> state
|
|
324
|
+
state/next-session
|
|
325
|
+
(state/record-plan plan)
|
|
326
|
+
(state/record-loading engine text snippet-name))))
|
|
327
|
+
(emit-event {:type "ttsPlanCreated"
|
|
328
|
+
:agency "tts"
|
|
329
|
+
:plan plan})
|
|
330
|
+
(emit-status!)
|
|
331
|
+
(if-not (:ok plan)
|
|
332
|
+
(emit-error! (plan-failure-message plan))
|
|
333
|
+
(do
|
|
334
|
+
((:start-session session-scheduler) (session-id) engine snippet-name)
|
|
335
|
+
(case engine
|
|
336
|
+
"azure" (start-azure! (session-id) command snippet-name)
|
|
337
|
+
"webSpeech" (start-web-speech! (session-id) command snippet-name)
|
|
338
|
+
(emit-error! (str "Unsupported TTS engine: " engine)))))))
|
|
339
|
+
|
|
340
|
+
(load-voices! [engine]
|
|
341
|
+
(let [config (:config @state-atom)
|
|
342
|
+
;; Voice loading has its own plan so missing backend/browser
|
|
343
|
+
;; support is visible before any provider request is made.
|
|
344
|
+
request-id (next-voice-load! engine)
|
|
345
|
+
world {:backendUrl (:backendUrl config)
|
|
346
|
+
:hasAzureVoices (boolean (custom-provider config :azureVoices))
|
|
347
|
+
:hasWebSpeech (boolean (or (custom-provider config :webSpeechVoices)
|
|
348
|
+
(speech-synthesis*)))}
|
|
349
|
+
plan (planner/plan-voice-load engine world)]
|
|
350
|
+
(emit-event {:type "ttsPlanCreated"
|
|
351
|
+
:agency "tts"
|
|
352
|
+
:plan plan})
|
|
353
|
+
(if-not (:ok plan)
|
|
354
|
+
(do
|
|
355
|
+
(when (= "azure" engine)
|
|
356
|
+
(swap! state-atom
|
|
357
|
+
state/record-azure-status
|
|
358
|
+
"error"
|
|
359
|
+
(plan-failure-message plan)
|
|
360
|
+
[]))
|
|
361
|
+
(emit-error! (plan-failure-message plan)))
|
|
362
|
+
(case engine
|
|
363
|
+
"webSpeech"
|
|
364
|
+
(-> (load-web-speech-voices! config)
|
|
365
|
+
(.then (fn [voices]
|
|
366
|
+
(when (active-voice-load? engine request-id)
|
|
367
|
+
(let [normalized (normalize-voices
|
|
368
|
+
(js->clj voices :keywordize-keys true)
|
|
369
|
+
"webSpeech")]
|
|
370
|
+
(swap! state-atom state/record-web-speech-voices normalized)
|
|
371
|
+
(emit-event {:type "ttsVoicesLoaded"
|
|
372
|
+
:agency "tts"
|
|
373
|
+
:engine "webSpeech"
|
|
374
|
+
:voices normalized})
|
|
375
|
+
(emit-status!)))))
|
|
376
|
+
(.catch (fn [error]
|
|
377
|
+
(when (active-voice-load? engine request-id)
|
|
378
|
+
(emit-error! (.-message error))))))
|
|
379
|
+
|
|
380
|
+
"azure"
|
|
381
|
+
(-> (load-azure-voices! config)
|
|
382
|
+
(.then (fn [response]
|
|
383
|
+
(when (active-voice-load? engine request-id)
|
|
384
|
+
(let [data (js->clj response :keywordize-keys true)
|
|
385
|
+
voices (normalize-voices (:voices data) "azure")
|
|
386
|
+
ready? (and (:valid data) (seq voices))
|
|
387
|
+
status (if ready? "ready" "error")
|
|
388
|
+
message (if ready?
|
|
389
|
+
(str "Connected via backend environment (" (count voices) " voices).")
|
|
390
|
+
(or (:message data) "Backend Azure credentials are not configured."))]
|
|
391
|
+
(swap! state-atom state/record-azure-status status message voices)
|
|
392
|
+
(emit-event {:type "ttsVoicesLoaded"
|
|
393
|
+
:agency "tts"
|
|
394
|
+
:engine "azure"
|
|
395
|
+
:voices voices
|
|
396
|
+
:status status
|
|
397
|
+
:message message})
|
|
398
|
+
(emit-status!)))))
|
|
399
|
+
(.catch (fn [error]
|
|
400
|
+
(when (active-voice-load? engine request-id)
|
|
401
|
+
(swap! state-atom state/record-azure-status "error" (.-message error) [])
|
|
402
|
+
(emit-error! (.-message error))))))
|
|
403
|
+
|
|
404
|
+
(emit-error! (str "Unsupported voice provider: " engine))))))
|
|
405
|
+
|
|
406
|
+
(run-action! [action]
|
|
407
|
+
(case (:op action)
|
|
408
|
+
"cancel-voice-loads"
|
|
409
|
+
(cancel-voice-loads!)
|
|
410
|
+
|
|
411
|
+
"configure"
|
|
412
|
+
(do
|
|
413
|
+
(swap! state-atom state/configure (:config action))
|
|
414
|
+
(emit-status!))
|
|
415
|
+
|
|
416
|
+
"load-voices"
|
|
417
|
+
(load-voices! (:engine action))
|
|
418
|
+
|
|
419
|
+
"stop-active-session"
|
|
420
|
+
(when (active-speech?)
|
|
421
|
+
(stop-session! (:reason action)))
|
|
422
|
+
|
|
423
|
+
"advance-session"
|
|
424
|
+
(swap! state-atom state/next-session)
|
|
425
|
+
|
|
426
|
+
"stop-session"
|
|
427
|
+
(stop-session! (:reason action))
|
|
428
|
+
|
|
429
|
+
"speak"
|
|
430
|
+
(speak! (:command action))
|
|
431
|
+
|
|
432
|
+
"reset"
|
|
433
|
+
(do
|
|
434
|
+
(stop-web-speech!)
|
|
435
|
+
((:stop-all session-scheduler))
|
|
436
|
+
(cleanup-audio! resources)
|
|
437
|
+
(reset! state-atom (state/default-state nil))
|
|
438
|
+
(emit-status!))
|
|
439
|
+
|
|
440
|
+
"error"
|
|
441
|
+
(emit-error! (:message action))
|
|
442
|
+
|
|
443
|
+
(emit-error! (str "Unknown TTS planner action: " (:op action)))))
|
|
444
|
+
|
|
445
|
+
(dispatch! [command]
|
|
446
|
+
(when-not @disposed?
|
|
447
|
+
(let [payload (js->clj command :keywordize-keys true)]
|
|
448
|
+
(emit-input {:type "command"
|
|
449
|
+
:agency "tts"
|
|
450
|
+
:command payload})
|
|
451
|
+
(doseq [action (planner/plan-command @state-atom payload)]
|
|
452
|
+
(run-action! action)))))]
|
|
453
|
+
#js {:dispatch dispatch!
|
|
454
|
+
:snapshot (fn [] (state/visible-state @state-atom))
|
|
455
|
+
:schedulerQueue (fn [] (clj->js ((:queue session-scheduler))))
|
|
456
|
+
:input (stream/writable-port input-stream dispatch!)
|
|
457
|
+
:events (stream/readable-port event-stream)
|
|
458
|
+
:subscribeInput (fn [listener] ((:subscribe input-stream) listener))
|
|
459
|
+
:subscribeEvents (fn [listener] ((:subscribe event-stream) listener))
|
|
460
|
+
:subscribe (fn [listener] ((:subscribe event-stream) listener))
|
|
461
|
+
:subscribeStatus (fn [listener] ((:subscribe event-stream) listener))
|
|
462
|
+
:configure (fn [next-config]
|
|
463
|
+
(dispatch! #js {:type "configure" :config next-config})
|
|
464
|
+
(when (= "none" (get-in @state-atom [:config :webSpeechReferenceMode]))
|
|
465
|
+
(clear-display-media-reference! resources)
|
|
466
|
+
(notify-playback-reference-track!)))
|
|
467
|
+
:loadVoices (fn
|
|
468
|
+
([] (dispatch! #js {:type "loadVoices"}))
|
|
469
|
+
([engine] (dispatch! #js {:type "loadVoices" :engine engine})))
|
|
470
|
+
:speak (fn [text] (dispatch! #js {:type "speak" :text text}))
|
|
471
|
+
:stop (fn [] (dispatch! #js {:type "stop"}))
|
|
472
|
+
:reset (fn [] (dispatch! #js {:type "reset"}))
|
|
473
|
+
:getPlaybackReferenceTrack (fn [] (get-playback-reference-track resources))
|
|
474
|
+
:onPlaybackReferenceTrackChange (fn [listener]
|
|
475
|
+
(swap! playback-reference-listeners conj listener)
|
|
476
|
+
(listener (get-playback-reference-track resources))
|
|
477
|
+
(fn []
|
|
478
|
+
(swap! playback-reference-listeners disj listener)))
|
|
479
|
+
:preparePlaybackReference (fn []
|
|
480
|
+
(let [config (:config @state-atom)]
|
|
481
|
+
(cond
|
|
482
|
+
(and (= "webSpeech" (:engine config))
|
|
483
|
+
(= "displayMedia" (:webSpeechReferenceMode config)))
|
|
484
|
+
(-> (ensure-web-speech-display-media-reference! resources)
|
|
485
|
+
(.then (fn [status]
|
|
486
|
+
(notify-playback-reference-track!)
|
|
487
|
+
status)))
|
|
488
|
+
|
|
489
|
+
(get-playback-reference-track resources)
|
|
490
|
+
(js/Promise.resolve "available")
|
|
491
|
+
|
|
492
|
+
:else
|
|
493
|
+
(js/Promise.resolve "unavailable"))))
|
|
494
|
+
:dispose (fn []
|
|
495
|
+
(when-not @disposed?
|
|
496
|
+
(reset! disposed? true)
|
|
497
|
+
(cancel-voice-loads!)
|
|
498
|
+
((:dispose session-scheduler))
|
|
499
|
+
(stop-web-speech!)
|
|
500
|
+
(dispose-playback-resources! resources)
|
|
501
|
+
(reset! playback-reference-listeners #{})
|
|
502
|
+
((:dispose input-stream))
|
|
503
|
+
((:dispose event-stream))))})))
|