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