@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,290 @@
1
+ (ns polymer.tts.azure
2
+ (:require [clojure.string :as str]
3
+ [polymer.lipsync.state :as state]
4
+ [polymer.lipsync.articulation.visemes :as visemes]))
5
+
6
+ ;; Azure Speech emits SAPI-style viseme IDs 0-21. Embody expects the
7
+ ;; canonical 15-slot CC4/ARKit order. This namespace is pure normalization:
8
+ ;; provider event data in, Polymer viseme timeline data out.
9
+ ;;
10
+ ;; This lives under TTS, not LipSync articulation, because Azure IDs are provider
11
+ ;; facts. The namespace imports LipSync's canonical viseme definitions only to
12
+ ;; output the provider-neutral timeline that LipSync can articulate like any
13
+ ;; other text/timeline source.
14
+
15
+ (def azure->canonical
16
+ {0 (:B_M_P visemes/canonical-visemes)
17
+ 1 (:AE visemes/canonical-visemes)
18
+ 2 (:Ah visemes/canonical-visemes)
19
+ 3 (:Oh visemes/canonical-visemes)
20
+ 4 (:Ih visemes/canonical-visemes)
21
+ 5 (:Er visemes/canonical-visemes)
22
+ 6 (:Ih visemes/canonical-visemes)
23
+ 7 (:W_OO visemes/canonical-visemes)
24
+ 8 (:Oh visemes/canonical-visemes)
25
+ 9 (:Ah visemes/canonical-visemes)
26
+ 10 (:Oh visemes/canonical-visemes)
27
+ 11 (:Ah visemes/canonical-visemes)
28
+ 12 (:K_G_H_NG visemes/canonical-visemes)
29
+ 13 (:R visemes/canonical-visemes)
30
+ 14 (:T_L_D_N visemes/canonical-visemes)
31
+ 15 (:S_Z visemes/canonical-visemes)
32
+ 16 (:Ch_J visemes/canonical-visemes)
33
+ 17 (:Th visemes/canonical-visemes)
34
+ 18 (:F_V visemes/canonical-visemes)
35
+ 19 (:T_L_D_N visemes/canonical-visemes)
36
+ 20 (:K_G_H_NG visemes/canonical-visemes)
37
+ 21 (:B_M_P visemes/canonical-visemes)})
38
+
39
+ (def min-duration-ms
40
+ {:silence 0 :bilabial 55 :vowel 120 :fricative 80 :tongue 80 :liquid 90 :glide 100 :default 85})
41
+
42
+ (def max-duration-ms
43
+ {:silence 0 :bilabial 110 :vowel 220 :fricative 150 :tongue 140 :liquid 160 :glide 180 :default 150})
44
+
45
+ (def max-overlap-ms
46
+ {:silence 0 :bilabial 8 :vowel 90 :fricative 45 :tongue 45 :liquid 60 :glide 65 :default 45})
47
+
48
+ (def duplicate-window-ms 35)
49
+ (def diphthong-min-duration-ms 85)
50
+ (def diphthong-secondary-min-ms 38)
51
+
52
+ (def azure-vowels
53
+ #{(:AE visemes/canonical-visemes)
54
+ (:Ah visemes/canonical-visemes)
55
+ (:EE visemes/canonical-visemes)
56
+ (:Er visemes/canonical-visemes)
57
+ (:Ih visemes/canonical-visemes)
58
+ (:Oh visemes/canonical-visemes)
59
+ (:W_OO visemes/canonical-visemes)})
60
+
61
+ (defn normalized-word [word]
62
+ (-> (or word "") str/lower-case (str/replace #"[^a-z]" "")))
63
+
64
+ (defn viseme-class [provider-id canonical-id]
65
+ (cond
66
+ (= provider-id 0) :silence
67
+ (= canonical-id (:B_M_P visemes/canonical-visemes)) :bilabial
68
+ (contains? azure-vowels canonical-id) (if (= canonical-id (:W_OO visemes/canonical-visemes)) :glide :vowel)
69
+ (#{(:Ch_J visemes/canonical-visemes)
70
+ (:F_V visemes/canonical-visemes)
71
+ (:S_Z visemes/canonical-visemes)
72
+ (:Th visemes/canonical-visemes)} canonical-id) :fricative
73
+ (#{(:K_G_H_NG visemes/canonical-visemes)
74
+ (:T_L_D_N visemes/canonical-visemes)} canonical-id) :tongue
75
+ (= canonical-id (:R visemes/canonical-visemes)) :liquid
76
+ :else :default))
77
+
78
+ (defn normalize-provider-viseme [event]
79
+ (let [id (or (:visemeId event) (:viseme_id event) (:id event) 0)
80
+ time (or (:time event) (:audio_offset event) (:audioOffset event) 0)]
81
+ (when (state/finite-number? time)
82
+ {:visemeId id
83
+ :time time})))
84
+
85
+ (defn normalize-provider-visemes [events]
86
+ ;; This is the one place a local transducer is useful: normalize each provider
87
+ ;; row and drop malformed rows in one pass. Sorting is intentionally outside
88
+ ;; the transducer because ordering is a whole-collection operation.
89
+ (->> (into [] (keep normalize-provider-viseme) (or events []))
90
+ (sort-by :time)
91
+ vec))
92
+
93
+ (defn normalize-azure-synthesis
94
+ "Return the pure provider packet TTS uses after Azure synthesis.
95
+
96
+ Audio fields are normalized here for playback. Visemes and word timings stay
97
+ raw until the Azure provider mapper and LipSync agency normalize them into a
98
+ provider-neutral animation timeline."
99
+ [payload]
100
+ {:audioBase64 (or (:audio_base64 payload) (:audioBase64 payload))
101
+ :audioFormat (or (:audio_format payload) (:audioFormat payload))
102
+ :durationSec (state/number-or (or (:durationSec payload)
103
+ (:duration_sec payload)
104
+ (:duration payload))
105
+ 0)
106
+ :visemes (:visemes payload)
107
+ :wordTimings (or (:word_boundaries payload)
108
+ (:wordTimings payload))})
109
+
110
+ (defn azure-synthesis->lipsync-command
111
+ "Build the LipSync command from a normalized Azure synthesis packet."
112
+ [snippet-name text payload config]
113
+ {:type "processAzureVisemes"
114
+ :name snippet-name
115
+ :text text
116
+ :source "azure"
117
+ :visemes (:visemes payload)
118
+ :wordTimings (or (:wordTimings payload)
119
+ (:word_timings payload)
120
+ (:word_boundaries payload))
121
+ :totalDurationMs (js/Math.round (* (state/number-or (:durationSec payload) 0) 1000))
122
+ :options {:visualLeadMs (:visualLeadMs config)}})
123
+
124
+ (defn word-start-sec [word]
125
+ (state/number-or (or (:startSec word) (:start word) (:start_time word)) 0))
126
+
127
+ (defn word-end-sec [word]
128
+ (state/number-or (or (:endSec word) (:end word) (:end_time word)) (word-start-sec word)))
129
+
130
+ (defn find-word-at-time [time-sec word-timings]
131
+ (some (fn [word]
132
+ (when (and (>= time-sec (- (word-start-sec word) 0.02))
133
+ (<= time-sec (+ (word-end-sec word) 0.02)))
134
+ word))
135
+ word-timings))
136
+
137
+ (defn long-e-word? [word]
138
+ (or (boolean (re-find #"(?:ee|ea|ie|ei)" word))
139
+ (contains? #{"we" "me" "be" "he" "she" "see"} word)
140
+ (str/ends-with? word "y")))
141
+
142
+ (defn rounded-back? [event-time-sec word]
143
+ (let [text (normalized-word (:word word))]
144
+ (and (pos? (count text))
145
+ (boolean (re-find #"(?:oo|ew|ue|ui|ough|ow|oa|oe|ose|ole|old|own|o)$" text))
146
+ (let [start-sec (word-start-sec word)
147
+ end-sec (word-end-sec word)
148
+ duration-sec (max 0.001 (- end-sec start-sec))
149
+ progress (state/clamp 0 1 (/ (- event-time-sec start-sec) duration-sec))]
150
+ (>= progress 0.35)))))
151
+
152
+ (defn dental-th? [event-time-sec word]
153
+ (let [text (normalized-word (:word word))]
154
+ (and (str/includes? text "th")
155
+ (let [start-sec (word-start-sec word)
156
+ end-sec (word-end-sec word)
157
+ duration-sec (max 0.001 (- end-sec start-sec))
158
+ progress (state/clamp 0 1 (/ (- event-time-sec start-sec) duration-sec))
159
+ positions (loop [index (str/index-of text "th")
160
+ result []]
161
+ (if (nil? index)
162
+ result
163
+ (recur (str/index-of text "th" (inc index)) (conj result index))))]
164
+ (some (fn [index]
165
+ (let [th-progress (if (<= (count text) 2) 0 (/ index (max 1 (- (count text) 2))))
166
+ starts-word? (= index 0)
167
+ ends-word? (>= index (- (count text) 2))]
168
+ (or (and starts-word? (<= progress 0.45))
169
+ (and ends-word? (>= progress 0.55))
170
+ (<= (js/Math.abs (- progress th-progress)) 0.22))))
171
+ positions)))))
172
+
173
+ (defn refine-for-word [provider-id canonical-id event-time-sec word]
174
+ (let [text (normalized-word (:word word))]
175
+ (cond
176
+ (= provider-id 0) nil
177
+ (and (= provider-id 6) (long-e-word? text)) (:EE visemes/canonical-visemes)
178
+ (and (= provider-id 4) (rounded-back? event-time-sec word)) (:W_OO visemes/canonical-visemes)
179
+ (and (= provider-id 19) (dental-th? event-time-sec word)) (:Th visemes/canonical-visemes)
180
+ :else canonical-id)))
181
+
182
+ (defn mapped-events [normalized options]
183
+ (loop [remaining normalized
184
+ result []]
185
+ (if (empty? remaining)
186
+ result
187
+ (let [event (first remaining)
188
+ provider-id (:visemeId event)
189
+ base-canonical (get azure->canonical provider-id (:B_M_P visemes/canonical-visemes))
190
+ word (find-word-at-time (:time event) (:wordTimings options))
191
+ canonical-id (refine-for-word provider-id base-canonical (:time event) word)
192
+ previous (last result)
193
+ duplicate? (and previous
194
+ canonical-id
195
+ (= (:canonicalId previous) canonical-id)
196
+ (< (js/Math.abs (- (* 1000 (:time event))
197
+ (* 1000 (:time previous))))
198
+ duplicate-window-ms))]
199
+ (recur (rest remaining)
200
+ (if (or (nil? canonical-id) duplicate?)
201
+ result
202
+ (conj result (assoc event
203
+ :canonicalId canonical-id
204
+ :className (viseme-class provider-id canonical-id)))))))))
205
+
206
+ (defn clamp-duration [duration-ms min-ms max-ms remaining-ms]
207
+ (let [bounded (min (max duration-ms min-ms) max-ms remaining-ms)]
208
+ (max 0 (js/Math.round bounded))))
209
+
210
+ (defn duration-ms [current next-event total-duration-ms]
211
+ (let [offset-ms (max 0 (js/Math.round (* (:time current) 1000)))
212
+ remaining-ms (if (state/finite-number? total-duration-ms)
213
+ (max 0 (- total-duration-ms offset-ms))
214
+ js/Number.POSITIVE_INFINITY)
215
+ class-name (:className current)
216
+ fallback-ms (if (= class-name :silence) (max-duration-ms :silence) (min-duration-ms class-name))
217
+ raw-span-ms (if next-event
218
+ (max 0 (js/Math.round (* (- (:time next-event) (:time current)) 1000)))
219
+ (min fallback-ms remaining-ms))]
220
+ (if (= class-name :silence)
221
+ (clamp-duration raw-span-ms 0 (max-duration-ms :silence) remaining-ms)
222
+ (let [next-closure? (= (:className next-event) :bilabial)
223
+ overlap-ms (if next-closure? 8 (max-overlap-ms class-name))
224
+ desired-ms (max raw-span-ms (min-duration-ms class-name))
225
+ max-ms (min (max-duration-ms class-name) (+ raw-span-ms overlap-ms))]
226
+ (clamp-duration desired-ms 1 max-ms remaining-ms)))))
227
+
228
+ (defn push-event
229
+ ([timeline viseme-id offset-ms duration-ms]
230
+ (push-event timeline viseme-id offset-ms duration-ms nil))
231
+ ([timeline viseme-id offset-ms duration-ms extra-classes]
232
+ (if (<= duration-ms 0)
233
+ timeline
234
+ (let [classes (vec (distinct (concat (visemes/viseme-classes viseme-id)
235
+ (or extra-classes []))))]
236
+ (conj timeline {:visemeId viseme-id
237
+ :phonemeClass (visemes/primary-class classes)
238
+ :phonemeClasses classes
239
+ :jawActivation (visemes/jaw-activation-for-viseme viseme-id)
240
+ :offsetMs (max 0 (js/Math.round offset-ms))
241
+ :durationMs (max 1 (js/Math.round duration-ms))})))))
242
+
243
+ (defn diphthong-targets [provider-id]
244
+ (case provider-id
245
+ 8 [(:Oh visemes/canonical-visemes) (:W_OO visemes/canonical-visemes)]
246
+ 9 [(:Ah visemes/canonical-visemes) (:W_OO visemes/canonical-visemes)]
247
+ 10 [(:Oh visemes/canonical-visemes) (:EE visemes/canonical-visemes)]
248
+ 11 [(:Ah visemes/canonical-visemes) (:Ih visemes/canonical-visemes)]
249
+ nil))
250
+
251
+ (defn push-expanded-event [timeline provider-id canonical-id offset-ms duration-ms]
252
+ (let [targets (diphthong-targets provider-id)]
253
+ (if (or (nil? targets) (< duration-ms diphthong-min-duration-ms))
254
+ (push-event timeline canonical-id offset-ms duration-ms)
255
+ (let [[first-viseme second-viseme] targets
256
+ second-offset-ms (min (+ offset-ms duration-ms (- diphthong-secondary-min-ms))
257
+ (+ offset-ms (* duration-ms 0.55)))
258
+ first-duration-ms (max diphthong-secondary-min-ms
259
+ (min duration-ms (+ (- second-offset-ms offset-ms)
260
+ (* duration-ms 0.25))))
261
+ second-duration-ms (max diphthong-secondary-min-ms
262
+ (- (+ offset-ms duration-ms) second-offset-ms))]
263
+ ;; Azure provider IDs 8-11 represent diphthong motion. Preserve that
264
+ ;; fact after expansion so the jaw planner keeps one vocalic jaw arc
265
+ ;; while the lips travel through the two canonical targets.
266
+ (-> timeline
267
+ (push-event first-viseme offset-ms first-duration-ms ["diphthong"])
268
+ (push-event second-viseme second-offset-ms second-duration-ms ["diphthong"]))))))
269
+
270
+ (defn azure-visemes->timeline
271
+ ([visemes] (azure-visemes->timeline visemes nil nil))
272
+ ([visemes total-duration-ms options]
273
+ (let [options (or options {})
274
+ normalized (normalize-provider-visemes visemes)
275
+ mapped (mapped-events normalized options)]
276
+ (->> (map-indexed (fn [index event]
277
+ {:event event
278
+ :next-event (get mapped (inc index))})
279
+ mapped)
280
+ (reduce (fn [timeline {:keys [event next-event]}]
281
+ (let [offset-ms (max 0 (js/Math.round (* (:time event) 1000)))
282
+ event-duration-ms (duration-ms event next-event total-duration-ms)]
283
+ (push-expanded-event timeline
284
+ (:visemeId event)
285
+ (:canonicalId event)
286
+ offset-ms
287
+ event-duration-ms)))
288
+ [])
289
+ (sort-by :offsetMs)
290
+ vec))))
@@ -0,0 +1,178 @@
1
+ (ns polymer.tts.planner)
2
+
3
+ ;; The TTS provider planning layer is intentionally small: it produces data plans, not a
4
+ ;; planner framework. The agency still executes the plan explicitly.
5
+ ;;
6
+ ;; In this package "provider plan" means "turn the requested goal plus available world
7
+ ;; facts into an auditable action list." It does not mean hidden control flow.
8
+ ;; The browser/backend calls remain in polymer.tts.agency, and pure provider
9
+ ;; payload cleanup stays beside the provider boundary in the TTS/Azure agencies.
10
+
11
+ (def supported-engines
12
+ ;; Keep supported provider names in one set so speech and voice-load planning
13
+ ;; reject unknown strings the same way.
14
+ #{"azure" "webSpeech"})
15
+
16
+ (defn present-string?
17
+ "Treat only non-empty strings as configured provider values."
18
+ [value]
19
+ (and (string? value) (pos? (count value))))
20
+
21
+ (defn provider-ready?
22
+ "A provider is ready when the required browser/backend capability exists."
23
+ [world engine]
24
+ (case engine
25
+ "azure" (boolean (or (present-string? (:backendUrl world))
26
+ (:hasAzureSynthesize world)
27
+ (:hasAzureVoices world)))
28
+ "webSpeech" (boolean (:hasWebSpeech world))
29
+ "livekit" false
30
+ false))
31
+
32
+ (defn failure-step
33
+ "Return the first reason a goal cannot run, or nil when it is executable."
34
+ [goal]
35
+ (cond
36
+ (not (contains? supported-engines (:engine goal)))
37
+ {:op "fail" :reason "unsupported-engine" :engine (:engine goal)}
38
+
39
+ (false? (:hasText goal))
40
+ {:op "fail" :reason "missing-text"}
41
+
42
+ (not (:providerReady goal))
43
+ {:op "fail" :reason "provider-not-ready" :engine (:engine goal)}
44
+
45
+ :else nil))
46
+
47
+ (defn provider-goal
48
+ "Turn a speak command into the provider goal the agency must satisfy."
49
+ [command config world]
50
+ (let [engine (or (:engine command) (:engine config) "webSpeech")]
51
+ {:type "speak"
52
+ :engine engine
53
+ :text (:text command)
54
+ :providerReady (provider-ready? world engine)
55
+ :hasText (pos? (count (or (:text command) "")))}))
56
+
57
+ (defn speech-steps
58
+ "Return the ordered action data for a speech goal."
59
+ [goal]
60
+ (cond
61
+ (failure-step goal)
62
+ [(failure-step goal)]
63
+
64
+ (= "azure" (:engine goal))
65
+ [{:op "configure-lipsync"}
66
+ {:op "synthesize-azure"}
67
+ {:op "play-azure-audio"}
68
+ {:op "emit-word-boundaries"}]
69
+
70
+ (= "webSpeech" (:engine goal))
71
+ [{:op "configure-lipsync"}
72
+ {:op "speak-web-speech"}
73
+ {:op "use-web-speech-boundaries"}]
74
+
75
+ :else
76
+ [{:op "fail" :reason "unsupported-engine" :engine (:engine goal)}]))
77
+
78
+ (defn plan-ok?
79
+ "A plan is executable when the first step is not an explicit failure."
80
+ [steps]
81
+ (not= "fail" (:op (first steps))))
82
+
83
+ (defn plan-speech
84
+ "Build an auditable provider plan-style plan for a speak command."
85
+ [command config world]
86
+ (let [goal (provider-goal command config world)
87
+ steps (speech-steps goal)]
88
+ {:goal goal
89
+ :steps steps
90
+ :ok (plan-ok? steps)}))
91
+
92
+ (defn voice-load-goal
93
+ "Turn a load-voices command into a provider availability goal."
94
+ [engine world]
95
+ {:type "loadVoices"
96
+ :engine engine
97
+ :providerReady (provider-ready? world engine)})
98
+
99
+ (defn voice-load-steps
100
+ "Return the ordered action data for loading provider voice choices."
101
+ [goal]
102
+ (cond
103
+ (failure-step goal)
104
+ [(failure-step goal)]
105
+
106
+ (= "azure" (:engine goal))
107
+ [{:op "load-azure-voices"}]
108
+
109
+ (= "webSpeech" (:engine goal))
110
+ [{:op "load-web-speech-voices"}]
111
+
112
+ :else
113
+ [{:op "fail" :reason "unsupported-engine" :engine (:engine goal)}]))
114
+
115
+ (defn plan-voice-load
116
+ "Build a small plan for loading provider voice choices."
117
+ [engine world]
118
+ (let [goal (voice-load-goal engine world)
119
+ steps (voice-load-steps goal)]
120
+ {:goal goal
121
+ :steps steps
122
+ :ok (plan-ok? steps)}))
123
+
124
+ (def active-statuses
125
+ #{"loading" "speaking"})
126
+
127
+ (defn active-speech?
128
+ "Return true when the current state has provider work that must be stopped
129
+ before replacement/reset."
130
+ [tts-state]
131
+ (contains? active-statuses (:status tts-state)))
132
+
133
+ (defn command-action [op extra]
134
+ (merge {:op op} extra))
135
+
136
+ (defn plan-command
137
+ "Plan public TTS dispatch commands into explicit agency actions.
138
+
139
+ This is deliberately separate from provider planning above. `plan-speech` and
140
+ `plan-voice-load` decide whether a provider goal can run. `plan-command`
141
+ decides which local session/voice actions must happen before those provider
142
+ goals are attempted."
143
+ [tts-state payload]
144
+ (case (:type payload)
145
+ ;; configure must not cancel in-flight voice loads: hosts dispatch
146
+ ;; configure continuously (sliders, engine toggles) and would race the
147
+ ;; voice request into a permanent "checking" state. Only reset cancels.
148
+ "configure"
149
+ [(command-action "configure" {:config (:config payload)})]
150
+
151
+ "loadVoices"
152
+ [(command-action "load-voices"
153
+ {:engine (or (:engine payload)
154
+ (get-in tts-state [:config :engine]))})]
155
+
156
+ "speak"
157
+ (cond-> []
158
+ (active-speech? tts-state)
159
+ (conj (command-action "stop-active-session" {:reason "replaced"}))
160
+
161
+ true
162
+ (conj (command-action "speak" {:command payload})))
163
+
164
+ "stop"
165
+ [(command-action "advance-session" {})
166
+ (command-action "stop-session" {:reason "requested"})]
167
+
168
+ "reset"
169
+ (cond-> [(command-action "advance-session" {})
170
+ (command-action "cancel-voice-loads" {})]
171
+ (active-speech? tts-state)
172
+ (conj (command-action "stop-active-session" {:reason "reset"}))
173
+
174
+ true
175
+ (conj (command-action "reset" {})))
176
+
177
+ [(command-action "error"
178
+ {:message (str "Unknown TTS command: " (:type payload))})]))