@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,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))))}))
|