-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvox.lua
More file actions
2432 lines (2296 loc) · 100 KB
/
Copy pathvox.lua
File metadata and controls
2432 lines (2296 loc) · 100 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- ============================================================
-- VOX — local push-to-talk dictation (Willow/Wispr replacement)
-- Hold RIGHT OPTION (⌥) and speak. Release to transcribe + paste.
-- Quick-tap Right Option to LOCK recording (hands-free); tap again to stop.
--
-- Pipeline: sox (mic) -> whisper-server (persistent, model in RAM, Metal)
-- -> Ollama (local LLM cleanup, screen-context aware) -> paste
-- Everything runs on-device. No cloud. No subscription.
-- ============================================================
local M = {}
-- Enable the `hs` command-line tool so Vox can be inspected/driven from a
-- terminal (and remotely by fleet agents): e.g. `hs -c "print(1+1)"`.
pcall(function()
require("hs.ipc")
local prefix = hs.fs.attributes("/opt/homebrew/bin") and "/opt/homebrew"
or "/usr/local"
hs.ipc.cliInstall(prefix)
end)
-- ---------------- CONFIG (edit freely) ----------------------
local HOME = os.getenv("HOME")
-- Private scratch dir (0700) instead of world-readable /tmp. Recordings,
-- screenshots and OCR text are the most sensitive things Vox touches — they
-- must never sit in shared /tmp (mode 1777) where another local account can
-- read them in the moment before deletion. TMPDIR is already a per-user 0700
-- dir on macOS; we still make our own subdir and lock it down for the /tmp
-- fallback case. Every /tmp/vox-* path in this file routes through TMP.
local TMP = (os.getenv("TMPDIR") or "/tmp"):gsub("/+$", "")
.. "/vox-" .. (os.getenv("USER") or "user")
os.execute("/bin/mkdir -p '" .. TMP .. "' 2>/dev/null; "
.. "/bin/chmod 700 '" .. TMP .. "' 2>/dev/null")
local function tmp(name) return TMP .. "/" .. name end
-- hardware-aware defaults (tiers validated on real fleet hardware):
-- Apple Silicon -> large-v3-turbo on Metal (~1.5s)
-- modern Intel (4+ cores) -> small (large HANGS without Metal)
-- ancient Intel (<=2 cores, e.g. 2012 MBA) -> tiny (~4s, still usable)
local IS_ARM, CORES = false, 4
do
local p = io.popen("/usr/bin/uname -m")
if p then IS_ARM = (p:read("*a") or ""):find("arm64") ~= nil; p:close() end
local q = io.popen("/usr/sbin/sysctl -n hw.physicalcpu")
if q then CORES = tonumber(q:read("*a")) or 4; q:close() end
end
local BREW = IS_ARM and "/opt/homebrew/bin" or "/usr/local/bin"
local WMODEL = IS_ARM and "ggml-large-v3-turbo-q5_0.bin"
or (CORES <= 2 and "ggml-tiny-q5_1.bin" or "ggml-small-q5_1.bin")
local C = {
sox = BREW .. "/sox",
whisper = BREW .. "/whisper-cli", -- fallback only
whisperSrv = BREW .. "/whisper-server", -- fast path
serverPort = 8090,
-- whisperHost: where transcription happens. Keep 127.0.0.1 normally.
-- Old/slow Mac? Point it at a fast Mac running Vox on your LAN
-- (that Mac sets serverBind = "0.0.0.0" in ITS local.lua) and this
-- machine becomes a thin client — recording is cheap, the M-series
-- Mac does the thinking.
whisperHost = "127.0.0.1",
serverBind = "127.0.0.1",
model = HOME .. "/vox/models/" .. WMODEL,
wav = tmp("recording.wav"),
wavNorm = tmp("norm.wav"),
language = "en", -- "en", "fr", or "auto" (auto costs ~+1s
-- per dictation: extra detection pass)
-- never oversubscribe the CPU (a 2-core MBA with 8 threads = thrash)
threads = tostring(math.max(1, math.min(IS_ARM and 8 or 4, CORES))),
soundsDir = HOME .. "/vox/sounds/",
soundTheme = "classic", -- "classic" or "sleek" (menubar toggle)
soundVolume = 0.5,
-- Vocabulary hint fed to Whisper so it spells your world correctly.
-- Put YOUR names/brands/jargon in ~/vox/local.lua (untracked) —
-- see local.example.lua.
vocabulary = "Hammerspoon, Ollama, Whisper, Supabase, n8n, SaaS, CRM, API.",
-- Local LLM cleanup pass. OFF by default: whisper large-v3-turbo already
-- punctuates well, and the LLM adds 1.5-3s and sometimes paraphrases.
-- Toggle from the menubar when you want context-aware rewriting.
llmCleanup = false,
-- Opt-in escape hatch for ancient-hardware users who explicitly WANT the
-- local LLM to run even on <=2-core CPUs where it'll take 15-60s per pass.
-- Off by default — Vox's normal behavior on such Macs is to refuse smart-
-- reply / expand / cleanup so it doesn't feel broken. Set true in local.lua
-- if you've picked a small model (e.g. llama3.2:1b) and can wait.
forceLocalLLM = false,
translateTo = "off", -- "off", "English", "French", "Spanish", "Dutch"
ollamaUrl = "http://localhost:11434/api/generate",
ollamaModel = "llama3.2:3b", -- legacy fallback if the router finds nothing
llmTimeout = 10, -- secs before falling back to raw text
-- Adaptive brain: fast model for mechanical work (cleanup, quick replies),
-- smart model where quality IS the product (translation, content, complex
-- replies). Low-RAM Macs (<12GB) stay on fast automatically.
models = {
fast = "llama3.2:3b",
smart = "qwen2.5:7b",
},
holdKeycode = 61, -- 61 = Right Option. (Right Cmd = 54)
holdKeyName = "Right Option",
tapLockMax = 0.35, -- press shorter than this counts as a tap
tailGrace = 0.35, -- mic stays open this long after release
-- (last-word syllables are still in the air)
doubleTapWindow = 0.45, -- two taps this close = hands-free lock
minBytes = 24000, -- ignore recordings under ~0.7s
maxRecordSecs = 180, -- auto-stop a forgotten locked recording
-- Keep the transcript in the clipboard after pasting, so ⌘V re-pastes it
-- if it landed in the wrong window. Off = restore whatever you had copied.
keepInClipboard = true,
-- When you dictate twice in a row into the same app, insert the missing
-- space between "...sentence." and "Next sentence" automatically.
autoSpace = true,
-- Screen-aware dictation: while you talk, OCR the window you're dictating
-- into and feed the visible names/jargon to Whisper as spelling hints —
-- reply to someone and their name is spelled right on the first try.
-- (Needs the Screen Recording grant; skipped silently without it.)
screenContext = true,
-- Tiny idle alien: a minimal, mostly-still cutie at the bottom edge when
-- Vox is idle. Click him to start/stop a hands-free dictation.
miniAlien = true,
-- Voice commands: say "scratch that" to undo the last dictation;
-- say "new paragraph." / "new line." (as their own clause) for breaks.
voiceCommands = true,
-- The alien's brain: every dictation is remembered in ~/vox/memory/
-- (human-readable journal + instant full-text recall). LOCAL ONLY.
-- memoryRAG feeds relevant memories into expand/smart-reply prompts.
-- Export/import the whole brain between Macs: python3 ~/vox/mem.py export
memory = true,
memoryRAG = true,
fillerFilter = true, -- strip "uh"/"um" etc. from transcripts
apiEnable = true,
apiPort = 8091, -- localhost-only pulse API
memoryWebhook = "", -- optional: POST each memory to a URL
-- The ONLY thing Vox ever sends off this Mac is the update check (a git
-- fetch of code metadata from GitHub — never your audio, text, or memory).
-- Set false for a fully dark, zero-outbound machine (update manually with
-- git pull).
autoUpdate = true,
-- Smart ducking: fade playing audio down (not off) while recording,
-- ramp it back when done. Cleaner mic signal without killing the vibe.
duckAudio = true,
duckLevel = 0.35, -- music drops to 35% of current volume
-- Deterministic post-transcription fixes: zero latency, never paraphrases.
-- Matched case-insensitively; spaces in keys also match hyphens.
corrections = {
["super base"] = "Supabase",
["supa base"] = "Supabase",
["n eight n"] = "n8n",
},
}
-- Personal overrides: ~/vox/local.lua (gitignored) returns a table that is
-- merged over the config above. Keep private vocabulary/corrections there.
-- Every override is TYPE-CHECKED against the default — a typo in local.lua
-- (duckLevel = "high") falls back to the default instead of crashing later.
do
local defaults = {}
for k, v in pairs(C) do defaults[k] = v end
local f = loadfile(HOME .. "/vox/local.lua")
if f then
local ok, o = pcall(f)
if ok and type(o) == "table" then
for k, v in pairs(o) do C[k] = v end
end
end
local bad = {}
for k, dv in pairs(defaults) do
if C[k] ~= nil and type(C[k]) ~= type(dv) then
bad[#bad + 1] = k
C[k] = dv
end
end
if #bad > 0 then
hs.timer.doAfter(3, function()
hs.alert.show("Vox: ignored bad local.lua value(s): "
.. table.concat(bad, ", "), 5)
end)
end
end
-- ------------------------------------------------------------
local state = "idle" -- idle | recording | processing
local locked = false
local pendingTap = false -- first tap of a possible double-tap
local lockAt = 0 -- when hands-free lock engaged
local recMode = "dictate" -- dictate | expand (shift+key)
local recGen = 0 -- invalidates stale recorder callbacks
local keyDownAt = 0
local context = { app = "", title = "" }
local recTask, menubar
local timers = {} -- anchored refs so timers survive GC
local duck -- ducking state (defined below)
local reqId = 0 -- guards against late LLM responses
local function log(msg) print("[vox] " .. msg) end
-- repeating timers must never die from one bad frame: pcall each tick,
-- log the first error per name, keep ticking
local tickErrs = {}
local function safeTick(name, fn)
return function(...)
local ok, err = pcall(fn, ...)
if not ok and not tickErrs[name] then
tickErrs[name] = true
log("ERROR in " .. name .. " (suppressing repeats): " .. tostring(err))
end
end
end
-- ---------------- learning vocabulary -------------------------
-- Vox remembers the words you actually use (locally, in learned.json —
-- word frequencies only, never full transcripts) and feeds the distinctive
-- ones back into Whisper so recognition gets sharper the more you dictate.
local LEARNED_PATH = HOME .. "/vox/learned.json"
local learned = {}
local function loadLearned()
local f = io.open(LEARNED_PATH, "r")
if not f then return end
local ok, data = pcall(hs.json.decode, f:read("*a"))
f:close()
if ok and type(data) == "table" then learned = data end
end
loadLearned()
local function saveLearned()
-- prune one-offs if the store gets big
local n = 0
for _ in pairs(learned) do n = n + 1 end
if n > 2000 then
for k, e in pairs(learned) do
if e.n <= 1 then learned[k] = nil end
end
end
local f = io.open(LEARNED_PATH, "w")
if f then f:write(hs.json.encode(learned)); f:close() end
end
local function learnFrom(text)
for pos, w in text:gmatch("()([%a][%a'%-]+)") do
local lw = w:lower()
if #lw >= 4 then
local e = learned[lw] or { n = 0, cap = 0, form = w }
e.n = e.n + 1
local before = text:sub(math.max(1, pos - 2), pos - 1)
local atStart = (pos == 1) or before:find("[%.!%?]%s?$") ~= nil
if w:find("^%u") and not atStart then
e.cap = e.cap + 1
e.form = w -- remember the capitalized form
end
learned[lw] = e
end
end
if timers.learnSave then timers.learnSave:stop() end
timers.learnSave = hs.timer.doAfter(4, saveLearned)
end
local function learnedCount()
local n = 0
for _ in pairs(learned) do n = n + 1 end
return n
end
-- distinctive = mostly-capitalized mid-sentence (names/brands) or absent
-- from the system dictionary (jargon) and used repeatedly
local sysDict -- loaded once (boot-time via buildLearnedVocab)
local function systemDict()
if sysDict then return sysDict end
sysDict = {}
local f = io.open("/usr/share/dict/words", "r")
if f then
for line in f:lines() do sysDict[line:lower()] = true end
f:close()
end
return sysDict
end
-- dictionary check that also catches inflections the word list lacks
-- (Founders -> founder, Hiring -> hire, Trusted -> trust)
local function isCommonWord(wl)
local d = systemDict()
if d[wl] then return true end
local SLANG = { gonna = true, gotta = true, wanna = true, kinda = true,
sorta = true, okay = true, yeah = true }
if SLANG[wl] then return true end
for _, try in ipairs({ wl:gsub("s$", ""), wl:gsub("es$", ""),
wl:gsub("ed$", ""), wl:gsub("ed$", "e"),
wl:gsub("ing$", ""), wl:gsub("ing$", "e"),
wl:gsub("'s$", ""), wl:gsub("n't$", ""),
wl:gsub("'t$", ""), wl:gsub("'re$", ""),
wl:gsub("'ve$", ""), wl:gsub("'ll$", ""),
wl:gsub("'d$", "") }) do
if try ~= wl and d[try] then return true end
end
return false
end
local function buildLearnedVocab()
local dict = systemDict()
local cands = {}
for lw, e in pairs(learned) do
local proper = e.cap >= 2 and (e.cap / e.n) > 0.5
local unusual = (not dict[lw]) and e.n >= 3
if (proper or unusual) and not isCommonWord(lw) then
cands[#cands + 1] = { form = e.form, score = e.n + e.cap * 2 }
end
end
table.sort(cands, function(a, b) return a.score > b.score end)
local parts, len = {}, 0
for _, cd in ipairs(cands) do
len = len + #cd.form + 2
if len > 350 then break end -- whisper's prompt budget is finite
parts[#parts + 1] = cd.form
end
return table.concat(parts, ", ")
end
-- the brain teaches the ears: the memory's top proper-noun entities
-- (people, brands, places you actually talk about) join Whisper's vocabulary
local function brainVocab()
local p = io.popen("/usr/bin/python3 " .. HOME
.. "/vox/mem.py entities -n 25 2>/dev/null")
if not p then return "" end
local out = p:read("*a") or ""
p:close()
local parts, len = {}, 0
for line in out:gmatch("[^\n]+") do
local ok, e = pcall(hs.json.decode, line)
if ok and e and e.entity and e.entity:find("%u")
and not isCommonWord(e.entity:lower()) then -- real names only
len = len + #e.entity + 2
if len > 220 then break end
parts[#parts + 1] = e.entity
end
end
return table.concat(parts, ", ")
end
local vocabCache = nil
local function fullVocabulary()
if not vocabCache then
local extra = buildLearnedVocab() -- reads the system dictionary once
local brains = brainVocab()
vocabCache = C.vocabulary
.. (extra ~= "" and (" " .. extra .. ".") or "")
.. (brains ~= "" and (" " .. brains .. ".") or "")
end
return vocabCache
end
local function invalidateVocab() vocabCache = nil end
-- ---------------- the alien's brain ---------------------------
-- Full transcripts flow into ~/vox/memory/ via mem.py: a human-readable
-- monthly journal plus a SQLite full-text index for instant recall.
local nextMemMode = "dictate" -- tag set by generators before paste
local function rememberText(text, mode)
if not C.memory or #text < 2 then return end
M.memTask = hs.task.new("/usr/bin/python3", nil,
{ HOME .. "/vox/mem.py", "add", "--text", text,
"--app", context.app or "", "--mode", mode or "dictate" })
M.memTask:start()
if C.memoryWebhook ~= "" then -- optional pulse to n8n/anything
pcall(hs.http.asyncPost, C.memoryWebhook,
hs.json.encode({ ts = os.time(), app = context.app, mode = mode,
text = text }),
{ ["Content-Type"] = "application/json" }, function() end)
end
end
local function memoryLookup(query, n, cb)
if not (C.memory and C.memoryRAG) then cb("") return end
M.memQTask = hs.task.new("/usr/bin/python3", function(code, out)
local parts = {}
for line in (out or ""):gmatch("[^\n]+") do
local ok, e = pcall(hs.json.decode, line)
if ok and e and e.text then
parts[#parts + 1] = "- " .. e.text:sub(1, 300)
end
end
cb(#parts > 0 and table.concat(parts, "\n") or "")
end, { HOME .. "/vox/mem.py", "search", query, "-n", tostring(n or 3) })
M.memQTask:start()
end
-- ---------------- adaptive brain router -----------------------
-- Fast when we can be fast, concentrated when we need to be concentrated.
local lowRam = false
do
local p = io.popen("/usr/sbin/sysctl -n hw.memsize")
if p then
local v = tonumber(p:read("*a")) or 0
p:close()
lowRam = v > 0 and v < 12 * 1024 * 1024 * 1024
end
end
local availableModels = {}
local function refreshModels()
hs.http.asyncGet(C.ollamaUrl:gsub("/api/generate", "/api/tags"), nil,
function(status, body)
if status ~= 200 then return end
local ok, d = pcall(hs.json.decode, body)
if ok and d and d.models then
availableModels = {}
for _, m in ipairs(d.models) do availableModels[m.name] = true end
end
end)
end
-- is the LLM running on THIS machine (vs a fast Mac on the LAN)?
local function ollamaIsLocal()
return C.ollamaUrl:find("//127%.0%.0%.1") ~= nil
or C.ollamaUrl:find("//localhost") ~= nil
end
-- task: cleanup | translate | reply | expand · complex: judgement hint
local function pickModel(task, complex)
local want
if lowRam and ollamaIsLocal() then
want = C.models.fast -- 8GB Macs never swap-thrash; remote brain = no cap
elseif task == "translate" or task == "expand" or task == "answer" then
want = C.models.smart -- quality IS the product
elseif task == "reply" then
want = complex and C.models.smart or C.models.fast
else
want = C.models.fast -- mechanical: speed wins
end
if availableModels[want] then return want end
if availableModels[C.models.fast] then return C.models.fast end
return C.ollamaModel
end
-- ---------------- pipeline lock-in ----------------------------
-- "If it works once, lock it in." Vox verifies its own transcription
-- pipeline end-to-end (synthesized speech -> transcript), records the proven
-- configuration in calibration.json, and when the locked path starts failing
-- it walks the fallback chain by itself and locks in whatever works.
local CALIB_PATH = HOME .. "/vox/calibration.json"
local calib = {}
do
local f = io.open(CALIB_PATH, "r")
if f then
local ok, d = pcall(hs.json.decode, f:read("*a"))
f:close()
if ok and type(d) == "table" then calib = d end
end
end
local function saveCalib()
local f = io.open(CALIB_PATH, "w")
if f then f:write(hs.json.encode(calib)); f:close() end
end
local currentRev = "unknown"
do
local p = io.popen("cd \"$HOME/vox\" && /usr/bin/git rev-parse --short HEAD 2>/dev/null")
if p then currentRev = (p:read("*a") or ""):gsub("%s+", ""); p:close() end
end
-- what the user configured, before any self-healing overrides
local configuredHost = C.whisperHost
if calib.forceLocal and C.whisperHost ~= "127.0.0.1" then
log("calibration: remote brain was failing — running local until re-verified")
C.whisperHost = "127.0.0.1"
end
local function noteTranscribeSuccess(secs)
calib.mode = (C.whisperHost == "127.0.0.1") and "local" or ("remote " .. C.whisperHost)
calib.lastLatency = math.floor(secs * 10) / 10
if not calib.bestLatency or calib.lastLatency < calib.bestLatency then
calib.bestLatency = calib.lastLatency
end
calib.verifiedAt, calib.verifiedRev = os.time(), currentRev
calib.remoteFails = 0
saveCalib()
end
local function noteRemoteFail()
calib.remoteFails = (calib.remoteFails or 0) + 1
if calib.remoteFails >= 2 and not calib.forceLocal then
calib.forceLocal = true
C.whisperHost = "127.0.0.1"
hs.alert.show("Vox: remote brain failing — locked to local transcription."
.. " Menu: Verify pipeline to retry.", 4)
end
saveCalib()
end
-- ---------------- sounds (subtle, synthesized) ---------------
local sounds = {}
local function loadSounds()
for _, n in ipairs({ "start", "stop", "done" }) do
local s = hs.sound.getByFile(C.soundsDir .. C.soundTheme .. "/" .. n .. ".wav")
if s then s:volume(C.soundVolume) end
sounds[n] = s
end
end
loadSounds()
local function play(n)
local s = sounds[n]
if not s then return end
-- boost cues while system volume is ducked so they stay audible
s:volume((duck and duck.active)
and math.min(1, C.soundVolume / C.duckLevel) or C.soundVolume)
s:stop(); s:play()
end
-- ---------------- smart audio ducking -------------------------
duck = { active = false, orig = nil, dev = nil }
local function rampVolume(target, steps, interval, onDone)
if timers.duck then timers.duck:stop() end
local dev = duck.dev
if not dev then return end
local from = dev:outputVolume() or 0
local n = 0
timers.duck = hs.timer.doEvery(interval, function()
n = n + 1
dev:setOutputVolume(from + (target - from) * (n / steps))
if n >= steps then
timers.duck:stop()
if onDone then onDone() end
end
end)
end
local function duckDown()
if not C.duckAudio then return end
local dev = hs.audiodevice.defaultOutputDevice()
if not dev or not dev:outputVolume() then return end
local vol = dev:outputVolume()
if vol < 3 then return end -- nothing meaningful playing
if not duck.active then -- don't clobber orig mid-restore
duck.orig, duck.dev, duck.active = vol, dev, true
end
rampVolume(duck.orig * C.duckLevel, 5, 0.05) -- quick fade down
end
local function duckUp()
if not duck.active then return end
rampVolume(duck.orig, 8, 0.06, function() duck.active = false end)
end
-- ---------------- HUD: cute alien + bars, bottom center ------
-- A little green alien lives in the pill: bobs, blinks, blushes, and reacts
-- to your voice. The pill puffs in and out of a cloud of smoke.
local PILL_W, PILL_H, BARS = 100, 28, 8
local CV_W, CV_H = 150, 70 -- canvas is bigger than the pill so the
local OX, OY = (CV_W - PILL_W) / 2, 30 -- smoke has room to billow
local PUFFS = 7
local hud = { canvas = nil, timer = nil, mode = "rec", phase = 0,
visible = false, anim = nil, animT = 0,
nextBlink = 30, blinkUntil = 0, baseX = 0, baseY = 0,
level = 0, emote = nil, emoteUntil = 0 }
-- live mic level: peek at the tail of the growing recording (16-bit PCM)
local function micLevel()
local f = io.open(C.wav, "rb")
if not f then return 0 end
local size = f:seek("end")
local n = 3200 -- last ~100ms of audio
if size < 44 + n then f:close(); return 0 end
f:seek("set", size - n)
local d = f:read(n)
f:close()
if not d then return 0 end
local peak = 0
for i = 1, #d - 1, 8 do
local lo, hi = d:byte(i, i + 1)
local v = lo + hi * 256
if v >= 32768 then v = v - 65536 end
if v < 0 then v = -v end
if v > peak then peak = v end
end
return peak / 32768
end
-- what mood did the speaker leave the alien in?
local function detectEmotion(t)
local s = t:lower()
if s:find("haha") or s:find("%f[%a]lol%f[%A]") or s:find("lmao")
or s:find("funny") or s:find("joke") or s:find("hilarious") then
return "joy"
end
local _, excl = t:gsub("!", "")
if excl >= 2 then return "excite" end
if t:find("%?%s*$") then return "curious" end
if excl >= 1 then return "excite" end
return "done"
end
local ALIEN = { red = 0.45, green = 0.97, blue = 0.72, alpha = 1 } -- mint green
local EYES = { red = 0.02, green = 0.06, blue = 0.12, alpha = 0.95 }
local function easeOutBack(t) -- overshoot = the cute bounce
local c1, c3 = 1.70158, 2.70158
local u = t - 1
return 1 + c3 * u * u * u + c1 * u * u
end
-- ---------------- idle mini-alien -----------------------------
-- When Vox is idle, a tiny alien rests at the bottom edge with two little
-- buttons: C = speak-to-content, P = absorb the screen into memory.
-- Click the alien himself for hands-free dictation.
local mini = { canvas = nil, timer = nil, phase = 0,
nextBlink = 20, blinkUntil = 0, act = {} }
local MW, MH, MOFF = 74, 30, 24 -- canvas w/h, alien x-offset
local function miniEnsure()
if mini.canvas then return end
local c = hs.canvas.new({ x = 0, y = 0, w = MW, h = MH })
c:level(hs.canvas.windowLevels.overlay)
c:behavior({ "canJoinAllSpaces", "stationary" })
c:clickActivating(false) -- clicks don't steal app focus
c[1] = { type = "oval", action = "fill", fillGradient = "radial",
fillGradientColors = {
{ red = 0.72, green = 1.0, blue = 0.88, alpha = 1 },
{ red = 0.40, green = 0.90, blue = 0.66, alpha = 1 } },
fillGradientCenter = { x = -0.35, y = -0.45 },
frame = { x = MOFF + 6, y = 12, w = 14, h = 15 } }
c[2] = { type = "oval", action = "fill", fillColor = EYES,
frame = { x = MOFF + 9, y = 16.5, w = 3, h = 4 } }
c[3] = { type = "oval", action = "fill", fillColor = EYES,
frame = { x = MOFF + 14, y = 16.5, w = 3, h = 4 } }
c[4] = { type = "arc", action = "stroke", strokeColor = EYES,
strokeWidth = 1, center = { x = MOFF + 13, y = 22 }, radius = 2.2,
startAngle = 135, endAngle = 225 }
c[5] = { type = "segments", action = "stroke", strokeColor = ALIEN,
strokeWidth = 1.2,
coordinates = { { x = MOFF + 13, y = 12 }, { x = MOFF + 13, y = 8 } } }
c[6] = { type = "oval", action = "fill", fillColor = ALIEN,
frame = { x = MOFF + 11.6, y = 5.4, w = 2.8, h = 2.8 } }
c[7] = { type = "oval", action = "fill",
fillColor = { red = 1, green = 1, blue = 1, alpha = 0.85 },
frame = { x = MOFF + 10.7, y = 17.2, w = 1.2, h = 1.4 } }
c[8] = { type = "oval", action = "fill",
fillColor = { red = 1, green = 1, blue = 1, alpha = 0.85 },
frame = { x = MOFF + 15.7, y = 17.2, w = 1.2, h = 1.4 } }
-- C button (content) and P button (absorb screen)
local btnBg = { red = 0.10, green = 0.12, blue = 0.20, alpha = 0.85 }
c[9] = { type = "oval", action = "fill", fillColor = btnBg,
frame = { x = 2, y = 12, w = 17, h = 17 } }
c[10] = { type = "text", text = "C", textSize = 10,
textColor = { red = 0.45, green = 0.97, blue = 0.72, alpha = 1 },
textAlignment = "center", frame = { x = 2, y = 14.5, w = 17, h = 13 } }
c[11] = { type = "oval", action = "fill", fillColor = btnBg,
frame = { x = MW - 19, y = 12, w = 17, h = 17 } }
c[12] = { type = "text", text = "P", textSize = 10,
textColor = { red = 0.72, green = 0.52, blue = 1.0, alpha = 1 },
textAlignment = "center", frame = { x = MW - 19, y = 14.5, w = 17, h = 13 } }
c:alpha(0.55)
c:canvasMouseEvents(true, false, false, false)
c:mouseCallback(function(_, event, _, x, y)
if event ~= "mouseDown" then return end
if x <= 22 and mini.act.content then mini.act.content()
elseif x >= MW - 22 and mini.act.grab then mini.act.grab()
elseif mini.act.talk then mini.act.talk() end
end)
mini.canvas = c
end
local function miniTick()
local c = mini.canvas
if not c then return end
mini.phase = mini.phase + 1
local bob = math.sin(mini.phase * 0.12) * 0.8
if mini.phase >= mini.nextBlink then
mini.blinkUntil = mini.phase + 1
mini.nextBlink = mini.phase + 24 + math.random(48)
end
-- rare personality bits: a glance around, an antenna twitch, a tiny hop
if not mini.nextBit then
mini.nextBit = mini.phase + 240 + math.random(720) -- first in 1-4 min
end
if mini.phase >= mini.nextBit then
mini.bit = ({ "look", "twitch", "hop" })[math.random(3)]
mini.bitStart, mini.bitUntil = mini.phase, mini.phase + 10
mini.nextBit = mini.phase + 480 + math.random(960) -- next in 2-6 min
end
local inBit = mini.bit and mini.phase < mini.bitUntil
local lookX = 0
local swayAmp = 1.2
if inBit then
local t = (mini.phase - mini.bitStart) / 10
if mini.bit == "look" then
lookX = math.sin(t * math.pi * 2) * 1.7 -- glance left, right
elseif mini.bit == "twitch" then
swayAmp = 3.4 -- excited antenna
elseif mini.bit == "hop" then
bob = bob - math.sin(t * math.pi) * 3.2 -- one happy hop
end
end
local eh = (mini.phase < mini.blinkUntil) and 0.8 or 4
local ey = 18.5 - eh / 2 + bob
c[1].frame = { x = MOFF + 6, y = 12 + bob, w = 14, h = 15 }
c[2].frame = { x = MOFF + 9 + lookX, y = ey, w = 3, h = eh }
c[3].frame = { x = MOFF + 14 + lookX, y = ey, w = 3, h = eh }
c[4].center = { x = MOFF + 13, y = 22 + bob }
local sway = math.sin(mini.phase * (swayAmp > 2 and 0.5 or 0.08)) * swayAmp
c[5].coordinates = { { x = MOFF + 13, y = 12 + bob },
{ x = MOFF + 13 + sway, y = 8 + bob } }
c[6].frame = { x = MOFF + 11.6 + sway, y = 5.4 + bob, w = 2.8, h = 2.8 }
local ga = eh > 2 and 0.85 or 0
c[7].fillColor = { red = 1, green = 1, blue = 1, alpha = ga }
c[8].fillColor = { red = 1, green = 1, blue = 1, alpha = ga }
c[7].frame = { x = MOFF + 10.7 + lookX, y = ey + eh * 0.15, w = 1.2, h = 1.4 }
c[8].frame = { x = MOFF + 15.7 + lookX, y = ey + eh * 0.15, w = 1.2, h = 1.4 }
end
local function miniShow()
if not C.miniAlien or hud.visible then return end
miniEnsure()
local f = hs.screen.mainScreen():fullFrame()
mini.canvas:frame({ x = f.x + (f.w - MW) / 2, y = f.y + f.h - 34,
w = MW, h = MH })
mini.canvas:show()
if not mini.timer then
mini.timer = hs.timer.doEvery(0.25, safeTick("miniTick", miniTick))
end
end
local function miniHide()
if mini.timer then mini.timer:stop(); mini.timer = nil end
if mini.canvas then mini.canvas:hide() end
end
-- element indices: 1 pill · 2 head · 3/4 eyes · 5 smile · 6 antenna ·
-- 7 antenna tip · 8..15 bars · 16/17 eye glints · 18/19 blush · 20.. smoke
local function hudEnsure()
if hud.canvas then return end
local c = hs.canvas.new({ x = 0, y = 0, w = CV_W, h = CV_H })
c:level(hs.canvas.windowLevels.overlay)
c:behavior({ "canJoinAllSpaces", "stationary" })
c[1] = { type = "rectangle", action = "fill", -- pill
fillColor = { red = 0.04, green = 0.04, blue = 0.09, alpha = 0.6 },
roundedRectRadii = { xRadius = PILL_H / 2, yRadius = PILL_H / 2 },
frame = { x = OX, y = OY, w = PILL_W, h = PILL_H } }
c[2] = { type = "oval", action = "fill", -- head w/ shading
fillGradient = "radial",
fillGradientColors = {
{ red = 0.72, green = 1.0, blue = 0.88, alpha = 1 },
{ red = 0.40, green = 0.90, blue = 0.66, alpha = 1 },
},
fillGradientCenter = { x = -0.35, y = -0.45 },
frame = { x = CV_W / 2 - 7, y = OY + 6, w = 14, h = 15 } }
c[3] = { type = "oval", action = "fill", fillColor = EYES, -- eye L
frame = { x = 0, y = -10, w = 3.4, h = 4.6 } }
c[4] = { type = "oval", action = "fill", fillColor = EYES, -- eye R
frame = { x = 0, y = -10, w = 3.4, h = 4.6 } }
c[5] = { type = "arc", action = "stroke", strokeColor = EYES, -- smile
strokeWidth = 1.1, center = { x = CV_W / 2, y = OY + 16 },
radius = 2.4, startAngle = 135, endAngle = 225 }
c[6] = { type = "segments", action = "stroke", strokeColor = ALIEN, -- antenna
strokeWidth = 1.2,
coordinates = { { x = CV_W / 2, y = OY + 6 }, { x = CV_W / 2, y = OY + 2.5 } } }
c[7] = { type = "oval", action = "fill", fillColor = ALIEN, -- tip
frame = { x = CV_W / 2 - 1.5, y = OY + 0.6, w = 3, h = 3 } }
for i = 1, BARS do
c[i + 7] = { type = "rectangle", action = "fill", -- bars
fillColor = { red = 0.35, green = 0.9, blue = 1.0, alpha = 0.95 },
roundedRectRadii = { xRadius = 1.5, yRadius = 1.5 },
frame = { x = 0, y = -10, w = 3, h = 4 } }
end
for i = 16, 17 do -- glints
c[i] = { type = "oval", action = "fill",
fillColor = { red = 1, green = 1, blue = 1, alpha = 0.9 },
frame = { x = 0, y = -10, w = 1.5, h = 1.8 } }
end
for i = 18, 19 do -- blush
c[i] = { type = "oval", action = "fill",
fillColor = { red = 1.0, green = 0.55, blue = 0.65, alpha = 0.22 },
frame = { x = 0, y = -10, w = 3.6, h = 2.1 } }
end
for i = 0, PUFFS - 1 do -- smoke
c[20 + i] = { type = "oval", action = "fill",
fillColor = { red = 0.86, green = 0.93, blue = 1.0, alpha = 0 },
frame = { x = -30, y = -30, w = 1, h = 1 } }
end
for k = 0, 2 do -- thought dots
c[27 + k] = { type = "oval", action = "fill",
fillColor = { red = 0.72, green = 0.52, blue = 1.0, alpha = 0 },
frame = { x = -10, y = -10, w = 2, h = 2 } }
end
for k = 0, 2 do -- comet trail
c[30 + k] = { type = "oval", action = "fill",
fillColor = { red = 0.8, green = 0.6, blue = 1.0, alpha = 0 },
frame = { x = -10, y = -10, w = 2, h = 2 } }
end
hud.canvas = c
end
local function hudTick()
local c = hud.canvas
if not c then return end
hud.phase = hud.phase + 0.4
-- entrance / exit (pill motion) + smoke puffs
local yOff, alpha, puffT = 0, 1, nil
if hud.anim == "in" then
hud.animT = math.min(1, hud.animT + 0.14)
yOff = (1 - easeOutBack(hud.animT)) * 24
alpha = math.min(1, hud.animT * 2.5)
puffT = hud.animT
if hud.animT >= 1 then hud.anim = nil end
elseif hud.anim == "out" then
hud.animT = math.min(1, hud.animT + 0.16)
yOff, alpha = hud.animT * hud.animT * 20, 1 - hud.animT * 1.1
if alpha < 0 then alpha = 0 end
puffT = hud.animT
if hud.animT >= 1 then
hud.visible = false
if hud.timer then hud.timer:stop(); hud.timer = nil end
c:hide()
miniShow() -- the tiny idle alien takes back the stage
return
end
end
c:frame({ x = hud.baseX, y = hud.baseY + yOff, w = CV_W, h = CV_H })
-- smoke: blooms outward and fades as the pill arrives/leaves
for i = 0, PUFFS - 1 do
local el = c[20 + i]
if puffT then
-- staggered per-puff timing makes the poof feel alive
local pt = math.max(0, math.min(1, puffT * 1.2 - i * 0.045))
local ang = (i / PUFFS) * 6.283 + 0.55
local spread = (18 + (i % 3) * 8) * (0.3 + pt * 1.0)
local px = CV_W / 2 + math.cos(ang) * spread
local py = OY + PILL_H / 2 + math.sin(ang) * spread * 0.55 - pt * 13
local r = 7 + pt * 20 + (i % 3) * 3
el.frame = { x = px - r / 2, y = py - r / 2, w = r, h = r }
el.fillColor = { red = 0.87, green = 0.94, blue = 1.0,
alpha = math.max(0, (1 - pt) * 0.5) }
else
el.fillColor = { red = 0.86, green = 0.93, blue = 1.0, alpha = 0 }
end
end
-- pill + face fade together (elements 1..19 share the canvas alpha via
-- per-element handling being overkill; canvas alpha covers the smoke too,
-- so we fade the pill/face by alpha on the pill and rely on motion)
c:alpha(puffT and alpha or 1)
local working = (hud.mode == "work")
local breathe = working and (0.08 + math.sin(hud.phase * 0.9) * 0.07) or 0
c[1].fillColor = { red = 0.04 + breathe * 0.6, green = 0.04,
blue = 0.09 + breathe, alpha = (0.6 + breathe) * alpha }
-- comet with fading tail orbits the pill while the alien works
for k = 0, 2 do
local el = c[30 + k]
if working then
local a = hud.phase * 1.25 - k * 0.38
local px = OX + PILL_W / 2 + math.cos(a) * (PILL_W / 2 + 7)
local py = OY + PILL_H / 2 + math.sin(a) * (PILL_H / 2 + 6)
local rr = 3.6 - k * 0.9
el.frame = { x = px - rr / 2, y = py - rr / 2, w = rr, h = rr }
el.fillColor = { red = 0.8, green = 0.62, blue = 1.0,
alpha = (0.95 - k * 0.3) * alpha }
else
el.fillColor = { red = 0.8, green = 0.62, blue = 1.0, alpha = 0 }
end
end
-- live voice level (smoothed) drives everything while listening
if hud.mode == "rec" then
hud.level = hud.level * 0.55 + micLevel() * 0.45
end
local norm = math.min(1, hud.level / 0.12)
-- alien: mood-driven face
local eyeW, eyeH, smileR, smileW = 3.4, 4.8, 2.4, 1.1
local bobAmp, bobSpd, antSway = 1.4, 0.45, 1.6
local eyeDart, orbit = 0, false
if hud.mode == "rec" then
eyeH = 4.4 + norm * 2.2
bobAmp = 1.4 + norm * 1.6
if hud.phase >= hud.nextBlink then
hud.blinkUntil = hud.phase + 1.3
hud.nextBlink = hud.phase + 16 + math.random() * 24
end
if hud.phase < hud.blinkUntil then eyeH = 1.1 end
elseif hud.mode == "work" then
-- THINKING looks nothing like listening: antenna spins like a radar,
-- eyes dart side to side, thought dots rise, bars do a KITT sweep
orbit = true
eyeDart = math.sin(hud.phase * 0.35) * 1.4
bobAmp, bobSpd = 0.9, 0.65
elseif hud.mode == "emote" then
if hud.emote == "joy" then -- laughing squint + big grin + bounce
eyeW, eyeH, smileR, smileW = 4.4, 1.5, 3.7, 1.6
bobAmp, bobSpd = 2.8, 1.15
elseif hud.emote == "excite" then -- big sparkly eyes, quick bounce
eyeW, eyeH = 4.6, 6.2
bobAmp, bobSpd = 2.2, 0.9
elseif hud.emote == "curious" then -- wide eyes, antenna swings wondering
eyeH, antSway = 5.6, 4.2
elseif hud.emote == "dance" then -- the keep-warm groove: subtle sway
bobAmp, bobSpd, antSway = 1.9, 0.95, 3.0
smileR = 3.0
end
if hud.phase >= hud.emoteUntil and hud.anim ~= "out" then
hud.anim, hud.animT = "out", 0
end
end
local bob = math.sin(hud.phase * bobSpd) * bobAmp
local sway = (hud.mode == "emote" and hud.emote == "dance")
and math.sin(hud.phase * 0.5) * 3.5 or 0
local ax, ay = CV_W / 2 + sway, OY + 13.5 + bob
c[2].frame = { x = ax - 7, y = ay - 7.5, w = 14, h = 15 }
local eyeCY = ay - 2.2
local ex = ax + eyeDart
c[3].frame = { x = ex - 1.6 - eyeW, y = eyeCY - eyeH / 2, w = eyeW, h = eyeH }
c[4].frame = { x = ex + 1.6, y = eyeCY - eyeH / 2, w = eyeW, h = eyeH }
-- sparkle glints track the eyes (hidden mid-blink / joy-squint)
local glintA = (eyeH > 2.4) and 0.9 or 0
c[16].fillColor = { red = 1, green = 1, blue = 1, alpha = glintA }
c[17].fillColor = { red = 1, green = 1, blue = 1, alpha = glintA }
c[16].frame = { x = ex - 1.6 - eyeW + eyeW * 0.52, y = eyeCY - eyeH / 2 + eyeH * 0.14, w = 1.5, h = 1.8 }
c[17].frame = { x = ex + 1.6 + eyeW * 0.52, y = eyeCY - eyeH / 2 + eyeH * 0.14, w = 1.5, h = 1.8 }
-- blush cheeks, a touch stronger when he's excited or joyful
local blushA = 0.22 + ((hud.emote == "joy" or hud.emote == "excite")
and hud.mode == "emote" and 0.16 or 0)
c[18].fillColor = { red = 1.0, green = 0.55, blue = 0.65, alpha = blushA }
c[19].fillColor = { red = 1.0, green = 0.55, blue = 0.65, alpha = blushA }
c[18].frame = { x = ax - 8.6, y = ay + 1.2, w = 3.6, h = 2.1 }
c[19].frame = { x = ax + 5.0, y = ay + 1.2, w = 3.6, h = 2.1 }
c[5].center = { x = ax, y = ay + 2.2 }
c[5].radius = smileR
c[5].strokeWidth = smileW
-- antenna: radar-spin while thinking, victory twirl when the text lands
local twirl = hud.mode == "emote"
and (hud.phase - (hud.emoteStart or -99)) < 5
local tipX, tipY
if orbit or twirl then
local spd = twirl and 2.4 or 1.1
tipX = ax + math.cos(hud.phase * spd) * (twirl and 5.4 or 4.6)
tipY = ay - 11.5 + math.sin(hud.phase * spd) * (twirl and 3.0 or 2.4)
else
tipX = ax + math.sin(hud.phase * 0.6) * antSway
tipY = ay - 11
end
c[6].coordinates = { { x = ax, y = ay - 7.5 }, { x = tipX, y = tipY + 1 } }
if twirl then -- tip flares white during the twirl
c[7].fillColor = { red = 1, green = 1, blue = 1, alpha = 0.95 }
c[7].frame = { x = tipX - 2, y = tipY - 3.3, w = 4, h = 4 }
else
c[7].fillColor = ALIEN
c[7].frame = { x = tipX - 1.5, y = tipY - 2.8, w = 3, h = 3 }
end
-- thought dots: "..." rising above his head while he works
for k = 0, 2 do
local el = c[27 + k]
if hud.mode == "work" then
local a = math.max(0, math.sin(hud.phase * 0.8 - k * 1.1))
local rr = 1.7 + k * 0.7
el.fillColor = { red = 0.72, green = 0.52, blue = 1.0, alpha = a * 0.85 }
el.frame = { x = ax + 7.5 + k * 4.2 - rr / 2,
y = ay - 10.5 - k * 3.4 - rr / 2, w = rr, h = rr }
else
el.fillColor = { red = 0.72, green = 0.52, blue = 1.0, alpha = 0 }
end
end
-- bars flank the alien; while listening YOUR VOICE sets the height
for i = 1, BARS do
local d = (i <= 4) and (5 - i) or (i - 4) -- distance from alien: 1..4
local h
if hud.mode == "rec" then
local wave = math.abs(math.sin(hud.phase + d * 0.9))
h = 4 + norm * (15 - d * 1.8) * (0.5 + wave * 0.5) + math.random() * 1.5
elseif hud.mode == "emote" then
h = 4 + math.abs(math.sin(hud.phase * 0.7 + d)) * 2.5
else -- thinking: KITT sweep = clearly BUSY
local pos = ((math.sin(hud.phase * 0.55) + 1) / 2) * (BARS - 1) + 1
h = 4 + 13 * math.max(0, 1 - math.abs(i - pos) * 0.55)
end
local x = (i <= 4) and (OX + 12 + (i - 1) * 7)
or (OX + PILL_W - 36 + (i - 5) * 7)
c[i + 7].frame = { x = x, y = OY + (PILL_H - h) / 2, w = 3, h = h }
end
end
local function hudShow(mode)
miniHide()
hudEnsure()