@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.
Files changed (85) hide show
  1. package/README.md +226 -0
  2. package/dist/index.d.ts +2603 -0
  3. package/dist/index.js +1031 -0
  4. package/dist/test.cjs +1519 -0
  5. package/dist/worker/agency-worker.js +972 -0
  6. package/dist/worker/manifest.edn +1 -0
  7. package/package.json +57 -0
  8. package/shadow-cljs.edn +173 -0
  9. package/src/polymer/animation/agency.cljs +84 -0
  10. package/src/polymer/animation/domain.cljs +110 -0
  11. package/src/polymer/animation/planner.cljs +88 -0
  12. package/src/polymer/animation/runtime.cljs +137 -0
  13. package/src/polymer/animation/scheduler.cljs +178 -0
  14. package/src/polymer/animation/service.cljs +981 -0
  15. package/src/polymer/animation/state.cljs +112 -0
  16. package/src/polymer/blink/agency.cljs +131 -0
  17. package/src/polymer/blink/planner.cljs +115 -0
  18. package/src/polymer/blink/scheduler.cljs +90 -0
  19. package/src/polymer/blink/snippet.cljs +50 -0
  20. package/src/polymer/blink/state.cljs +102 -0
  21. package/src/polymer/camera_context/agency.cljs +132 -0
  22. package/src/polymer/camera_context/domain.cljs +186 -0
  23. package/src/polymer/camera_context/planner.cljs +50 -0
  24. package/src/polymer/camera_context/scheduler.cljs +113 -0
  25. package/src/polymer/camera_context/state.cljs +121 -0
  26. package/src/polymer/character.cljs +471 -0
  27. package/src/polymer/conversation/agency.cljs +83 -0
  28. package/src/polymer/conversation/domain.cljs +35 -0
  29. package/src/polymer/conversation/effector.cljs +130 -0
  30. package/src/polymer/conversation/planner.cljs +178 -0
  31. package/src/polymer/conversation/scheduler.cljs +202 -0
  32. package/src/polymer/conversation/service.cljs +329 -0
  33. package/src/polymer/conversation/state.cljs +186 -0
  34. package/src/polymer/core.cljs +172 -0
  35. package/src/polymer/eye_head/agency.cljs +75 -0
  36. package/src/polymer/eye_head/domain.cljs +133 -0
  37. package/src/polymer/eye_head/goap.cljs +106 -0
  38. package/src/polymer/eye_head/scheduler.cljs +176 -0
  39. package/src/polymer/eye_head/service.cljs +259 -0
  40. package/src/polymer/eye_head/snippet.cljs +92 -0
  41. package/src/polymer/eye_head/state.cljs +167 -0
  42. package/src/polymer/gaze/agency.cljs +85 -0
  43. package/src/polymer/gaze/domain.cljs +211 -0
  44. package/src/polymer/gaze/goap.cljs +208 -0
  45. package/src/polymer/gaze/scheduler.cljs +184 -0
  46. package/src/polymer/gaze/state.cljs +202 -0
  47. package/src/polymer/gesture/agency.cljs +185 -0
  48. package/src/polymer/gesture/goap.cljs +461 -0
  49. package/src/polymer/gesture/scheduler.cljs +122 -0
  50. package/src/polymer/gesture/snippet.cljs +198 -0
  51. package/src/polymer/gesture/state.cljs +273 -0
  52. package/src/polymer/lipsync/agency.cljs +593 -0
  53. package/src/polymer/lipsync/articulation/jaw.cljs +258 -0
  54. package/src/polymer/lipsync/articulation/snippet.cljs +605 -0
  55. package/src/polymer/lipsync/articulation/tongue.cljs +418 -0
  56. package/src/polymer/lipsync/articulation/visemes.cljs +429 -0
  57. package/src/polymer/lipsync/goap.cljs +132 -0
  58. package/src/polymer/lipsync/pronunciation/effector.cljs +51 -0
  59. package/src/polymer/lipsync/scheduler.cljs +302 -0
  60. package/src/polymer/lipsync/state.cljs +202 -0
  61. package/src/polymer/nlp/phonemes.cljs +164 -0
  62. package/src/polymer/prosodic/agency.cljs +196 -0
  63. package/src/polymer/prosodic/goap.cljs +122 -0
  64. package/src/polymer/prosodic/scheduler.cljs +99 -0
  65. package/src/polymer/prosodic/snippet.cljs +106 -0
  66. package/src/polymer/prosodic/state.cljs +139 -0
  67. package/src/polymer/runtime/embody.cljs +117 -0
  68. package/src/polymer/runtime/embody_wasm.cljs +177 -0
  69. package/src/polymer/runtime/rust_embody_host.cljs +645 -0
  70. package/src/polymer/society.cljs +239 -0
  71. package/src/polymer/stream.cljs +130 -0
  72. package/src/polymer/transcription/agency.cljs +69 -0
  73. package/src/polymer/transcription/domain.cljs +68 -0
  74. package/src/polymer/transcription/effector.cljs +611 -0
  75. package/src/polymer/transcription/planner.cljs +203 -0
  76. package/src/polymer/transcription/scheduler.cljs +225 -0
  77. package/src/polymer/transcription/service.cljs +209 -0
  78. package/src/polymer/transcription/state.cljs +239 -0
  79. package/src/polymer/tts/agency.cljs +503 -0
  80. package/src/polymer/tts/azure.cljs +290 -0
  81. package/src/polymer/tts/planner.cljs +178 -0
  82. package/src/polymer/tts/runtime.cljs +624 -0
  83. package/src/polymer/tts/scheduler.cljs +162 -0
  84. package/src/polymer/tts/state.cljs +191 -0
  85. package/src/polymer/worker.cljs +70 -0
