@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,302 @@
|
|
|
1
|
+
(ns polymer.lipsync.scheduler
|
|
2
|
+
(:require [clojure.string :as str]))
|
|
3
|
+
|
|
4
|
+
;; LipSync's scheduler owns the effector queue for one utterance.
|
|
5
|
+
;;
|
|
6
|
+
;; The LipSync agency decides what speech timeline is active and records state.
|
|
7
|
+
;; The scheduler owns the ordered work produced by that decision:
|
|
8
|
+
;; schedule the combined lip/jaw/tongue snippet, seek it when audio or word
|
|
9
|
+
;; boundaries reveal drift, remove it when the utterance is stopped/replaced, and
|
|
10
|
+
;; fire the local finish callback after the snippet should be done. It still does
|
|
11
|
+
;; not call Embody directly; it emits animation intent as data for Polymer
|
|
12
|
+
;; Animation to execute.
|
|
13
|
+
|
|
14
|
+
(def cleanup-buffer-ms 80)
|
|
15
|
+
|
|
16
|
+
(defn now-ms []
|
|
17
|
+
(.now js/Date))
|
|
18
|
+
|
|
19
|
+
(defn finite-positive? [value]
|
|
20
|
+
(and (number? value) (js/isFinite value) (pos? value)))
|
|
21
|
+
|
|
22
|
+
(def tongue-au-ids #{37 38 39 40 41 42 73 74 76 77})
|
|
23
|
+
|
|
24
|
+
(defn clear-timeout! [timer-atom]
|
|
25
|
+
(when-let [timer @timer-atom]
|
|
26
|
+
(js/clearTimeout timer)
|
|
27
|
+
(reset! timer-atom nil)))
|
|
28
|
+
|
|
29
|
+
(defn effector-target [channel]
|
|
30
|
+
(let [target (:target channel)
|
|
31
|
+
target-type (:type target)]
|
|
32
|
+
(case target-type
|
|
33
|
+
"viseme" "lip"
|
|
34
|
+
"au" (case (:id target)
|
|
35
|
+
26 "jaw"
|
|
36
|
+
(if (contains? tongue-au-ids (:id target)) "tongue" "au"))
|
|
37
|
+
"bone" (case (:id target)
|
|
38
|
+
"JAW" "jaw"
|
|
39
|
+
"TONGUE" "tongue"
|
|
40
|
+
"bone")
|
|
41
|
+
target-type)))
|
|
42
|
+
|
|
43
|
+
(defn snippet-effectors [snippet]
|
|
44
|
+
(->> (:channels snippet)
|
|
45
|
+
(keep effector-target)
|
|
46
|
+
distinct
|
|
47
|
+
vec))
|
|
48
|
+
|
|
49
|
+
(defn queue-entry [effect queue-index]
|
|
50
|
+
(assoc effect
|
|
51
|
+
:queueIndex queue-index
|
|
52
|
+
:queuedAt (now-ms)))
|
|
53
|
+
|
|
54
|
+
(defn promise-like? [value]
|
|
55
|
+
(boolean (and value (fn? (aget value "then")))))
|
|
56
|
+
|
|
57
|
+
(defn create-scheduler
|
|
58
|
+
[{:keys [emit-event on-finished pronunciation-effector on-text-ready]}]
|
|
59
|
+
(let [finish-timer (atom nil)
|
|
60
|
+
active-timeline (atom nil)
|
|
61
|
+
pronunciation-dictionary (atom nil)
|
|
62
|
+
text-plan-token (atom 0)
|
|
63
|
+
queue (atom [])
|
|
64
|
+
disposed? (atom false)]
|
|
65
|
+
(letfn [(enqueue! [effect]
|
|
66
|
+
(let [entry (queue-entry effect (count @queue))]
|
|
67
|
+
(swap! queue conj entry)
|
|
68
|
+
entry))
|
|
69
|
+
|
|
70
|
+
(schedule-finished! [max-time-sec]
|
|
71
|
+
(clear-timeout! finish-timer)
|
|
72
|
+
(when (and (not @disposed?) (finite-positive? max-time-sec))
|
|
73
|
+
(reset! finish-timer
|
|
74
|
+
(js/setTimeout
|
|
75
|
+
(fn []
|
|
76
|
+
(reset! finish-timer nil)
|
|
77
|
+
(when-not @disposed?
|
|
78
|
+
(enqueue! {:type "finishTimeline"
|
|
79
|
+
:agency "lipSync"
|
|
80
|
+
:reason "completed"})
|
|
81
|
+
(on-finished)))
|
|
82
|
+
(+ (* max-time-sec 1000) cleanup-buffer-ms)))))
|
|
83
|
+
|
|
84
|
+
(schedule-animation! [snippet-data options]
|
|
85
|
+
(let [effectors (snippet-effectors snippet-data)
|
|
86
|
+
entry (enqueue! {:type "scheduleAnimation"
|
|
87
|
+
:agency "lipSync"
|
|
88
|
+
:requestId (:name snippet-data)
|
|
89
|
+
:snippetName (:name snippet-data)
|
|
90
|
+
:effectors effectors})]
|
|
91
|
+
(emit-event {:type "animation.requestScheduleSnippet"
|
|
92
|
+
:agency "lipSync"
|
|
93
|
+
:requestId (:requestId entry)
|
|
94
|
+
:snippet snippet-data
|
|
95
|
+
:options options
|
|
96
|
+
:effectors effectors
|
|
97
|
+
:queueIndex (:queueIndex entry)
|
|
98
|
+
:queuedAt (:queuedAt entry)})
|
|
99
|
+
(schedule-finished! (:maxTime snippet-data))))
|
|
100
|
+
|
|
101
|
+
(report-pronunciation-error! [error]
|
|
102
|
+
(emit-event {:type "error"
|
|
103
|
+
:agency "lipSync"
|
|
104
|
+
:message (or (.-message error)
|
|
105
|
+
"Unable to load LipSync pronunciation data")}))
|
|
106
|
+
|
|
107
|
+
(load-pronunciations! [on-loaded on-error]
|
|
108
|
+
(try
|
|
109
|
+
(let [result ((:load pronunciation-effector))]
|
|
110
|
+
(if (promise-like? result)
|
|
111
|
+
(-> result
|
|
112
|
+
(.then on-loaded)
|
|
113
|
+
(.catch on-error))
|
|
114
|
+
(on-loaded result)))
|
|
115
|
+
(catch :default error
|
|
116
|
+
(on-error error))))
|
|
117
|
+
|
|
118
|
+
(accept-pronunciations! [dictionary]
|
|
119
|
+
(when dictionary
|
|
120
|
+
(when (nil? @pronunciation-dictionary)
|
|
121
|
+
(reset! pronunciation-dictionary dictionary)
|
|
122
|
+
(enqueue! {:type "pronunciationLexiconReady"
|
|
123
|
+
:agency "lipSync"})))
|
|
124
|
+
dictionary)
|
|
125
|
+
|
|
126
|
+
(preload-pronunciations! []
|
|
127
|
+
(when (and (not @disposed?)
|
|
128
|
+
(nil? @pronunciation-dictionary))
|
|
129
|
+
(if-let [dictionary ((:peek pronunciation-effector))]
|
|
130
|
+
(reset! pronunciation-dictionary dictionary)
|
|
131
|
+
(do
|
|
132
|
+
(enqueue! {:type "loadPronunciationLexicon"
|
|
133
|
+
:agency "lipSync"
|
|
134
|
+
:reason "preload"})
|
|
135
|
+
(load-pronunciations!
|
|
136
|
+
(fn [dictionary]
|
|
137
|
+
(when-not @disposed?
|
|
138
|
+
(accept-pronunciations! dictionary)))
|
|
139
|
+
(fn [_]
|
|
140
|
+
;; Preload is opportunistic. A later startText retries and
|
|
141
|
+
;; reports an error only if speech actually needs the data.
|
|
142
|
+
nil))))))
|
|
143
|
+
|
|
144
|
+
(prepare-text! [payload]
|
|
145
|
+
(let [token (swap! text-plan-token inc)]
|
|
146
|
+
(if-let [dictionary @pronunciation-dictionary]
|
|
147
|
+
(on-text-ready payload dictionary)
|
|
148
|
+
(do
|
|
149
|
+
(enqueue! {:type "loadPronunciationLexicon"
|
|
150
|
+
:agency "lipSync"
|
|
151
|
+
:reason "startText"
|
|
152
|
+
:requestToken token})
|
|
153
|
+
(load-pronunciations!
|
|
154
|
+
(fn [dictionary]
|
|
155
|
+
(when (and (not @disposed?)
|
|
156
|
+
(= token @text-plan-token))
|
|
157
|
+
(if-let [accepted (accept-pronunciations! dictionary)]
|
|
158
|
+
(on-text-ready payload accepted)
|
|
159
|
+
(report-pronunciation-error!
|
|
160
|
+
(js/Error.
|
|
161
|
+
"LipSync pronunciation module has no dictionary export")))))
|
|
162
|
+
(fn [error]
|
|
163
|
+
(when (and (not @disposed?)
|
|
164
|
+
(= token @text-plan-token))
|
|
165
|
+
(report-pronunciation-error! error))))))))
|
|
166
|
+
|
|
167
|
+
(remove-animation! [name reason]
|
|
168
|
+
(let [entry (enqueue! {:type "removeAnimation"
|
|
169
|
+
:agency "lipSync"
|
|
170
|
+
:requestId name
|
|
171
|
+
:name name
|
|
172
|
+
:reason reason})]
|
|
173
|
+
(emit-event {:type "animation.requestRemoveSnippet"
|
|
174
|
+
:agency "lipSync"
|
|
175
|
+
:requestId name
|
|
176
|
+
:name name
|
|
177
|
+
:reason reason
|
|
178
|
+
:queueIndex (:queueIndex entry)
|
|
179
|
+
:queuedAt (:queuedAt entry)})))
|
|
180
|
+
|
|
181
|
+
(seek-animation! [name offset-sec reason]
|
|
182
|
+
(let [request-id (str name ":seek:" (str/replace (str (now-ms)) "." "_"))
|
|
183
|
+
entry (enqueue! {:type "seekAnimation"
|
|
184
|
+
:agency "lipSync"
|
|
185
|
+
:requestId request-id
|
|
186
|
+
:name name
|
|
187
|
+
:offsetSec offset-sec
|
|
188
|
+
:reason reason})]
|
|
189
|
+
(emit-event {:type "animation.requestSeekSnippet"
|
|
190
|
+
:agency "lipSync"
|
|
191
|
+
:requestId request-id
|
|
192
|
+
:name name
|
|
193
|
+
:offsetSec offset-sec
|
|
194
|
+
:reason reason
|
|
195
|
+
:queueIndex (:queueIndex entry)
|
|
196
|
+
:queuedAt (:queuedAt entry)})))
|
|
197
|
+
|
|
198
|
+
(start-timeline! [snippet-data options]
|
|
199
|
+
(swap! text-plan-token inc)
|
|
200
|
+
(let [name (:name snippet-data)
|
|
201
|
+
effectors (snippet-effectors snippet-data)
|
|
202
|
+
replaced-name (:name @active-timeline)
|
|
203
|
+
replacement? (and replaced-name (not= replaced-name name))]
|
|
204
|
+
;; A new utterance owns the same mouth/jaw/tongue effectors as
|
|
205
|
+
;; the previous utterance, so the scheduler removes the previous
|
|
206
|
+
;; animation request before accepting the next start. That keeps
|
|
207
|
+
;; replacement ordering in one queue instead of scattering it
|
|
208
|
+
;; through agency command handlers.
|
|
209
|
+
(when replacement?
|
|
210
|
+
(remove-animation! replaced-name "replaced"))
|
|
211
|
+
(reset! active-timeline {:name name
|
|
212
|
+
:maxTime (:maxTime snippet-data)
|
|
213
|
+
:effectors effectors})
|
|
214
|
+
(enqueue! {:type "startTimeline"
|
|
215
|
+
:agency "lipSync"
|
|
216
|
+
:requestId name
|
|
217
|
+
:snippetName name
|
|
218
|
+
:effectors effectors
|
|
219
|
+
:maxTime (:maxTime snippet-data)
|
|
220
|
+
:replacedName (when replacement? replaced-name)})
|
|
221
|
+
(schedule-animation! snippet-data options)
|
|
222
|
+
{:name name
|
|
223
|
+
:replacedName (when replacement? replaced-name)
|
|
224
|
+
:effectors effectors}))
|
|
225
|
+
|
|
226
|
+
(stop-timeline! [reason remove?]
|
|
227
|
+
(swap! text-plan-token inc)
|
|
228
|
+
(clear-timeout! finish-timer)
|
|
229
|
+
(let [current @active-timeline
|
|
230
|
+
name (:name current)]
|
|
231
|
+
(when name
|
|
232
|
+
(enqueue! {:type "stopTimeline"
|
|
233
|
+
:agency "lipSync"
|
|
234
|
+
:requestId name
|
|
235
|
+
:snippetName name
|
|
236
|
+
:reason reason
|
|
237
|
+
:remove? remove?})
|
|
238
|
+
(when remove?
|
|
239
|
+
(remove-animation! name reason)))
|
|
240
|
+
(reset! active-timeline nil)
|
|
241
|
+
{:name name
|
|
242
|
+
:reason reason
|
|
243
|
+
:remove? remove?}))]
|
|
244
|
+
{:start-timeline
|
|
245
|
+
(fn [snippet-data options]
|
|
246
|
+
(when-not @disposed?
|
|
247
|
+
(start-timeline! snippet-data options)))
|
|
248
|
+
|
|
249
|
+
:preload-pronunciations
|
|
250
|
+
(fn []
|
|
251
|
+
(preload-pronunciations!))
|
|
252
|
+
|
|
253
|
+
:prepare-text
|
|
254
|
+
(fn [payload]
|
|
255
|
+
(when-not @disposed?
|
|
256
|
+
(prepare-text! payload)))
|
|
257
|
+
|
|
258
|
+
:schedule-timeline
|
|
259
|
+
(fn [snippet-data options]
|
|
260
|
+
(when-not @disposed?
|
|
261
|
+
(schedule-animation! snippet-data options)))
|
|
262
|
+
|
|
263
|
+
:remove
|
|
264
|
+
(fn [name reason]
|
|
265
|
+
(when-not @disposed?
|
|
266
|
+
(remove-animation! name reason)))
|
|
267
|
+
|
|
268
|
+
:seek
|
|
269
|
+
(fn [name offset-sec reason]
|
|
270
|
+
(when-not @disposed?
|
|
271
|
+
(seek-animation! name offset-sec reason)))
|
|
272
|
+
|
|
273
|
+
:schedule-finished
|
|
274
|
+
(fn [max-time-sec]
|
|
275
|
+
(when-not @disposed?
|
|
276
|
+
(schedule-finished! max-time-sec)))
|
|
277
|
+
|
|
278
|
+
:stop
|
|
279
|
+
(fn []
|
|
280
|
+
;; Replacement/explicit stop cancels only pending local finish. Remove
|
|
281
|
+
;; requests are separate queued effector operations.
|
|
282
|
+
(clear-timeout! finish-timer))
|
|
283
|
+
|
|
284
|
+
:stop-timeline
|
|
285
|
+
(fn [reason remove?]
|
|
286
|
+
(when-not @disposed?
|
|
287
|
+
(stop-timeline! reason remove?)))
|
|
288
|
+
|
|
289
|
+
:queue
|
|
290
|
+
(fn []
|
|
291
|
+
@queue)
|
|
292
|
+
|
|
293
|
+
:dispose
|
|
294
|
+
(fn []
|
|
295
|
+
;; Disposal is final for this scheduler instance; late callbacks are
|
|
296
|
+
;; guarded by disposed? even if a host implementation fails to clear.
|
|
297
|
+
(reset! disposed? true)
|
|
298
|
+
(swap! text-plan-token inc)
|
|
299
|
+
(clear-timeout! finish-timer)
|
|
300
|
+
(reset! active-timeline nil)
|
|
301
|
+
(reset! pronunciation-dictionary nil)
|
|
302
|
+
(reset! queue []))})))
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
(ns polymer.lipsync.state
|
|
2
|
+
(:require [clojure.string :as str]))
|
|
3
|
+
|
|
4
|
+
;; LipSync state is the agency-local ledger for one character's speech
|
|
5
|
+
;; animation timeline. Provider credentials, audio playback, HTTP, LiveKit, and
|
|
6
|
+
;; browser APIs stay outside Polymer; this state only tracks the deterministic
|
|
7
|
+
;; timing data needed to schedule and correct viseme animation.
|
|
8
|
+
|
|
9
|
+
(def default-config
|
|
10
|
+
{:intensity 1
|
|
11
|
+
:speechRate 1
|
|
12
|
+
:jawScale 1
|
|
13
|
+
:tongueScale 1
|
|
14
|
+
:rampMs 15
|
|
15
|
+
:holdMs 40
|
|
16
|
+
:priority 50
|
|
17
|
+
:visualLeadMs 0
|
|
18
|
+
:wordDriftThresholdSec 0.06})
|
|
19
|
+
|
|
20
|
+
(def default-state
|
|
21
|
+
{:agency "lipSync"
|
|
22
|
+
:speaking false
|
|
23
|
+
:currentWord nil
|
|
24
|
+
:currentViseme nil
|
|
25
|
+
:snippetName nil
|
|
26
|
+
:source nil
|
|
27
|
+
:text nil
|
|
28
|
+
:startTime nil
|
|
29
|
+
:audioStartedAt nil
|
|
30
|
+
:audioTimeSec nil
|
|
31
|
+
:maxTime 0
|
|
32
|
+
:wordIndex 0
|
|
33
|
+
:wordTimings []
|
|
34
|
+
:scheduledCount 0
|
|
35
|
+
:stoppedCount 0
|
|
36
|
+
:syncCorrectionCount 0
|
|
37
|
+
:config default-config
|
|
38
|
+
:lastEvent nil})
|
|
39
|
+
|
|
40
|
+
(defn now-ms []
|
|
41
|
+
(.now js/Date))
|
|
42
|
+
|
|
43
|
+
(defn finite-number? [value]
|
|
44
|
+
(and (number? value) (js/isFinite value)))
|
|
45
|
+
|
|
46
|
+
(defn clamp [lo hi value]
|
|
47
|
+
(-> value (max lo) (min hi)))
|
|
48
|
+
|
|
49
|
+
(defn number-or [value fallback]
|
|
50
|
+
(if (finite-number? value) value fallback))
|
|
51
|
+
|
|
52
|
+
(defn sanitize-config [config]
|
|
53
|
+
;; This is the only place public config gets clamped. The rest of the agency
|
|
54
|
+
;; can then treat config as ordinary data, whether it came from LoomLarge UI,
|
|
55
|
+
;; another Polymer agency, or a worker message.
|
|
56
|
+
(let [merged (merge default-config config)]
|
|
57
|
+
{:intensity (clamp 0 2 (number-or (:intensity merged) (:intensity default-config)))
|
|
58
|
+
:speechRate (clamp 0.2 3 (number-or (:speechRate merged) (:speechRate default-config)))
|
|
59
|
+
:jawScale (clamp 0 2 (number-or (:jawScale merged) (:jawScale default-config)))
|
|
60
|
+
:tongueScale (clamp 0 2 (number-or (:tongueScale merged) (:tongueScale default-config)))
|
|
61
|
+
:rampMs (clamp 0 200 (number-or (:rampMs merged) (:rampMs default-config)))
|
|
62
|
+
:holdMs (clamp 0 500 (number-or (:holdMs merged) (:holdMs default-config)))
|
|
63
|
+
:priority (int (clamp -1000 1000 (number-or (:priority merged) (:priority default-config))))
|
|
64
|
+
:visualLeadMs (clamp 0 250 (number-or (:visualLeadMs merged) (:visualLeadMs default-config)))
|
|
65
|
+
:wordDriftThresholdSec (clamp 0.01 0.5 (number-or (:wordDriftThresholdSec merged)
|
|
66
|
+
(:wordDriftThresholdSec default-config)))}))
|
|
67
|
+
|
|
68
|
+
(defn js-config [config]
|
|
69
|
+
(if config (js->clj config :keywordize-keys true) {}))
|
|
70
|
+
|
|
71
|
+
(defn config->state [config]
|
|
72
|
+
(assoc default-state :config (sanitize-config (js-config config))))
|
|
73
|
+
|
|
74
|
+
(defn configure [state config]
|
|
75
|
+
(update state :config #(sanitize-config (merge % (js-config config)))))
|
|
76
|
+
|
|
77
|
+
(defn clean-word [word]
|
|
78
|
+
(when word
|
|
79
|
+
(let [value (str/trim (str word))]
|
|
80
|
+
(when (pos? (count value)) value))))
|
|
81
|
+
|
|
82
|
+
(defn word-start-sec [timing]
|
|
83
|
+
(number-or (or (:startSec timing)
|
|
84
|
+
(:start timing)
|
|
85
|
+
(:start_time timing))
|
|
86
|
+
0))
|
|
87
|
+
|
|
88
|
+
(defn word-end-sec [timing]
|
|
89
|
+
(number-or (or (:endSec timing)
|
|
90
|
+
(:end timing)
|
|
91
|
+
(:end_time timing))
|
|
92
|
+
(word-start-sec timing)))
|
|
93
|
+
|
|
94
|
+
(defn normalize-word-timing [timing]
|
|
95
|
+
;; Provider word-boundary messages are not perfectly standardized. Normalize
|
|
96
|
+
;; both the LiveKit-style start/end shape and the Azure-style start_time/end_time
|
|
97
|
+
;; shape into the one state shape used for drift correction.
|
|
98
|
+
(let [word (clean-word (:word timing))
|
|
99
|
+
start-sec (max 0 (word-start-sec timing))
|
|
100
|
+
end-sec (max start-sec (word-end-sec timing))]
|
|
101
|
+
(when word
|
|
102
|
+
{:word word
|
|
103
|
+
:startSec start-sec
|
|
104
|
+
:endSec end-sec})))
|
|
105
|
+
|
|
106
|
+
(defn normalize-word-timings [word-timings]
|
|
107
|
+
;; This is a pure row-normalization pass: normalize every provider timing row
|
|
108
|
+
;; and drop malformed/empty words. A local transducer keeps that one pass
|
|
109
|
+
;; explicit without creating a separate namespace for a tiny transformation.
|
|
110
|
+
(into [] (keep normalize-word-timing) (or word-timings [])))
|
|
111
|
+
|
|
112
|
+
(defn record-start [state timeline snippet-name started-at max-time]
|
|
113
|
+
;; A timeline starts as one utterance-level animation snippet. Word timings are
|
|
114
|
+
;; retained only for drift correction; they are not used to create per-word
|
|
115
|
+
;; snippets that can pile up or fight each other.
|
|
116
|
+
(let [visemes (:visemes timeline)
|
|
117
|
+
first-viseme (:visemeId (first visemes))]
|
|
118
|
+
(-> state
|
|
119
|
+
(assoc :speaking true
|
|
120
|
+
:currentWord nil
|
|
121
|
+
:currentViseme first-viseme
|
|
122
|
+
:snippetName snippet-name
|
|
123
|
+
:source (or (:source timeline) "unknown")
|
|
124
|
+
:text (:text timeline)
|
|
125
|
+
:startTime started-at
|
|
126
|
+
:audioStartedAt nil
|
|
127
|
+
:audioTimeSec nil
|
|
128
|
+
:maxTime max-time
|
|
129
|
+
:wordIndex 0
|
|
130
|
+
:wordTimings (normalize-word-timings (:wordTimings timeline)))
|
|
131
|
+
(update :scheduledCount inc)
|
|
132
|
+
(assoc :lastEvent {:type "lipSyncTimelineStarted"
|
|
133
|
+
:name snippet-name
|
|
134
|
+
:source (or (:source timeline) "unknown")
|
|
135
|
+
:at started-at}))))
|
|
136
|
+
|
|
137
|
+
(defn record-stop [state stopped-at reason]
|
|
138
|
+
(-> state
|
|
139
|
+
(assoc :speaking false
|
|
140
|
+
:currentWord nil
|
|
141
|
+
:currentViseme nil
|
|
142
|
+
:snippetName nil
|
|
143
|
+
:source nil
|
|
144
|
+
:text nil
|
|
145
|
+
:startTime nil
|
|
146
|
+
:audioStartedAt nil
|
|
147
|
+
:audioTimeSec nil
|
|
148
|
+
:maxTime 0
|
|
149
|
+
:wordIndex 0
|
|
150
|
+
:wordTimings [])
|
|
151
|
+
(update :stoppedCount inc)
|
|
152
|
+
(assoc :lastEvent {:type "lipSyncTimelineStopped"
|
|
153
|
+
:reason reason
|
|
154
|
+
:at stopped-at})))
|
|
155
|
+
|
|
156
|
+
(defn record-word-boundary [state word word-index observed-at]
|
|
157
|
+
(-> state
|
|
158
|
+
(assoc :currentWord word
|
|
159
|
+
:wordIndex (inc (or word-index (:wordIndex state) 0)))
|
|
160
|
+
(assoc :lastEvent {:type "lipSyncWordBoundary"
|
|
161
|
+
:word word
|
|
162
|
+
:wordIndex word-index
|
|
163
|
+
:at observed-at})))
|
|
164
|
+
|
|
165
|
+
(defn record-word-timings [state word-timings updated-at]
|
|
166
|
+
;; Provider timing metadata can arrive after viseme data. Updating it in the
|
|
167
|
+
;; agency keeps later word-boundary drift correction accurate without asking
|
|
168
|
+
;; LoomLarge to manage any LipSync internals.
|
|
169
|
+
(-> state
|
|
170
|
+
(assoc :wordTimings (normalize-word-timings word-timings)
|
|
171
|
+
:wordIndex 0)
|
|
172
|
+
(assoc :lastEvent {:type "lipSyncWordTimingsUpdated"
|
|
173
|
+
:count (count (normalize-word-timings word-timings))
|
|
174
|
+
:at updated-at})))
|
|
175
|
+
|
|
176
|
+
(defn record-sync-correction [state name offset-sec corrected-at]
|
|
177
|
+
(-> state
|
|
178
|
+
(update :syncCorrectionCount inc)
|
|
179
|
+
(assoc :lastEvent {:type "lipSyncSyncCorrection"
|
|
180
|
+
:name name
|
|
181
|
+
:offsetSec offset-sec
|
|
182
|
+
:at corrected-at})))
|
|
183
|
+
|
|
184
|
+
(defn record-audio-started [state audio-time-sec observed-at]
|
|
185
|
+
;; Audio remains a host side effect; LipSync only records the host clock value
|
|
186
|
+
;; that should drive the already-scheduled animation timeline.
|
|
187
|
+
(-> state
|
|
188
|
+
(assoc :audioStartedAt observed-at
|
|
189
|
+
:audioTimeSec (max 0 (number-or audio-time-sec 0)))
|
|
190
|
+
(assoc :lastEvent {:type "lipSyncAudioStarted"
|
|
191
|
+
:audioTimeSec (max 0 (number-or audio-time-sec 0))
|
|
192
|
+
:at observed-at})))
|
|
193
|
+
|
|
194
|
+
(defn record-audio-time [state audio-time-sec observed-at]
|
|
195
|
+
(-> state
|
|
196
|
+
(assoc :audioTimeSec (max 0 (number-or audio-time-sec 0)))
|
|
197
|
+
(assoc :lastEvent {:type "lipSyncAudioTime"
|
|
198
|
+
:audioTimeSec (max 0 (number-or audio-time-sec 0))
|
|
199
|
+
:at observed-at})))
|
|
200
|
+
|
|
201
|
+
(defn visible-state [state]
|
|
202
|
+
(clj->js state))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
(ns polymer.nlp.phonemes
|
|
2
|
+
(:require [clojure.string :as str]))
|
|
3
|
+
|
|
4
|
+
;; The CMU dictionary is browser-compatible ESM and provides exact ARPABET for
|
|
5
|
+
;; more than 134,000 English entries. It is deliberately a lexicon, not a G2P
|
|
6
|
+
;; model: lookup misses stay explicit and neutral instead of becoming guessed
|
|
7
|
+
;; phoneme sequences.
|
|
8
|
+
(def unresolved-prefix "UNRESOLVED_WORD:")
|
|
9
|
+
|
|
10
|
+
(def punctuation #{"." "," ";" ":" "!" "?"})
|
|
11
|
+
|
|
12
|
+
(def supported-phonemes
|
|
13
|
+
#{"AA" "AE" "AH" "AO" "AW" "AX" "AY"
|
|
14
|
+
"B" "CH" "D" "DH" "EH" "ER" "EY"
|
|
15
|
+
"F" "G" "HH" "IH" "IY" "JH" "K"
|
|
16
|
+
"L" "M" "N" "NG" "OW" "OY" "P"
|
|
17
|
+
"R" "S" "SH" "T" "TH" "UH" "UW"
|
|
18
|
+
"V" "W" "Y" "Z" "ZH"})
|
|
19
|
+
|
|
20
|
+
(def arpabet-aliases
|
|
21
|
+
{"EL" "L"
|
|
22
|
+
"EM" "M"
|
|
23
|
+
"EN" "N"
|
|
24
|
+
"AXR" "ER"
|
|
25
|
+
"UX" "UW"
|
|
26
|
+
"HV" "HH"
|
|
27
|
+
"WH" "W"
|
|
28
|
+
"DX" "D"})
|
|
29
|
+
|
|
30
|
+
(def arpabet-expansions
|
|
31
|
+
{"TS" ["T" "S"]
|
|
32
|
+
"DZ" ["D" "Z"]})
|
|
33
|
+
|
|
34
|
+
(defn clean-word [word]
|
|
35
|
+
(-> (or word "")
|
|
36
|
+
str
|
|
37
|
+
str/lower-case
|
|
38
|
+
(str/replace #"[^a-z']" "")))
|
|
39
|
+
|
|
40
|
+
(defn pause-token [token]
|
|
41
|
+
(case token
|
|
42
|
+
"," "PAUSE_COMMA"
|
|
43
|
+
";" "PAUSE_SEMICOLON"
|
|
44
|
+
":" "PAUSE_COLON"
|
|
45
|
+
"." "PAUSE_PERIOD"
|
|
46
|
+
"?" "PAUSE_QUESTION"
|
|
47
|
+
"!" "PAUSE_EXCLAMATION"
|
|
48
|
+
"PAUSE_SPACE"))
|
|
49
|
+
|
|
50
|
+
(defn tokenize [text]
|
|
51
|
+
(vec (re-seq #"[A-Za-z']+|[,.;:!?]|\s+" (or text ""))))
|
|
52
|
+
|
|
53
|
+
(defn unresolved-token [word]
|
|
54
|
+
(str unresolved-prefix (max 1 (count (clean-word word)))))
|
|
55
|
+
|
|
56
|
+
(defn unresolved-token? [phoneme]
|
|
57
|
+
(str/starts-with? (or phoneme "") unresolved-prefix))
|
|
58
|
+
|
|
59
|
+
(defn unresolved-token-length [phoneme]
|
|
60
|
+
(let [text (or phoneme "")
|
|
61
|
+
parsed (js/parseInt (subs text (min (count unresolved-prefix)
|
|
62
|
+
(count text)))
|
|
63
|
+
10)]
|
|
64
|
+
(if (js/isFinite parsed) (max 1 parsed) 1)))
|
|
65
|
+
|
|
66
|
+
(defn normalize-arpabet-token [token]
|
|
67
|
+
(let [normalized (-> (or token "")
|
|
68
|
+
str/upper-case
|
|
69
|
+
(str/replace #"[0-9]" ""))
|
|
70
|
+
aliased (get arpabet-aliases normalized normalized)]
|
|
71
|
+
(or (get arpabet-expansions aliased)
|
|
72
|
+
(when (contains? supported-phonemes aliased)
|
|
73
|
+
[aliased]))))
|
|
74
|
+
|
|
75
|
+
(defn arpabet->phonemes
|
|
76
|
+
"Validate a complete ARPABET word. One unknown token invalidates the word."
|
|
77
|
+
[arpabet]
|
|
78
|
+
(let [tokens (vec (if (string? arpabet)
|
|
79
|
+
(remove str/blank? (str/split arpabet #"\s+"))
|
|
80
|
+
(or arpabet [])))
|
|
81
|
+
normalized (mapv normalize-arpabet-token tokens)
|
|
82
|
+
unmapped (into []
|
|
83
|
+
(keep-indexed (fn [index expansion]
|
|
84
|
+
(when (nil? expansion)
|
|
85
|
+
(nth tokens index))))
|
|
86
|
+
normalized)]
|
|
87
|
+
{:phonemes (into [] (comp (keep identity) cat) normalized)
|
|
88
|
+
:unmappedTokens unmapped}))
|
|
89
|
+
|
|
90
|
+
(defn unresolved-pronunciation [word unmapped]
|
|
91
|
+
{:word word
|
|
92
|
+
:source "unresolved"
|
|
93
|
+
:phonemes [(unresolved-token word)]
|
|
94
|
+
:unmappedTokens (vec unmapped)})
|
|
95
|
+
|
|
96
|
+
(defn dictionary-value [dictionary word]
|
|
97
|
+
(when dictionary
|
|
98
|
+
(aget dictionary word)))
|
|
99
|
+
|
|
100
|
+
(defn dictionary-pronunciations
|
|
101
|
+
"Return CMU's canonical entry and numbered alternatives for one spelling."
|
|
102
|
+
[dictionary word]
|
|
103
|
+
(let [cleaned (clean-word word)
|
|
104
|
+
canonical (dictionary-value dictionary cleaned)]
|
|
105
|
+
(loop [index 2
|
|
106
|
+
entries (cond-> [] canonical (conj canonical))]
|
|
107
|
+
(if-let [alternative (dictionary-value dictionary
|
|
108
|
+
(str cleaned "(" index ")"))]
|
|
109
|
+
(recur (inc index) (conj entries alternative))
|
|
110
|
+
entries))))
|
|
111
|
+
|
|
112
|
+
(defn pronunciation-from-arpabet [word variant arpabet]
|
|
113
|
+
(let [converted (arpabet->phonemes arpabet)]
|
|
114
|
+
(if (and (seq (:phonemes converted))
|
|
115
|
+
(empty? (:unmappedTokens converted)))
|
|
116
|
+
(assoc converted
|
|
117
|
+
:word word
|
|
118
|
+
:source "cmuLexicon"
|
|
119
|
+
:variant variant
|
|
120
|
+
:arpabet arpabet)
|
|
121
|
+
(unresolved-pronunciation word (:unmappedTokens converted)))))
|
|
122
|
+
|
|
123
|
+
(defn word-pronunciations
|
|
124
|
+
"Return every validated CMU pronunciation for a spelling."
|
|
125
|
+
[dictionary word]
|
|
126
|
+
(let [cleaned (clean-word word)
|
|
127
|
+
entries (dictionary-pronunciations dictionary cleaned)]
|
|
128
|
+
(if (seq entries)
|
|
129
|
+
(mapv #(pronunciation-from-arpabet cleaned %1 %2)
|
|
130
|
+
(range 1 (inc (count entries)))
|
|
131
|
+
entries)
|
|
132
|
+
[(unresolved-pronunciation cleaned [])])))
|
|
133
|
+
|
|
134
|
+
(defn word-pronunciation
|
|
135
|
+
"Return CMU's canonical pronunciation, or an explicit neutral miss."
|
|
136
|
+
[dictionary word]
|
|
137
|
+
(let [cleaned (clean-word word)]
|
|
138
|
+
(if (str/blank? cleaned)
|
|
139
|
+
{:word cleaned :source "empty" :phonemes [] :unmappedTokens []}
|
|
140
|
+
(first (word-pronunciations dictionary cleaned)))))
|
|
141
|
+
|
|
142
|
+
(defn word->phonemes [dictionary word]
|
|
143
|
+
(:phonemes (word-pronunciation dictionary word)))
|
|
144
|
+
|
|
145
|
+
(defn text-pronunciations
|
|
146
|
+
"Look up each word independently; no context-sensitive choice is invented."
|
|
147
|
+
[dictionary text]
|
|
148
|
+
(into []
|
|
149
|
+
(keep (fn [token]
|
|
150
|
+
(cond
|
|
151
|
+
(re-matches #"[A-Za-z']+" token)
|
|
152
|
+
(word-pronunciation dictionary token)
|
|
153
|
+
|
|
154
|
+
(contains? punctuation token)
|
|
155
|
+
{:word token
|
|
156
|
+
:source "punctuation"
|
|
157
|
+
:phonemes [(pause-token token)]
|
|
158
|
+
:unmappedTokens []}
|
|
159
|
+
|
|
160
|
+
:else nil)))
|
|
161
|
+
(tokenize text)))
|
|
162
|
+
|
|
163
|
+
(defn text->phonemes [dictionary text]
|
|
164
|
+
(into [] (mapcat :phonemes) (text-pronunciations dictionary text)))
|