@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,429 @@
1
+ (ns polymer.lipsync.articulation.visemes
2
+ (:require [clojure.string :as str]
3
+ [polymer.lipsync.state :as state]
4
+ [polymer.nlp.phonemes :as phonemes]))
5
+
6
+ ;; Polymer keeps the canonical 15-slot CC4/ARKit viseme order beside the CLJS
7
+ ;; lip-sync code so numeric curves line up with Embody. This mirrors the
8
+ ;; installed Embody export:
9
+ ;; ["AE" "Ah" "B_M_P" "Ch_J" "EE" "Er" "F_V" "Ih" "K_G_H_NG" "Oh" "R"
10
+ ;; "S_Z" "T_L_D_N" "Th" "W_OO"].
11
+
12
+ (def canonical-visemes
13
+ {:AE 0
14
+ :Ah 1
15
+ :B_M_P 2
16
+ :Ch_J 3
17
+ :EE 4
18
+ :Er 5
19
+ :F_V 6
20
+ :Ih 7
21
+ :K_G_H_NG 8
22
+ :Oh 9
23
+ :R 10
24
+ :S_Z 11
25
+ :T_L_D_N 12
26
+ :Th 13
27
+ :W_OO 14})
28
+
29
+ (def jaw-activations
30
+ ;; JALI treats visible speech as two mostly independent axes: lip action and
31
+ ;; jaw action. These values are not baked into the viseme morphs. They are
32
+ ;; only the default jaw-axis target used when an input provider does not send
33
+ ;; a more specific jawActivation value for a phoneme/viseme event. They are
34
+ ;; intentionally lower than Embody's manual viseme jaw amounts: speech jaw is
35
+ ;; an animation performance parameter, not a full pose slider.
36
+ [0.48 0.55 0 0.22 0.14 0.24 0.06 0.14 0.24 0.40 0.24 0.06 0.22 0.10 0.34])
37
+
38
+ (defn jaw-activation-for-viseme [viseme-id]
39
+ (get jaw-activations viseme-id 0.3))
40
+
41
+ (declare normalize-phoneme)
42
+
43
+ (def vowels
44
+ #{"A" "E" "I" "O" "U"
45
+ "AA" "AE" "AH" "AO" "AW" "AX" "AY"
46
+ "EH" "ER" "EY"
47
+ "IH" "IX" "IY"
48
+ "OW" "OY"
49
+ "UH" "UW"})
50
+
51
+ (defn primary-class [classes]
52
+ (or (first classes) "default"))
53
+
54
+ (defn viseme-classes [viseme-id]
55
+ ;; These are coarse visual-speech classes, not a replacement for phoneme
56
+ ;; labels. Azure and other provider-only paths may only know a canonical
57
+ ;; viseme slot, so this function gives the later JALI planners enough class
58
+ ;; data to make conservative jaw/lip decisions.
59
+ (cond
60
+ (= viseme-id (:B_M_P canonical-visemes)) ["bilabial"]
61
+ (= viseme-id (:F_V canonical-visemes)) ["labiodental" "fricative"]
62
+ (= viseme-id (:S_Z canonical-visemes)) ["sibilant" "fricative"]
63
+ (= viseme-id (:Th canonical-visemes)) ["dental" "fricative"]
64
+ (= viseme-id (:Ch_J canonical-visemes)) ["obstruent" "tongue"]
65
+ (= viseme-id (:K_G_H_NG canonical-visemes)) ["obstruent" "tongue"]
66
+ (= viseme-id (:T_L_D_N canonical-visemes)) ["tongue"]
67
+ (= viseme-id (:R canonical-visemes)) ["liquid"]
68
+ (= viseme-id (:W_OO canonical-visemes)) ["glide" "lip-heavy"]
69
+ (contains? #{(:AE canonical-visemes)
70
+ (:Ah canonical-visemes)
71
+ (:EE canonical-visemes)
72
+ (:Er canonical-visemes)
73
+ (:Ih canonical-visemes)
74
+ (:Oh canonical-visemes)}
75
+ viseme-id) ["vowel"]
76
+ :else ["default"]))
77
+
78
+ (defn phoneme-classes [phoneme viseme-id]
79
+ ;; JALI's later jaw/lip planners need class metadata in addition to the
80
+ ;; phoneme and canonical viseme slot. A phoneme can belong to multiple classes:
81
+ ;; for example M is both bilabial and nasal, while F is labiodental and
82
+ ;; fricative. Keep this data on every event so planner rules do not have to
83
+ ;; reverse-engineer intent from a lossy viseme ID.
84
+ (let [normalized (normalize-phoneme phoneme)]
85
+ (cond
86
+ (str/starts-with? (or phoneme "") "PAUSE_") ["pause"]
87
+ (contains? vowels normalized) ["vowel"]
88
+ (contains? #{"P" "B"} normalized) ["bilabial" "obstruent"]
89
+ (= "M" normalized) ["bilabial" "nasal"]
90
+ (contains? #{"F" "V"} normalized) ["labiodental" "fricative"]
91
+ (contains? #{"S" "Z" "SH" "ZH"} normalized) ["sibilant" "fricative"]
92
+ (contains? #{"TH" "DH"} normalized) ["dental" "fricative"]
93
+ (contains? #{"T" "D" "K" "G" "CH" "JH"} normalized) ["obstruent" "tongue"]
94
+ (contains? #{"N" "NG"} normalized) ["nasal" "tongue"]
95
+ (contains? #{"L"} normalized) ["liquid" "tongue"]
96
+ (contains? #{"R"} normalized) ["liquid"]
97
+ (contains? #{"W"} normalized) ["glide" "lip-heavy"]
98
+ (contains? #{"Y" "H" "HH"} normalized) ["glide"]
99
+ :else (viseme-classes viseme-id))))
100
+
101
+ (defn jaw-activation-for-phoneme [phoneme viseme-id]
102
+ ;; The lip target and the jaw target are allowed to disagree. For example,
103
+ ;; HH borrows the Ah lip family but should not open the jaw like a strong Ah
104
+ ;; vowel. This keeps Web Speech fallback from flapping the jaw on consonants.
105
+ (let [normalized (normalize-phoneme phoneme)]
106
+ (cond
107
+ (contains? #{"P" "B" "M"} normalized) 0
108
+ (contains? #{"F" "V" "S" "Z" "SH" "ZH" "TH" "DH"} normalized) 0.06
109
+ (contains? #{"T" "D" "N" "L" "K" "G" "NG" "CH" "JH"} normalized) 0.16
110
+ (contains? #{"H" "HH" "Y" "W" "R"} normalized) 0.18
111
+ :else (jaw-activation-for-viseme viseme-id))))
112
+
113
+ (def phoneme->viseme
114
+ {"sil" (:B_M_P canonical-visemes)
115
+ "pau" (:B_M_P canonical-visemes)
116
+ "PAUSE" (:B_M_P canonical-visemes)
117
+ "A" (:Ah canonical-visemes)
118
+ "E" (:EE canonical-visemes)
119
+ "I" (:Ih canonical-visemes)
120
+ "O" (:Oh canonical-visemes)
121
+ "U" (:W_OO canonical-visemes)
122
+ "0" (:Th canonical-visemes)
123
+ "X" (:S_Z canonical-visemes)
124
+ "J" (:Ch_J canonical-visemes)
125
+ "AE" (:AE canonical-visemes)
126
+ "AX" (:Ah canonical-visemes)
127
+ "AH" (:Ah canonical-visemes)
128
+ "AA" (:Ah canonical-visemes)
129
+ "AO" (:Oh canonical-visemes)
130
+ "EY" (:EE canonical-visemes)
131
+ "EH" (:EE canonical-visemes)
132
+ "UH" (:W_OO canonical-visemes)
133
+ "ER" (:Er canonical-visemes)
134
+ "Y" (:Ih canonical-visemes)
135
+ "IY" (:EE canonical-visemes)
136
+ "IH" (:Ih canonical-visemes)
137
+ "IX" (:Ih canonical-visemes)
138
+ "W" (:W_OO canonical-visemes)
139
+ "UW" (:W_OO canonical-visemes)
140
+ "OW" (:Oh canonical-visemes)
141
+ "AW" (:Ah canonical-visemes)
142
+ "OY" (:Oh canonical-visemes)
143
+ "AY" (:Ah canonical-visemes)
144
+ "H" (:Ah canonical-visemes)
145
+ "HH" (:Ah canonical-visemes)
146
+ "R" (:R canonical-visemes)
147
+ "L" (:T_L_D_N canonical-visemes)
148
+ "S" (:S_Z canonical-visemes)
149
+ "Z" (:S_Z canonical-visemes)
150
+ "SH" (:S_Z canonical-visemes)
151
+ "CH" (:Ch_J canonical-visemes)
152
+ "JH" (:Ch_J canonical-visemes)
153
+ "ZH" (:S_Z canonical-visemes)
154
+ "TH" (:Th canonical-visemes)
155
+ "DH" (:Th canonical-visemes)
156
+ "F" (:F_V canonical-visemes)
157
+ "V" (:F_V canonical-visemes)
158
+ "D" (:T_L_D_N canonical-visemes)
159
+ "T" (:T_L_D_N canonical-visemes)
160
+ "N" (:T_L_D_N canonical-visemes)
161
+ "K" (:K_G_H_NG canonical-visemes)
162
+ "G" (:K_G_H_NG canonical-visemes)
163
+ "NG" (:K_G_H_NG canonical-visemes)
164
+ "P" (:B_M_P canonical-visemes)
165
+ "B" (:B_M_P canonical-visemes)
166
+ "M" (:B_M_P canonical-visemes)})
167
+
168
+ (def phoneme-durations
169
+ {"A" 50 "E" 45 "I" 40 "O" 55 "U" 50
170
+ "0" 35 "X" 45 "J" 40
171
+ "P" 25 "B" 25 "T" 20 "D" 20 "K" 30 "G" 30
172
+ "F" 35 "V" 35 "S" 40 "Z" 40 "SH" 45 "ZH" 45
173
+ "TH" 35 "DH" 35 "H" 30 "HH" 30
174
+ "CH" 40 "JH" 40
175
+ "M" 35 "N" 35 "NG" 40
176
+ "L" 40 "R" 40
177
+ "W" 35 "Y" 30
178
+ "IY" 50 "EY" 60 "UW" 50 "OW" 60 "AO" 55
179
+ "IH" 40 "EH" 45 "UH" 45 "AH" 45 "AX" 35
180
+ "AY" 65 "AW" 65 "OY" 70
181
+ "ER" 50 "AA" 55 "AE" 55
182
+ "IX" 30})
183
+
184
+ (def fallback-text-duration-scale 2)
185
+
186
+ (def web-speech-word-floor-ms 360)
187
+ (def web-speech-char-floor-ms 65)
188
+
189
+ (def pause-durations
190
+ {"PAUSE_SPACE" 0
191
+ "PAUSE_COMMA" 50
192
+ "PAUSE_PERIOD" 100
193
+ "PAUSE_QUESTION" 100
194
+ "PAUSE_EXCLAMATION" 100
195
+ "PAUSE_SEMICOLON" 75
196
+ "PAUSE_COLON" 75})
197
+
198
+ (def diphthong-targets
199
+ {;; Web Speech fallback starts from text, so it does not get provider
200
+ ;; phoneme timing. Treat English diphthongs as internal lip travel inside
201
+ ;; one vocalic jaw gesture: the lips move through two targets, but AU26
202
+ ;; should stay on the same jaw arc instead of flapping twice.
203
+ "EY" [(:AE canonical-visemes) (:EE canonical-visemes)]
204
+ "OW" [(:Oh canonical-visemes) (:W_OO canonical-visemes)]
205
+ "AW" [(:Ah canonical-visemes) (:W_OO canonical-visemes)]
206
+ "OY" [(:Oh canonical-visemes) (:EE canonical-visemes)]
207
+ "AY" [(:Ah canonical-visemes) (:Ih canonical-visemes)]})
208
+
209
+ (def diphthong-min-duration-ms 80)
210
+ (def diphthong-secondary-min-ms 36)
211
+
212
+ (def pause-token phonemes/pause-token)
213
+ (def tokenize phonemes/tokenize)
214
+
215
+ (defn normalize-phoneme [phoneme]
216
+ (-> (or phoneme "") str/upper-case (str/replace #"[0-9]" "")))
217
+
218
+ (defn vowel? [phoneme]
219
+ (contains? vowels (normalize-phoneme phoneme)))
220
+
221
+ (defn phoneme->viseme-duration [phoneme]
222
+ (cond
223
+ (phonemes/unresolved-token? phoneme)
224
+ ;; An unresolved pronunciation must retain utterance/word timing without
225
+ ;; inventing a lip, jaw, or tongue pose. Snippet assembly recognizes this
226
+ ;; neutral marker and advances time without creating an effector channel.
227
+ {:phoneme phoneme
228
+ :phonemeClass "pause"
229
+ :phonemeClasses ["unknown" "pause"]
230
+ :viseme (:B_M_P canonical-visemes)
231
+ :jawActivation 0
232
+ :neutral true
233
+ :duration (max web-speech-word-floor-ms
234
+ (* web-speech-char-floor-ms
235
+ (phonemes/unresolved-token-length phoneme)))}
236
+
237
+ (str/starts-with? (or phoneme "") "PAUSE_")
238
+ (let [classes (phoneme-classes phoneme (:B_M_P canonical-visemes))]
239
+ {:phoneme phoneme
240
+ :phonemeClass (primary-class classes)
241
+ :phonemeClasses classes
242
+ :viseme (:B_M_P canonical-visemes)
243
+ :jawActivation 0
244
+ :duration (get pause-durations phoneme 300)})
245
+
246
+ :else
247
+ (let [normalized (normalize-phoneme phoneme)
248
+ viseme-id (get phoneme->viseme normalized (:B_M_P canonical-visemes))
249
+ classes (phoneme-classes normalized viseme-id)
250
+ fallback-duration (if (vowel? normalized) 50 35)]
251
+ {:phoneme normalized
252
+ :phonemeClass (primary-class classes)
253
+ :phonemeClasses classes
254
+ :viseme viseme-id
255
+ :jawActivation (jaw-activation-for-phoneme normalized viseme-id)
256
+ :duration (get phoneme-durations normalized fallback-duration)})))
257
+
258
+ (defn event-classes-for-target [phoneme viseme-id diphthong?]
259
+ ;; A diphthong is one phoneme with internal lip travel. Keep the phoneme label
260
+ ;; stable, but classify each generated target by the target viseme so the jaw
261
+ ;; and lip planners know whether the segment is open, rounded, or spread.
262
+ (let [classes (if diphthong?
263
+ (vec (distinct (conj (viseme-classes viseme-id) "diphthong")))
264
+ (phoneme-classes phoneme viseme-id))]
265
+ {:phonemeClass (primary-class classes)
266
+ :phonemeClasses classes}))
267
+
268
+ (defn diphthong-segments [phoneme viseme-id scaled-duration]
269
+ (let [targets (get diphthong-targets phoneme)]
270
+ (if (and targets (>= scaled-duration diphthong-min-duration-ms))
271
+ (let [[first-viseme second-viseme] targets
272
+ second-offset-ms (min (- scaled-duration diphthong-secondary-min-ms)
273
+ (* scaled-duration 0.55))
274
+ first-duration-ms (max diphthong-secondary-min-ms
275
+ (min scaled-duration
276
+ (+ second-offset-ms (* scaled-duration 0.22))))
277
+ second-duration-ms (max diphthong-secondary-min-ms
278
+ (- scaled-duration second-offset-ms))]
279
+ [{:viseme first-viseme
280
+ :relativeOffsetMs 0
281
+ :durationMs first-duration-ms
282
+ :diphthong true}
283
+ {:viseme second-viseme
284
+ :relativeOffsetMs second-offset-ms
285
+ :durationMs second-duration-ms
286
+ :diphthong true}])
287
+ [{:viseme viseme-id
288
+ :relativeOffsetMs 0
289
+ :durationMs scaled-duration
290
+ :diphthong false}])))
291
+
292
+ (defn phoneme-event [phoneme current-ms segment neutral?]
293
+ (let [viseme-id (:viseme segment)
294
+ class-data (event-classes-for-target phoneme viseme-id (:diphthong segment))]
295
+ (cond->
296
+ {:visemeId viseme-id
297
+ :phoneme phoneme
298
+ :phonemeClass (if neutral? "pause" (:phonemeClass class-data))
299
+ :phonemeClasses (if neutral? ["unknown" "pause"] (:phonemeClasses class-data))
300
+ :jawActivation (if neutral?
301
+ 0
302
+ (if (:diphthong segment)
303
+ (jaw-activation-for-viseme viseme-id)
304
+ (jaw-activation-for-phoneme phoneme viseme-id)))
305
+ :offsetMs (+ current-ms (:relativeOffsetMs segment))
306
+ :durationMs (:durationMs segment)}
307
+ neutral? (assoc :neutral true))))
308
+
309
+ (defn adjust-duration [duration speech-rate]
310
+ (js/Math.round (/ (* duration fallback-text-duration-scale)
311
+ (state/clamp 0.2 3 (state/number-or speech-rate 1)))))
312
+
313
+ (defn phonemes->visemes
314
+ ([phonemes] (phonemes->visemes phonemes 0 1))
315
+ ([phonemes start-ms speech-rate]
316
+ (loop [remaining phonemes
317
+ current-ms start-ms
318
+ events []]
319
+ (if (empty? remaining)
320
+ events
321
+ (let [{:keys [phoneme viseme duration neutral]} (phoneme->viseme-duration (first remaining))
322
+ scaled-duration (adjust-duration duration speech-rate)]
323
+ (if (<= scaled-duration 0)
324
+ (recur (rest remaining) current-ms events)
325
+ (recur (rest remaining)
326
+ (+ current-ms scaled-duration)
327
+ (into events
328
+ (map #(phoneme-event phoneme current-ms % neutral))
329
+ (diphthong-segments phoneme viseme scaled-duration)))))))))
330
+
331
+ (defn word->visemes
332
+ ([word] (word->visemes nil word 0 1))
333
+ ([word start-ms speech-rate]
334
+ (word->visemes nil word start-ms speech-rate))
335
+ ([dictionary word start-ms speech-rate]
336
+ (phonemes->visemes (phonemes/word->phonemes dictionary word)
337
+ start-ms
338
+ speech-rate)))
339
+
340
+ (defn text->visemes
341
+ ([text] (text->visemes nil text 1))
342
+ ([text speech-rate]
343
+ (text->visemes nil text speech-rate))
344
+ ([dictionary text speech-rate]
345
+ (phonemes->visemes (phonemes/text->phonemes dictionary text)
346
+ 0
347
+ speech-rate)))
348
+
349
+ (defn phonemes-duration-ms [phonemes speech-rate]
350
+ (transduce
351
+ (map (fn [phoneme]
352
+ (adjust-duration (:duration (phoneme->viseme-duration phoneme)) speech-rate)))
353
+ +
354
+ 0
355
+ phonemes))
356
+
357
+ (defn word-tokens [text]
358
+ (vec (re-seq #"[A-Za-z]+" (or text ""))))
359
+
360
+ (defn word-char-count [words]
361
+ (transduce (map count) + 0 words))
362
+
363
+ (defn timeline-duration-ms [events]
364
+ (transduce (map #(+ (:offsetMs %) (:durationMs %))) max 0 events))
365
+
366
+ (defn scale-event [scale event]
367
+ (-> event
368
+ (update :offsetMs #(js/Math.round (* scale %)))
369
+ (update :durationMs #(max 1 (js/Math.round (* scale %))))))
370
+
371
+ (defn scale-events [events scale]
372
+ (into [] (map #(scale-event scale %)) events))
373
+
374
+ (defn scale-word-timings [word-timings scale]
375
+ (into []
376
+ (map (fn [timing]
377
+ (-> timing
378
+ (update :startSec #(* scale %))
379
+ (update :endSec #(* scale %)))))
380
+ word-timings))
381
+
382
+ (defn web-speech-duration-ms
383
+ "Estimate a Web Speech utterance floor from text shape, not just phoneme count.
384
+
385
+ Browser Web Speech does not expose provider viseme timing or total audio
386
+ duration. Short function words are especially under-estimated by pure phoneme
387
+ sums, so use a conservative word/character floor and keep the longer of that
388
+ floor and the phoneme-derived timeline. Boundary events can still correct
389
+ drift later, but they should not immediately clamp a too-short phrase snippet
390
+ to its end."
391
+ [text speech-rate base-duration-ms]
392
+ (let [rate (state/clamp 0.2 3 (state/number-or speech-rate 1))
393
+ words (word-tokens text)
394
+ word-floor (/ (* (count words) web-speech-word-floor-ms) rate)
395
+ char-floor (/ (* (word-char-count words) web-speech-char-floor-ms) rate)
396
+ floor-ms (max word-floor char-floor)]
397
+ (js/Math.round (max base-duration-ms floor-ms))))
398
+
399
+ (defn text->word-timings
400
+ "Estimate word timing from the same deterministic text planner used for
401
+ fallback Web Speech visemes. Provider timings are still preferred, but Web
402
+ Speech only gives boundary callbacks while speaking; this gives those
403
+ callbacks an expected timeline for drift correction."
404
+ ([text] (text->word-timings nil text 1))
405
+ ([text speech-rate]
406
+ (text->word-timings nil text speech-rate))
407
+ ([dictionary text speech-rate]
408
+ (loop [pronunciations (phonemes/text-pronunciations dictionary text)
409
+ current-ms 0
410
+ timings []]
411
+ (if (empty? pronunciations)
412
+ timings
413
+ (let [{:keys [word phonemes source]} (first pronunciations)]
414
+ (cond
415
+ (not= source "punctuation")
416
+ (let [duration-ms (phonemes-duration-ms phonemes speech-rate)
417
+ end-ms (+ current-ms duration-ms)]
418
+ (recur (rest pronunciations)
419
+ end-ms
420
+ (conj timings {:word word
421
+ :startSec (/ current-ms 1000)
422
+ :endSec (/ end-ms 1000)})))
423
+
424
+ (= source "punctuation")
425
+ (let [duration-ms (phonemes-duration-ms phonemes speech-rate)]
426
+ (recur (rest pronunciations) (+ current-ms duration-ms) timings))
427
+
428
+ :else
429
+ (recur (rest pronunciations) current-ms timings)))))))
@@ -0,0 +1,132 @@
1
+ (ns polymer.lipsync.goap)
2
+
3
+ ;; LipSync GOAP is deliberately small and data-only. It answers one question:
4
+ ;; given this LipSync command and the current agency facts, which deterministic
5
+ ;; mouth-animation steps should run, or why should the command stop before
6
+ ;; changing state/scheduling animation?
7
+
8
+ (def supported-command-types
9
+ ;; Keep the accepted command vocabulary in one place so a command can be
10
+ ;; rejected before state mutation, scheduling, or drift correction starts.
11
+ #{"configure"
12
+ "startText"
13
+ "startTimeline"
14
+ "processAzureVisemes"
15
+ "wordBoundary"
16
+ "audioStarted"
17
+ "audioTime"
18
+ "updateWordTimings"
19
+ "stop"
20
+ "reset"})
21
+
22
+ (defn present-string?
23
+ [value]
24
+ (and (string? value) (pos? (count value))))
25
+
26
+ (defn has-visemes?
27
+ [value]
28
+ (boolean (seq value)))
29
+
30
+ (defn command-goal
31
+ "Normalize an incoming LipSync command into a planner goal."
32
+ [command world]
33
+ ;; The goal is intentionally just facts. It should stay cheap to inspect in
34
+ ;; tests/devtools and should never close over JS handles, timers, audio, or
35
+ ;; engine objects.
36
+ (let [type (:type command)
37
+ timeline (:timeline command)]
38
+ {:type type
39
+ :speaking (:speaking world)
40
+ :hasText (present-string? (:text command))
41
+ :hasTimelineVisemes (has-visemes? (:visemes timeline))
42
+ :hasProviderVisemes (has-visemes? (:visemes command))
43
+ :hasWord (present-string? (:word command))
44
+ :hasWordTimings (has-visemes? (:wordTimings command))}))
45
+
46
+ (defn failure-step
47
+ "Return the first reason a LipSync command is invalid, or nil when runnable."
48
+ [goal]
49
+ (cond
50
+ (not (contains? supported-command-types (:type goal)))
51
+ {:op "fail" :reason "unsupported-command" :commandType (:type goal)}
52
+
53
+ (and (= "startText" (:type goal)) (not (:hasText goal)))
54
+ {:op "fail" :reason "missing-text"}
55
+
56
+ (and (= "startTimeline" (:type goal)) (not (:hasTimelineVisemes goal)))
57
+ {:op "fail" :reason "missing-timeline-visemes"}
58
+
59
+ (and (= "processAzureVisemes" (:type goal)) (not (:hasProviderVisemes goal)))
60
+ {:op "fail" :reason "missing-provider-visemes"}
61
+
62
+ (and (= "wordBoundary" (:type goal)) (not (:hasWord goal)))
63
+ {:op "fail" :reason "missing-word"}
64
+
65
+ :else nil))
66
+
67
+ (defn command-steps
68
+ "Return ordered pure/domain steps for a LipSync command."
69
+ [goal]
70
+ ;; These steps are an audit trail and validation boundary, not a hidden
71
+ ;; interpreter. The agency still executes explicit functions for each command.
72
+ ;; Future inter-agency constraints can extend this data without moving runtime
73
+ ;; side effects into the planner.
74
+ (if-let [failure (failure-step goal)]
75
+ [failure]
76
+ (case (:type goal)
77
+ "configure" [{:op "apply-config"}]
78
+ "startText" [{:op "plan-text-visemes"}
79
+ {:op "build-articulated-snippet"}
80
+ {:op "schedule-animation"}]
81
+ "startTimeline" [{:op "normalize-timeline"}
82
+ {:op "build-articulated-snippet"}
83
+ {:op "schedule-animation"}]
84
+ "processAzureVisemes" [{:op "normalize-provider-visemes"}
85
+ {:op "map-provider-visemes"}
86
+ {:op "build-articulated-snippet"}
87
+ {:op "schedule-animation"}]
88
+ "wordBoundary" [{:op "record-word-boundary"}
89
+ {:op "correct-drift-if-needed"}]
90
+ "audioStarted" [{:op "align-to-audio-clock"}]
91
+ "audioTime" [{:op "correct-to-audio-clock"}]
92
+ "updateWordTimings" [{:op "normalize-word-timings"}]
93
+ "stop" [{:op "stop-active-timeline"}]
94
+ "reset" [{:op "reset-state"}])))
95
+
96
+ (defn plan-ok?
97
+ [steps]
98
+ (not= "fail" (:op (first steps))))
99
+
100
+ (defn command-actions
101
+ "Return the small executable action list for a valid LipSync command.
102
+
103
+ The `command-steps` plan above is the semantic audit trail: what goal the
104
+ agency believes it is pursuing. These action maps are intentionally narrower:
105
+ they name the agency-local operation that may mutate state, schedule the
106
+ accepted snippet, or touch the active audio-alignment clock."
107
+ [goal]
108
+ (case (:type goal)
109
+ "configure" [{:op "configure"}]
110
+ "startText" [{:op "start-text"}]
111
+ "startTimeline" [{:op "start-timeline"}]
112
+ "processAzureVisemes" [{:op "process-provider-visemes"}]
113
+ "wordBoundary" [{:op "record-word-boundary"}]
114
+ "audioStarted" [{:op "align-audio-start"}]
115
+ "audioTime" [{:op "align-audio-time"}]
116
+ "updateWordTimings" [{:op "update-word-timings"}]
117
+ "stop" [{:op "stop" :reason "requested" :remove? true}]
118
+ "reset" [{:op "stop" :reason "reset" :remove? true}
119
+ {:op "reset-state"}]
120
+ []))
121
+
122
+ (defn plan-command
123
+ "Build the auditable LipSync plan for one incoming command."
124
+ [command world]
125
+ (let [goal (command-goal command world)
126
+ steps (command-steps goal)
127
+ ok? (plan-ok? steps)]
128
+ {:goal goal
129
+ :steps steps
130
+ :actions (when ok?
131
+ (command-actions goal))
132
+ :ok ok?}))
@@ -0,0 +1,51 @@
1
+ (ns polymer.lipsync.pronunciation.effector)
2
+
3
+ ;; Loading the full pronunciation lexicon is module I/O, so it stays behind
4
+ ;; LipSync's scheduler instead of appearing in pure phoneme transforms. The
5
+ ;; promise is cached per agency; the browser also caches the imported module.
6
+ (def package-loader
7
+ (aget js/globalThis "__polymerLoadPronunciationDictionary"))
8
+
9
+ ;; The connector only needs to bridge module initialization. Capture it in this
10
+ ;; namespace and remove the temporary global immediately.
11
+ (js-delete js/globalThis "__polymerLoadPronunciationDictionary")
12
+
13
+ (defn runtime-value
14
+ [runtime key]
15
+ (cond
16
+ (map? runtime) (get runtime key)
17
+ runtime (aget runtime (name key))
18
+ :else nil))
19
+
20
+ (defn create-effector
21
+ [runtime]
22
+ (let [injected-dictionary (runtime-value runtime :pronunciationDictionary)
23
+ injected-loader (runtime-value runtime :loadPronunciationDictionary)
24
+ load-promise (atom nil)]
25
+ {:peek (fn [] injected-dictionary)
26
+ :load
27
+ (fn []
28
+ (if injected-dictionary
29
+ injected-dictionary
30
+ (or @load-promise
31
+ (let [load-result
32
+ (if injected-loader
33
+ (injected-loader)
34
+ ;; shadow-cljs.edn defines this one-line ESM connector
35
+ ;; after Closure compilation. Everything beyond the
36
+ ;; import primitive remains owned by this CLJS effector.
37
+ (package-loader))
38
+ pending
39
+ (-> (.resolve js/Promise load-result)
40
+ (.then
41
+ (fn [module]
42
+ (or (aget module "dictionary")
43
+ (some-> (aget module "default")
44
+ (aget "dictionary"))
45
+ module)))
46
+ (.catch
47
+ (fn [error]
48
+ (reset! load-promise nil)
49
+ (throw error))))]
50
+ (reset! load-promise pending)
51
+ pending))))}))