@@ -0,0 +1,605 @@
1
+ (ns polymer.lipsync.articulation.snippet
2
+ (:require [clojure.string :as str]
3
+ [polymer.lipsync.articulation.jaw :as jaw]
4
+ [polymer.lipsync.state :as state]
5
+ [polymer.lipsync.articulation.tongue :as tongue]
6
+ [polymer.lipsync.articulation.visemes :as visemes]))
7
+
8
+ ;; Snippet construction is pure: it turns a normalized viseme timeline into the
9
+ ;; animation data that Polymer Animation can schedule. It does not know about
10
+ ;; host UI code, audio playback, LiveKit, Azure credentials, or engine handles.
11
+
12
+ (def jaw-au "26")
13
+ (def labiodental-contact-au "32")
14
+ (def labiodental-press-au "24")
15
+ (def intensity-eps 0.001)
16
+ (def coarticulation-strength 0.52)
17
+ (def envelope-shoulder-ratio 0.55)
18
+ (def envelope-shoulder-intensity 0.62)
19
+ (def lip-total-activation-cap 1.05)
20
+ (def lip-dominant-cap 1)
21
+ (def lip-secondary-ratio 0.30)
22
+ (def lip-secondary-cap 0.22)
23
+ (def closure-dominance-threshold 0.55)
24
+ (def closure-secondary-cap 0.035)
25
+ (def plosive-preclose-sec 0.028)
26
+ (def plosive-hold-sec 0.022)
27
+ (def plosive-release-sec 0.018)
28
+ (def labiodental-contact-peak 0.24)
29
+ (def labiodental-press-peak 0.12)
30
+
31
+ (def vowel-visemes
32
+ #{(:AE visemes/canonical-visemes)
33
+ (:Ah visemes/canonical-visemes)
34
+ (:EE visemes/canonical-visemes)
35
+ (:Er visemes/canonical-visemes)
36
+ (:Ih visemes/canonical-visemes)
37
+ (:Oh visemes/canonical-visemes)})
38
+
39
+ (defn finite-number? [value]
40
+ (and (number? value) (js/isFinite value)))
41
+
42
+ (defn normalize-event [event]
43
+ (let [viseme-id (or (:visemeId event) (:viseme_id event) (:id event))
44
+ offset-ms (or (:offsetMs event) (:offset_ms event) (:offset event) 0)
45
+ duration-ms (or (:durationMs event) (:duration_ms event) (:duration event) 0)
46
+ jaw-activation (or (:jawActivation event)
47
+ (:jaw_activation event)
48
+ (:jaw event)
49
+ (:jawAmount event))]
50
+ (when (and (finite-number? viseme-id)
51
+ (finite-number? offset-ms)
52
+ (finite-number? duration-ms)
53
+ (pos? duration-ms))
54
+ (let [canonical-id (int (state/clamp 0 14 viseme-id))]
55
+ (cond->
56
+ {:visemeId canonical-id
57
+ :offsetMs (max 0 offset-ms)
58
+ :durationMs (max 1 duration-ms)
59
+ :jawActivation (if (finite-number? jaw-activation)
60
+ (state/clamp 0 2 jaw-activation)
61
+ (visemes/jaw-activation-for-viseme canonical-id))}
62
+ (:phoneme event) (assoc :phoneme (:phoneme event))
63
+ (or (:phonemeClass event) (:phoneme_class event))
64
+ (assoc :phonemeClass (or (:phonemeClass event) (:phoneme_class event)))
65
+ (or (:phonemeClasses event) (:phoneme_classes event))
66
+ (assoc :phonemeClasses (or (:phonemeClasses event) (:phoneme_classes event)))
67
+ (:neutral event) (assoc :neutral true))))))
68
+
69
+ (defn normalize-events [events]
70
+ ;; Normalize/drop is element-local and transducer-friendly. Sorting is kept as
71
+ ;; the next explicit step because ordering is a whole-collection decision.
72
+ (->> (into [] (keep normalize-event) (or events []))
73
+ (sort-by :offsetMs)
74
+ vec))
75
+
76
+ (defn viseme-class [viseme-id]
77
+ (cond
78
+ (= viseme-id (:B_M_P visemes/canonical-visemes)) :bilabial
79
+ (= viseme-id (:W_OO visemes/canonical-visemes)) :glide
80
+ (contains? vowel-visemes viseme-id) :vowel
81
+ (#{(:Ch_J visemes/canonical-visemes)
82
+ (:F_V visemes/canonical-visemes)
83
+ (:S_Z visemes/canonical-visemes)
84
+ (:Th visemes/canonical-visemes)} viseme-id) :fricative
85
+ (#{(:K_G_H_NG visemes/canonical-visemes)
86
+ (:T_L_D_N visemes/canonical-visemes)} viseme-id) :tongue
87
+ (= viseme-id (:R visemes/canonical-visemes)) :liquid
88
+ :else :default))
89
+
90
+ (defn class-name [value]
91
+ (cond
92
+ (keyword? value) (name value)
93
+ (string? value) (str/lower-case value)
94
+ (nil? value) nil
95
+ :else (str/lower-case (str value))))
96
+
97
+ (defn event-classes [event]
98
+ (let [from-event (into []
99
+ (keep class-name)
100
+ (concat (or (:phonemeClasses event) [])
101
+ [(:phonemeClass event)]))
102
+ fallback (into [] (keep class-name) (visemes/viseme-classes (:visemeId event)))]
103
+ (set (if (seq from-event) from-event fallback))))
104
+
105
+ (defn has-class? [event class]
106
+ (contains? (event-classes event) class))
107
+
108
+ (defn normalized-phoneme [event]
109
+ (visemes/normalize-phoneme (:phoneme event)))
110
+
111
+ (defn vowel-event? [event]
112
+ (or (has-class? event "vowel")
113
+ (contains? vowel-visemes (:visemeId event))))
114
+
115
+ (defn bilabial-plosive? [event]
116
+ ;; M shares the B_M_P viseme but should not get a hard burst release. Only
117
+ ;; known P/B phonemes, or class metadata that says bilabial obstruent without
118
+ ;; nasal, get the plosive closure plan.
119
+ (let [phoneme (normalized-phoneme event)
120
+ classes (event-classes event)]
121
+ (and (= (:visemeId event) (:B_M_P visemes/canonical-visemes))
122
+ (not (contains? classes "nasal"))
123
+ (or (contains? #{"P" "B"} phoneme)
124
+ (and (contains? classes "bilabial")
125
+ (contains? classes "obstruent"))))))
126
+
127
+ (defn labiodental-event? [event]
128
+ (or (= (:visemeId event) (:F_V visemes/canonical-visemes))
129
+ (has-class? event "labiodental")))
130
+
131
+ (defn envelope-profile [viseme-id]
132
+ ;; The profile keeps closure sounds crisp, vowels rounder, and fricatives
133
+ ;; lighter. Those differences matter more than raw intensity for readable
134
+ ;; speech animation. The timings are deliberately visual timings, not acoustic
135
+ ;; timings: anything below roughly one 60fps frame reads as snapping once the
136
+ ;; snippet reaches Three.js, so non-closure sounds get ramps long enough to
137
+ ;; interpolate across multiple rendered frames.
138
+ (cond
139
+ (= viseme-id (:W_OO visemes/canonical-visemes)) {:attackSec 0.034 :releaseSec 0.046 :peak 0.98}
140
+ (= viseme-id (:Oh visemes/canonical-visemes)) {:attackSec 0.036 :releaseSec 0.046 :peak 0.96}
141
+ (= viseme-id (:EE visemes/canonical-visemes)) {:attackSec 0.030 :releaseSec 0.038 :peak 0.94}
142
+ (= viseme-id (:Ih visemes/canonical-visemes)) {:attackSec 0.028 :releaseSec 0.036 :peak 0.88}
143
+ (= viseme-id (:F_V visemes/canonical-visemes)) {:attackSec 0.024 :releaseSec 0.034 :peak 0.86}
144
+ (= viseme-id (:Th visemes/canonical-visemes)) {:attackSec 0.024 :releaseSec 0.034 :peak 0.82}
145
+ (= viseme-id (:Ch_J visemes/canonical-visemes)) {:attackSec 0.026 :releaseSec 0.036 :peak 0.84}
146
+ (= viseme-id (:S_Z visemes/canonical-visemes)) {:attackSec 0.022 :releaseSec 0.032 :peak 0.78}
147
+ (= viseme-id (:K_G_H_NG visemes/canonical-visemes)) {:attackSec 0.022 :releaseSec 0.032 :peak 0.68}
148
+ (= viseme-id (:T_L_D_N visemes/canonical-visemes)) {:attackSec 0.026 :releaseSec 0.036 :peak 0.80}
149
+ :else
150
+ (case (viseme-class viseme-id)
151
+ :bilabial {:attackSec 0.004 :releaseSec 0.006 :peak 1.0}
152
+ :vowel {:attackSec 0.032 :releaseSec 0.042 :peak 0.92}
153
+ :fricative {:attackSec 0.024 :releaseSec 0.034 :peak 0.72}
154
+ :tongue {:attackSec 0.026 :releaseSec 0.036 :peak 0.76}
155
+ :liquid {:attackSec 0.030 :releaseSec 0.038 :peak 0.82}
156
+ :glide {:attackSec 0.028 :releaseSec 0.038 :peak 0.84}
157
+ {:attackSec 0.026 :releaseSec 0.034 :peak 0.86})))
158
+
159
+ (defn deduplicate-curve [curve]
160
+ (let [frames (vec curve)]
161
+ (if (<= (count frames) 1)
162
+ frames
163
+ (loop [remaining (rest frames)
164
+ result [(first frames)]]
165
+ (if (empty? remaining)
166
+ (vec result)
167
+ (let [curr (first remaining)
168
+ prev (last result)
169
+ next-frame (second remaining)
170
+ intensity-changed? (> (js/Math.abs (- (:intensity curr) (:intensity prev))) intensity-eps)
171
+ ends-plateau? (and next-frame
172
+ (> (js/Math.abs (- (:intensity next-frame) (:intensity curr))) intensity-eps))
173
+ keep? (or intensity-changed? ends-plateau? (= 1 (count remaining)))]
174
+ (recur (rest remaining) (if keep? (conj result curr) result))))))))
175
+
176
+ (defn scale-lip-intensity [value intensity]
177
+ (let [normalized (state/clamp 0 lip-dominant-cap value)
178
+ scale (max 0 (state/number-or intensity 1))]
179
+ (cond
180
+ (<= (js/Math.abs (- scale 1)) intensity-eps) normalized
181
+ (<= scale 1) (* normalized scale)
182
+ :else (- 1 (js/Math.pow (- 1 normalized) scale)))))
183
+
184
+ (defn scale-curve-intensity [curve intensity]
185
+ (mapv (fn [frame]
186
+ (assoc frame :intensity (scale-lip-intensity (:intensity frame) intensity)))
187
+ curve))
188
+
189
+ (defn build-viseme-curve [viseme-id start-ms duration-ms intensity]
190
+ (let [start-sec (/ start-ms 1000)
191
+ duration-sec (/ duration-ms 1000)
192
+ end-sec (+ start-sec duration-sec)
193
+ profile (envelope-profile viseme-id)
194
+ peak (:peak profile)]
195
+ (if (<= duration-sec 0)
196
+ []
197
+ (let [attack-sec (min (:attackSec profile) (* duration-sec 0.45))
198
+ release-sec (min (:releaseSec profile) (* duration-sec 0.45))]
199
+ (scale-curve-intensity
200
+ (if (<= duration-sec (+ attack-sec release-sec 0.002))
201
+ [{:time start-sec :intensity 0}
202
+ {:time (+ start-sec (* duration-sec 0.5)) :intensity peak}
203
+ {:time end-sec :intensity 0}]
204
+ (let [ramp-up-end (+ start-sec attack-sec)
205
+ hold-end (- end-sec release-sec)]
206
+ (if (= (viseme-class viseme-id) :bilabial)
207
+ [{:time start-sec :intensity 0}
208
+ {:time ramp-up-end :intensity peak}
209
+ {:time hold-end :intensity peak}
210
+ {:time end-sec :intensity 0}]
211
+ [{:time start-sec :intensity 0}
212
+ {:time (+ start-sec (* attack-sec envelope-shoulder-ratio))
213
+ :intensity (* peak envelope-shoulder-intensity)}
214
+ {:time ramp-up-end :intensity peak}
215
+ {:time hold-end :intensity peak}
216
+ {:time (+ hold-end (* release-sec (- 1 envelope-shoulder-ratio)))
217
+ :intensity (* peak envelope-shoulder-intensity)}
218
+ {:time end-sec :intensity 0}])))
219
+ intensity)))))
220
+
221
+ (defn build-plosive-closure-curve [event next-event intensity]
222
+ ;; P/B need a visible close-hold-release phrase, not just a generic envelope.
223
+ ;; The close starts slightly early, holds the B_M_P target briefly, then drops
224
+ ;; sharply at the following vowel boundary when one is present. Jaw motion is
225
+ ;; still separate and comes only from the jaw planner.
226
+ (let [start-sec (/ (:offsetMs event) 1000)
227
+ duration-sec (/ (:durationMs event) 1000)
228
+ end-sec (+ start-sec duration-sec)
229
+ next-start-sec (when next-event (/ (:offsetMs next-event) 1000))
230
+ release-end-sec (if (and next-start-sec (vowel-event? next-event))
231
+ (max start-sec next-start-sec)
232
+ end-sec)
233
+ close-start-sec (max 0 (- start-sec plosive-preclose-sec))
234
+ close-end-sec (min (+ start-sec 0.010) release-end-sec)
235
+ hold-end-sec (max close-end-sec
236
+ (min (- release-end-sec 0.004)
237
+ (+ close-end-sec plosive-hold-sec)
238
+ (- end-sec plosive-release-sec)))
239
+ peak (:peak (envelope-profile (:visemeId event)))]
240
+ (scale-curve-intensity
241
+ (-> [{:time close-start-sec :intensity 0}
242
+ {:time close-end-sec :intensity peak}
243
+ {:time hold-end-sec :intensity peak}
244
+ {:time release-end-sec :intensity 0}]
245
+ deduplicate-curve
246
+ vec)
247
+ intensity)))
248
+
249
+ (defn build-event-viseme-curve [event next-event intensity]
250
+ (cond
251
+ (:neutral event) []
252
+ (bilabial-plosive? event) (build-plosive-closure-curve event next-event intensity)
253
+ :else (build-viseme-curve (:visemeId event)
254
+ (:offsetMs event)
255
+ (:durationMs event)
256
+ intensity)))
257
+
258
+ (defn merge-curves [existing incoming]
259
+ (->> (concat existing incoming)
260
+ (sort-by :time)
261
+ deduplicate-curve
262
+ vec))
263
+
264
+ (defn build-labiodental-au-curve [event peak intensity]
265
+ ;; F/V already activate the F_V viseme. This small AU overlay gives the lower
266
+ ;; lip a top-teeth contact cue on rigs that expose AU32/AU24, while rigs that
267
+ ;; do not expose those AUs can ignore the channels safely.
268
+ (let [start-sec (/ (:offsetMs event) 1000)
269
+ duration-sec (/ (:durationMs event) 1000)
270
+ end-sec (+ start-sec duration-sec)
271
+ attack-sec (min 0.024 (* duration-sec 0.35))
272
+ release-sec (min 0.034 (* duration-sec 0.40))
273
+ hold-end-sec (max (+ start-sec attack-sec)
274
+ (- end-sec release-sec))]
275
+ (scale-curve-intensity
276
+ [{:time start-sec :intensity 0}
277
+ {:time (+ start-sec attack-sec) :intensity peak}
278
+ {:time hold-end-sec :intensity peak}
279
+ {:time end-sec :intensity 0}]
280
+ intensity)))
281
+
282
+ (defn build-labiodental-curves [events intensity]
283
+ (reduce (fn [curves event]
284
+ (if (labiodental-event? event)
285
+ (-> curves
286
+ (update labiodental-contact-au
287
+ #(merge-curves (or % [])
288
+ (build-labiodental-au-curve event
289
+ labiodental-contact-peak
290
+ intensity)))
291
+ (update labiodental-press-au
292
+ #(merge-curves (or % [])
293
+ (build-labiodental-au-curve event
294
+ labiodental-press-peak
295
+ intensity))))
296
+ curves))
297
+ {}
298
+ events))
299
+
300
+ (defn update-last-frame-time [curve f]
301
+ (let [frames (vec curve)
302
+ idx (dec (count frames))]
303
+ (if (neg? idx)
304
+ frames
305
+ (assoc-in frames [idx :time] (f (get-in frames [idx :time]))))))
306
+
307
+ (defn update-first-frame-time [curve f]
308
+ (let [frames (vec curve)]
309
+ (if (empty? frames)
310
+ frames
311
+ (assoc-in frames [0 :time] (f (get-in frames [0 :time]))))))
312
+
313
+ (defn apply-coarticulation [curves events]
314
+ ;; Carry and anticipation are conservative. Bilabials keep hard closures;
315
+ ;; vowels/liquids/glides get a small overlap so provider timelines do not
316
+ ;; look like disconnected mouth poses.
317
+ (loop [index 0
318
+ result curves]
319
+ (if (>= index (dec (count events)))
320
+ result
321
+ (let [current (get events index)
322
+ next-event (get events (inc index))
323
+ current-class (viseme-class (:visemeId current))
324
+ next-class (viseme-class (:visemeId next-event))
325
+ current-end (+ (:offsetMs current) (:durationMs current))
326
+ next-start (:offsetMs next-event)
327
+ gap (- next-start current-end)
328
+ current-key (str (:visemeId current))
329
+ next-key (str (:visemeId next-event))
330
+ blend? (and (< gap 50)
331
+ (> gap -30)
332
+ (not= current-class :bilabial)
333
+ (not= next-class :bilabial))
334
+ carry? (contains? #{:vowel :liquid :glide} current-class)
335
+ anticipate? (contains? #{:vowel :liquid :glide} next-class)]
336
+ (recur (inc index)
337
+ (cond-> result
338
+ (and blend? carry? (get result current-key))
339
+ (update current-key update-last-frame-time
340
+ #(min (max % (/ next-start 1000))
341
+ (+ % (* 0.010 coarticulation-strength))))
342
+
343
+ (and blend? anticipate? (get result next-key))
344
+ (update next-key update-first-frame-time
345
+ #(max 0 (- % (* 0.016 coarticulation-strength))))))))))
346
+
347
+ (defn sample-curve-at [curve time]
348
+ ;; The limiter below works on sampled poses instead of raw keyframes. That is
349
+ ;; what keeps dense Azure timelines from briefly activating several full lip
350
+ ;; shapes at once between keyframes.
351
+ (let [frames (vec curve)]
352
+ (cond
353
+ (empty? frames) 0
354
+ (<= time (:time (first frames))) (:intensity (first frames))
355
+ (>= time (:time (last frames))) (:intensity (last frames))
356
+ :else
357
+ (loop [index 0]
358
+ (if (>= index (dec (count frames)))
359
+ 0
360
+ (let [a (get frames index)
361
+ b (get frames (inc index))]
362
+ (if (and (>= time (:time a)) (<= time (:time b)))
363
+ (let [span (max 0.000001 (- (:time b) (:time a)))
364
+ progress (/ (- time (:time a)) span)]
365
+ (+ (:intensity a) (* (- (:intensity b) (:intensity a)) progress)))
366
+ (recur (inc index)))))))))
367
+
368
+ (defn rounded-sample-time [time]
369
+ (/ (js/Math.round (* time 1000)) 1000))
370
+
371
+ (defn collect-lip-sample-times [curves]
372
+ (let [times (atom #{})]
373
+ (doseq [[key curve] curves
374
+ :when (not= key jaw-au)
375
+ frame curve]
376
+ (when (and (finite-number? (:time frame)) (>= (:time frame) 0))
377
+ (swap! times conj (rounded-sample-time (:time frame)))))
378
+ (let [sorted (sort @times)]
379
+ ;; Add midpoints across short spans so overlap is capped between envelope
380
+ ;; keyframes, where the visual flapping usually appears.
381
+ (doseq [[start end] (map vector sorted (rest sorted))
382
+ :when (and (> end start) (<= (- end start) 0.12))]
383
+ (swap! times conj (rounded-sample-time (/ (+ start end) 2)))))
384
+ (->> @times sort vec)))
385
+
386
+ (defn fit-secondary-activation [active adjusted budget]
387
+ (let [secondary (vec (rest active))
388
+ secondary-sum (transduce (map :value) + 0 secondary)]
389
+ (if (or (empty? secondary) (<= secondary-sum budget))
390
+ adjusted
391
+ (let [scale (/ budget secondary-sum)]
392
+ (reduce (fn [result entry]
393
+ (assoc result (:key entry) (* (:value entry) scale)))
394
+ adjusted
395
+ secondary)))))
396
+
397
+ (defn trim-inactive-padding [curve]
398
+ (let [frames (vec curve)
399
+ first-active (first (keep-indexed (fn [idx frame]
400
+ (when (> (:intensity frame) intensity-eps) idx))
401
+ frames))]
402
+ (if (nil? first-active)
403
+ []
404
+ (let [last-active (loop [idx (dec (count frames))]
405
+ (cond
406
+ (neg? idx) idx
407
+ (> (:intensity (get frames idx)) intensity-eps) idx
408
+ :else (recur (dec idx))))
409
+ start (max 0 (dec first-active))
410
+ end (min (dec (count frames)) (inc last-active))]
411
+ (subvec frames start (inc end))))))
412
+
413
+ (defn limit-concurrent-lip-activation [curves]
414
+ ;; Stable Latticework intentionally does not let every overlapping viseme keep
415
+ ;; its full value. The strongest active shape leads, secondary shapes get a
416
+ ;; small budget, and closed-mouth bilabials suppress everything else.
417
+ (let [lip-keys (into [] (remove #(= % jaw-au)) (keys curves))]
418
+ (if (<= (count lip-keys) 1)
419
+ curves
420
+ (let [sample-times (collect-lip-sample-times curves)]
421
+ (if (empty? sample-times)
422
+ curves
423
+ (let [normalized (reduce (fn [result time]
424
+ (let [values (mapv (fn [key]
425
+ {:key key
426
+ :visemeId (js/parseInt key 10)
427
+ :value (state/clamp 0 lip-dominant-cap
428
+ (sample-curve-at (get curves key) time))})
429
+ lip-keys)
430
+ active (->> (into [] (filter #(> (:value %) intensity-eps)) values)
431
+ (sort-by :value >)
432
+ vec)
433
+ adjusted-a (into {} (map (fn [entry] [(:key entry) (:value entry)]) values))
434
+ adjusted-b (if (> (count active) 1)
435
+ (let [dominant (first active)]
436
+ (if (and (= (:visemeId dominant) (:B_M_P visemes/canonical-visemes))
437
+ (>= (:value dominant) closure-dominance-threshold))
438
+ (fit-secondary-activation active adjusted-a closure-secondary-cap)
439
+ (let [total (transduce (map :value) + 0 active)]
440
+ (if (> total lip-total-activation-cap)
441
+ (let [budget (max 0
442
+ (min (- lip-total-activation-cap (:value dominant))
443
+ (* (:value dominant) lip-secondary-ratio)
444
+ lip-secondary-cap))]
445
+ (fit-secondary-activation active adjusted-a budget))
446
+ adjusted-a))))
447
+ adjusted-a)]
448
+ (reduce (fn [acc key]
449
+ (update acc key conj {:time time
450
+ :intensity (get adjusted-b key 0)}))
451
+ result
452
+ lip-keys)))
453
+ (into {} (map (fn [key] [key []]) lip-keys))
454
+ sample-times)]
455
+ (into {} (map (fn [key]
456
+ [key (-> normalized
457
+ (get key)
458
+ deduplicate-curve
459
+ trim-inactive-padding
460
+ vec)])
461
+ lip-keys))))))))
462
+
463
+ (defn reduce-curve-keys [viseme-id curve]
464
+ (let [frames (vec curve)]
465
+ (if (<= (count frames) 3)
466
+ frames
467
+ (let [profile (envelope-profile viseme-id)
468
+ reduced (loop [index 1
469
+ result [(first frames)]]
470
+ (if (>= index (dec (count frames)))
471
+ result
472
+ (let [prev (last result)
473
+ curr (get frames index)
474
+ next-frame (get frames (inc index))
475
+ preserves-peak? (>= (:intensity curr) (- (:peak profile) 0.02))
476
+ preserves-closure? (and (= viseme-id (:B_M_P visemes/canonical-visemes))
477
+ (>= (:intensity curr) 0.98))
478
+ near-flat? (and (< (js/Math.abs (- (:intensity curr) (:intensity prev))) 0.015)
479
+ (< (js/Math.abs (- (:intensity next-frame) (:intensity curr))) 0.015))]
480
+ (recur (inc index)
481
+ (if (or (not near-flat?) preserves-peak? preserves-closure?)
482
+ (conj result curr)
483
+ result)))))]
484
+ (conj reduced (last frames))))))
485
+
486
+ (defn reduce-lip-keys [curves]
487
+ (into {}
488
+ (map (fn [[key curve]]
489
+ (let [ordered (->> curve (sort-by :time) deduplicate-curve vec)]
490
+ [key (if (= key jaw-au)
491
+ ordered
492
+ (reduce-curve-keys (js/parseInt key 10) ordered))])))
493
+ curves))
494
+
495
+ (defn build-jaw-curve [events jaw-scale]
496
+ (jaw/build-jaw-curve events jaw-scale))
497
+
498
+ (def snippet-counter (atom 0))
499
+
500
+ (defn next-snippet-name [prefix]
501
+ (str prefix "_" (.now js/Date) "_" (swap! snippet-counter inc)))
502
+
503
+ (defn text-snippet-name [text]
504
+ (let [words (->> (str/split (or text "") #"\s+")
505
+ (take 3)
506
+ (str/join "_")
507
+ str/lower-case
508
+ (#(str/replace % #"[^a-z_]" "")))]
509
+ (next-snippet-name (str "lipSync_" (if (pos? (count words)) words "timeline")))))
510
+
511
+ (defn build-curves [events config]
512
+ (let [raw-curves (reduce (fn [curves index]
513
+ (let [event (get events index)
514
+ next-event (get events (inc index))
515
+ key (str (:visemeId event))
516
+ curve (build-event-viseme-curve event
517
+ next-event
518
+ (:intensity config))]
519
+ (if (empty? curve)
520
+ curves
521
+ (update curves key #(if % (merge-curves % curve) curve)))))
522
+ {}
523
+ (range (count events)))
524
+ articulated (-> raw-curves
525
+ (apply-coarticulation events)
526
+ limit-concurrent-lip-activation
527
+ reduce-lip-keys)
528
+ jaw-curve (build-jaw-curve events (:jawScale config))
529
+ tongue-curves (tongue/build-tongue-curves events (:tongueScale config))
530
+ labiodental-curves (build-labiodental-curves events (:intensity config))
531
+ with-jaw (if (empty? jaw-curve)
532
+ articulated
533
+ (assoc articulated jaw-au jaw-curve))]
534
+ ;; Articulator AU curves are added after lip limiting because they are
535
+ ;; ordinary AU controls, not viseme slots competing for lip-shape budget.
536
+ (merge with-jaw labiodental-curves tongue-curves)))
537
+
538
+ (defn lipsync-channel-target [curve-key]
539
+ ;; LipSync owns its animation namespace explicitly. Numeric keys 0-14 are
540
+ ;; canonical viseme slots. The jaw curve is stored internally as "26" because
541
+ ;; the planner is conceptually FACS/JALI jaw activation, but it emits to Embody
542
+ ;; as a direct JAW bone channel. Emitting AU26 would also drive the CC4
543
+ ;; Jaw_Open morph, which can visually cover the separate viseme mouth shapes.
544
+ ;; Numeric keys above 14 that are not the jaw curve remain regular AUs for
545
+ ;; tongue/labiodental overlays.
546
+ (let [key (str curve-key)
547
+ numeric-id (when (re-matches #"^\d+$" key)
548
+ (js/parseInt key 10))]
549
+ (cond
550
+ (= key jaw-au)
551
+ {:type "bone" :id "JAW" :channel "rz" :maxDegrees 30}
552
+
553
+ (and (some? numeric-id) (<= 0 numeric-id 14))
554
+ {:type "viseme" :id numeric-id}
555
+
556
+ (some? numeric-id)
557
+ {:type "au" :id numeric-id}
558
+
559
+ :else
560
+ {:type "morph" :id key})))
561
+
562
+ (defn curves->channels [curves]
563
+ (into []
564
+ (map (fn [[curve-key curve]]
565
+ {:target (lipsync-channel-target curve-key)
566
+ :keyframes curve}))
567
+ curves))
568
+
569
+ (defn max-snippet-time [events curves]
570
+ (let [event-max (transduce (map #(/ (+ (:offsetMs %) (:durationMs %)) 1000))
571
+ max
572
+ 0
573
+ events)
574
+ curve-max (transduce (mapcat (fn [[_ curve]] (map :time curve)))
575
+ max
576
+ 0
577
+ curves)]
578
+ (max event-max curve-max)))
579
+
580
+ (defn build-lipsync-snippet
581
+ ([events config] (build-lipsync-snippet events config nil))
582
+ ([events config name]
583
+ (let [config (state/sanitize-config config)
584
+ normalized-events (normalize-events events)
585
+ snippet-name (or name (next-snippet-name "lipSync"))
586
+ curves (build-curves normalized-events config)
587
+ max-time (max-snippet-time normalized-events curves)]
588
+ {:name snippet-name
589
+ :snippetPriority (:priority config)
590
+ ;; Provider/text timing is already baked into the keyframe offsets. Keep
591
+ ;; playback at 1.0 so speech rate is not applied twice.
592
+ :snippetPlaybackRate 1
593
+ :snippetIntensityScale 1
594
+ :snippetJawScale (:jawScale config)
595
+ :autoVisemeJaw false
596
+ :loop false
597
+ :maxTime max-time
598
+ :curves curves
599
+ :channels (curves->channels curves)
600
+ :metadata {:agency "lipSync"
601
+ :visemeCount (count normalized-events)
602
+ :tongueCurveCount (count (select-keys curves tongue/tongue-au-keys))}})))
603
+
604
+ (defn build-text-snippet [text events config]
605
+ (build-lipsync-snippet events config (text-snippet-name text)))