agentix-cli 0.94.1 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/mac-voice/Sources/AgentXVoice/Character.swift +4 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +7 -0
- package/apps/mac-voice/Sources/AgentXVoice/PlayMath.swift +250 -0
- package/apps/mac-voice/Sources/AgentXVoice/PlayRead.swift +98 -0
- package/apps/mac-voice/Sources/AgentXVoice/PlayStage.swift +164 -0
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +14 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +57 -0
- package/apps/mac-voice/build.sh +1 -1
- package/dist/{App-Y62EOIB4.js → App-6PNLFYAK.js} +2 -2
- package/dist/{ChatApp-VIGYSGG4.js → ChatApp-YYBPNTIA.js} +2 -2
- package/dist/{agent-AGBADNWK.js → agent-THOKXUGD.js} +2 -2
- package/dist/{board-dashboard-MHMYOBKA.js → board-dashboard-URZW2L6F.js} +4 -4
- package/dist/{chunk-IWEN3YNQ.js → chunk-24EKSYR7.js} +2 -2
- package/dist/{chunk-IBXHC3ZV.js → chunk-3BD54ASS.js} +171 -171
- package/dist/chunk-3BD54ASS.js.map +1 -0
- package/dist/{chunk-5EOFTPQB.js → chunk-3DB2X334.js} +2 -2
- package/dist/{chunk-DS7CDASP.js → chunk-7V7GH57E.js} +2 -2
- package/dist/{chunk-Q4R4OS5S.js → chunk-AP4HOAUV.js} +2 -2
- package/dist/{chunk-C6PWAEVX.js → chunk-DE4SOF37.js} +2 -2
- package/dist/{chunk-KA6K25T4.js → chunk-FQHY4A5H.js} +2 -2
- package/dist/{chunk-T2IDDOSX.js → chunk-GE3ISITN.js} +2 -2
- package/dist/{chunk-EWE2TJMN.js → chunk-HZJ4S2LT.js} +5 -5
- package/dist/{chunk-HJUBCA7O.js → chunk-IEEZ3HJW.js} +2 -2
- package/dist/{chunk-MIRBKQHA.js → chunk-KE3FB3SK.js} +2 -2
- package/dist/{chunk-VUSDWZ6L.js → chunk-KFMGQ6S7.js} +2 -2
- package/dist/{chunk-VUSDWZ6L.js.map → chunk-KFMGQ6S7.js.map} +1 -1
- package/dist/{chunk-PS3UB5PH.js → chunk-NARH4ZMY.js} +2 -2
- package/dist/{chunk-73NSSC6K.js → chunk-PNSAL5NB.js} +2 -2
- package/dist/{chunk-OQHC7TJL.js → chunk-RQVRKBLH.js} +2 -2
- package/dist/{chunk-BP52VVCW.js → chunk-VJEO7EOS.js} +2 -2
- package/dist/{chunk-5PFTUGP4.js → chunk-VNVKNVGF.js} +2 -2
- package/dist/{chunk-LKBUTSYP.js → chunk-VUUDZFRR.js} +2 -2
- package/dist/{chunk-2MMC5LHF.js → chunk-WFZHADE2.js} +2 -2
- package/dist/{chunk-M56MKNAG.js → chunk-ZGUC3EG6.js} +2 -2
- package/dist/{chunk-74S7PKIX.js → chunk-ZGWDZ7X7.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/{client-RMUEJMV5.js → client-EKGV6UYH.js} +2 -2
- package/dist/{config-M2Z4HTZI.js → config-SF2U4WSJ.js} +2 -2
- package/dist/config-mutator-XTYVB2LB.js +2 -0
- package/dist/{doctor-W6OQ7JI2.js → doctor-3BOJJYBX.js} +2 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1 -1
- package/dist/{program-6G2IS3WF.js → program-T4RVQKES.js} +152 -152
- package/dist/program-T4RVQKES.js.map +1 -0
- package/dist/{promote-YH3MNZFF.js → promote-L2VBS3X2.js} +2 -2
- package/dist/{query-BJC5VVS5.js → query-Y5XOM7X6.js} +2 -2
- package/dist/{registry-EJTSQEBJ.js → registry-ID2KMJCU.js} +2 -2
- package/dist/{request-planner-MKVEPQGA.js → request-planner-LHVZFBYR.js} +2 -2
- package/dist/{review-candidates-OAG3DMQR.js → review-candidates-ZNCQQKQS.js} +2 -2
- package/dist/{routing-U4VI4PAH.js → routing-OAYXRJ7K.js} +2 -2
- package/dist/schedule-tool-K7UHGVQ2.js +2 -0
- package/dist/scheduler-VQFLXG4Y.js +2 -0
- package/dist/{seat-CVOTWLEN.js → seat-Y7QTFQDR.js} +2 -2
- package/dist/{select-FNERAHZI.js → select-OC4FXEIP.js} +2 -2
- package/dist/{set-enabled-3C7AVZ65.js → set-enabled-QBPTP6I4.js} +2 -2
- package/dist/{tool-H7CDGZJ6.js → tool-MEAHEWD2.js} +2 -2
- package/dist/{tool-KR4MDO2R.js → tool-P257FZXB.js} +2 -2
- package/dist/wiki-SZH2KSLQ.js +2 -0
- package/package.json +1 -1
- package/dist/chunk-IBXHC3ZV.js.map +0 -1
- package/dist/config-mutator-L2LCW5ES.js +0 -2
- package/dist/program-6G2IS3WF.js.map +0 -1
- package/dist/schedule-tool-ZTTZBTXA.js +0 -2
- package/dist/scheduler-4RLL7YOW.js +0 -2
- package/dist/wiki-HA3U33SO.js +0 -2
- /package/dist/{App-Y62EOIB4.js.map → App-6PNLFYAK.js.map} +0 -0
- /package/dist/{ChatApp-VIGYSGG4.js.map → ChatApp-YYBPNTIA.js.map} +0 -0
- /package/dist/{agent-AGBADNWK.js.map → agent-THOKXUGD.js.map} +0 -0
- /package/dist/{board-dashboard-MHMYOBKA.js.map → board-dashboard-URZW2L6F.js.map} +0 -0
- /package/dist/{chunk-IWEN3YNQ.js.map → chunk-24EKSYR7.js.map} +0 -0
- /package/dist/{chunk-5EOFTPQB.js.map → chunk-3DB2X334.js.map} +0 -0
- /package/dist/{chunk-DS7CDASP.js.map → chunk-7V7GH57E.js.map} +0 -0
- /package/dist/{chunk-Q4R4OS5S.js.map → chunk-AP4HOAUV.js.map} +0 -0
- /package/dist/{chunk-C6PWAEVX.js.map → chunk-DE4SOF37.js.map} +0 -0
- /package/dist/{chunk-KA6K25T4.js.map → chunk-FQHY4A5H.js.map} +0 -0
- /package/dist/{chunk-T2IDDOSX.js.map → chunk-GE3ISITN.js.map} +0 -0
- /package/dist/{chunk-EWE2TJMN.js.map → chunk-HZJ4S2LT.js.map} +0 -0
- /package/dist/{chunk-HJUBCA7O.js.map → chunk-IEEZ3HJW.js.map} +0 -0
- /package/dist/{chunk-MIRBKQHA.js.map → chunk-KE3FB3SK.js.map} +0 -0
- /package/dist/{chunk-PS3UB5PH.js.map → chunk-NARH4ZMY.js.map} +0 -0
- /package/dist/{chunk-73NSSC6K.js.map → chunk-PNSAL5NB.js.map} +0 -0
- /package/dist/{chunk-OQHC7TJL.js.map → chunk-RQVRKBLH.js.map} +0 -0
- /package/dist/{chunk-BP52VVCW.js.map → chunk-VJEO7EOS.js.map} +0 -0
- /package/dist/{chunk-5PFTUGP4.js.map → chunk-VNVKNVGF.js.map} +0 -0
- /package/dist/{chunk-LKBUTSYP.js.map → chunk-VUUDZFRR.js.map} +0 -0
- /package/dist/{chunk-2MMC5LHF.js.map → chunk-WFZHADE2.js.map} +0 -0
- /package/dist/{chunk-M56MKNAG.js.map → chunk-ZGUC3EG6.js.map} +0 -0
- /package/dist/{chunk-74S7PKIX.js.map → chunk-ZGWDZ7X7.js.map} +0 -0
- /package/dist/{client-RMUEJMV5.js.map → client-EKGV6UYH.js.map} +0 -0
- /package/dist/{config-M2Z4HTZI.js.map → config-SF2U4WSJ.js.map} +0 -0
- /package/dist/{config-mutator-L2LCW5ES.js.map → config-mutator-XTYVB2LB.js.map} +0 -0
- /package/dist/{doctor-W6OQ7JI2.js.map → doctor-3BOJJYBX.js.map} +0 -0
- /package/dist/{promote-YH3MNZFF.js.map → promote-L2VBS3X2.js.map} +0 -0
- /package/dist/{query-BJC5VVS5.js.map → query-Y5XOM7X6.js.map} +0 -0
- /package/dist/{registry-EJTSQEBJ.js.map → registry-ID2KMJCU.js.map} +0 -0
- /package/dist/{request-planner-MKVEPQGA.js.map → request-planner-LHVZFBYR.js.map} +0 -0
- /package/dist/{review-candidates-OAG3DMQR.js.map → review-candidates-ZNCQQKQS.js.map} +0 -0
- /package/dist/{routing-U4VI4PAH.js.map → routing-OAYXRJ7K.js.map} +0 -0
- /package/dist/{schedule-tool-ZTTZBTXA.js.map → schedule-tool-K7UHGVQ2.js.map} +0 -0
- /package/dist/{scheduler-4RLL7YOW.js.map → scheduler-VQFLXG4Y.js.map} +0 -0
- /package/dist/{seat-CVOTWLEN.js.map → seat-Y7QTFQDR.js.map} +0 -0
- /package/dist/{select-FNERAHZI.js.map → select-OC4FXEIP.js.map} +0 -0
- /package/dist/{set-enabled-3C7AVZ65.js.map → set-enabled-QBPTP6I4.js.map} +0 -0
- /package/dist/{tool-H7CDGZJ6.js.map → tool-MEAHEWD2.js.map} +0 -0
- /package/dist/{tool-KR4MDO2R.js.map → tool-P257FZXB.js.map} +0 -0
- /package/dist/{wiki-HA3U33SO.js.map → wiki-SZH2KSLQ.js.map} +0 -0
|
@@ -51,6 +51,10 @@ final class CharacterHost {
|
|
|
51
51
|
private var rest = CGPoint.zero
|
|
52
52
|
/// Its body on screen: where it can be taken hold of.
|
|
53
53
|
private var body = NSRect.zero
|
|
54
|
+
/// Its colours, and the point under it in screen points: play mode
|
|
55
|
+
/// (#505) takes it from there.
|
|
56
|
+
var stops: [NSColor] { view.stops }
|
|
57
|
+
var foot: NSPoint { NSPoint(x: CGFloat(sim.x), y: rest.y) }
|
|
54
58
|
/// Carried by the pointer: where the pointer took hold, and where it
|
|
55
59
|
/// stood then, which is not where it rests once it has stepped aside.
|
|
56
60
|
private var carried: (from: NSPoint, rest: CGPoint)?
|
|
@@ -35,6 +35,13 @@ enum Config {
|
|
|
35
35
|
set { UserDefaults.standard.set(newValue, forKey: "animatedOrb") }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/// "Play mode" in the menu (#505): the character may play on a frozen
|
|
39
|
+
/// picture of the screen when asked. Off by default.
|
|
40
|
+
static var playMode: Bool {
|
|
41
|
+
get { UserDefaults.standard.bool(forKey: "playMode") }
|
|
42
|
+
set { UserDefaults.standard.set(newValue, forKey: "playMode") }
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
/// Where the pill was last dragged to (its bottom-left corner, in
|
|
39
46
|
/// screen points). Nil: the default corner.
|
|
40
47
|
static var pillOrigin: CGPoint? {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Play mode (#505): the character leaves the bottom edge and plays on a
|
|
4
|
+
/// frozen picture of the screen. It walks along a line of text as on a
|
|
5
|
+
/// floor, jumps to another line, eats words and wipes a line. These are
|
|
6
|
+
/// the numbers only: where the words are, what it does and when, where it
|
|
7
|
+
/// is at a given moment and which letters are gone. Foundation only, so
|
|
8
|
+
/// the tests can run it without a window.
|
|
9
|
+
///
|
|
10
|
+
/// Everything is in the picture's points, origin top-left, y downwards.
|
|
11
|
+
enum PlayMath {
|
|
12
|
+
typealias M = CharacterMath
|
|
13
|
+
|
|
14
|
+
struct Point: Equatable { var x = 0.0, y = 0.0 }
|
|
15
|
+
|
|
16
|
+
struct Rect: Equatable {
|
|
17
|
+
var x = 0.0, y = 0.0, w = 0.0, h = 0.0
|
|
18
|
+
var maxX: Double { x + w }
|
|
19
|
+
var maxY: Double { y + h }
|
|
20
|
+
var midX: Double { x + w / 2 }
|
|
21
|
+
var midY: Double { y + h / 2 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
struct Word: Equatable {
|
|
25
|
+
var text: String
|
|
26
|
+
var rect: Rect
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// One line of text as it was read, left to right, and the colour of
|
|
30
|
+
/// the page around it (0xRRGGBB): what shows where a word is gone.
|
|
31
|
+
struct Line: Equatable {
|
|
32
|
+
var words: [Word]
|
|
33
|
+
var rect: Rect
|
|
34
|
+
var paper: UInt32 = 0xFFFFFF
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// One move of a script.
|
|
38
|
+
enum Step: Equatable {
|
|
39
|
+
/// Jump onto the start of a line.
|
|
40
|
+
case jump(line: Int)
|
|
41
|
+
/// Walk along the top of a line to its end.
|
|
42
|
+
case walk(line: Int)
|
|
43
|
+
/// Go through a line and eat these words, letter by letter.
|
|
44
|
+
case eat(line: Int, words: Range<Int>)
|
|
45
|
+
/// Wipe a whole line with a cloth.
|
|
46
|
+
case wipe(line: Int)
|
|
47
|
+
/// Stand still, facing you, for so many seconds.
|
|
48
|
+
case rest(Double)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Points per drawing unit on the page: a body 40 points across,
|
|
52
|
+
/// smaller than on the bottom edge so it fits between lines.
|
|
53
|
+
static let unit = 0.4
|
|
54
|
+
/// The middle of its body above its feet, and its top, in points.
|
|
55
|
+
static let middle = (M.Pose().lift + 50) * unit
|
|
56
|
+
static let top = (M.Pose().lift + 100) * unit + 8
|
|
57
|
+
/// Points a second, and seconds a letter.
|
|
58
|
+
static let walkSpeed = 220.0
|
|
59
|
+
static let wipeSpeed = 260.0
|
|
60
|
+
static let perLetter = 0.09
|
|
61
|
+
/// Its mouth, ahead of its middle.
|
|
62
|
+
static let mouth = 10.0
|
|
63
|
+
|
|
64
|
+
/// A word's letters: the word's rectangle cut into equal parts, one a
|
|
65
|
+
/// character. An estimate, close enough to take them one at a time.
|
|
66
|
+
static func letters(of word: Word) -> [Rect] {
|
|
67
|
+
let n = max(word.text.count, 1), w = word.rect.w / Double(n)
|
|
68
|
+
return (0..<n).map { Rect(x: word.rect.x + Double($0) * w, y: word.rect.y, w: w, h: word.rect.h) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// The colour most of `samples` share: the page around a line. Close
|
|
72
|
+
/// shades count as one, so the soft edge of a letter does not win.
|
|
73
|
+
static func common(_ samples: [UInt32]) -> UInt32? {
|
|
74
|
+
var count: [UInt32: Int] = [:]
|
|
75
|
+
for s in samples { count[s & 0xF8F8F8, default: 0] += 1 }
|
|
76
|
+
guard let best = count.max(by: { ($0.value, $0.key) < ($1.value, $1.key) })?.key else { return nil }
|
|
77
|
+
return samples.first { $0 & 0xF8F8F8 == best }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// The built-in script: the line nearest the middle of the page and
|
|
81
|
+
/// the two under it in the same column. It walks the first, eats the
|
|
82
|
+
/// start of the second and wipes the third. Lines too close to the top
|
|
83
|
+
/// to stand on, too short or of one word are left alone.
|
|
84
|
+
static func demo(_ lines: [Line], width: Double, height: Double) -> [Step] {
|
|
85
|
+
let fit = lines.indices.filter { lines[$0].words.count >= 2 && lines[$0].rect.w >= 120 && lines[$0].rect.y >= top + 40 }
|
|
86
|
+
func far(_ i: Int) -> Double { hypot(lines[i].rect.midX - width / 2, lines[i].rect.midY - height / 2) }
|
|
87
|
+
guard let first = fit.min(by: { far($0) < far($1) }) else { return [] }
|
|
88
|
+
let a = lines[first].rect
|
|
89
|
+
let under = fit.filter { lines[$0].rect.y > a.y + a.h / 2 && lines[$0].rect.x < a.maxX && lines[$0].rect.maxX > a.x }
|
|
90
|
+
.sorted { lines[$0].rect.y < lines[$1].rect.y }
|
|
91
|
+
let picked = [first] + under.prefix(2)
|
|
92
|
+
var steps: [Step] = [.jump(line: first), .walk(line: first)]
|
|
93
|
+
if picked.count > 1 { steps += [.eat(line: picked[1], words: 0..<min(3, lines[picked[1]].words.count))] }
|
|
94
|
+
else { steps += [.eat(line: first, words: 0..<min(2, lines[first].words.count))] }
|
|
95
|
+
if picked.count > 2 { steps += [.wipe(line: picked[2])] }
|
|
96
|
+
return steps + [.rest(1.2)]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// A script laid out in time: every move has its start and its end, so
|
|
101
|
+
/// where the character is, and what is gone, depends on the time alone.
|
|
102
|
+
/// The same script gives the same frames on every run.
|
|
103
|
+
struct Play {
|
|
104
|
+
typealias P = PlayMath
|
|
105
|
+
typealias M = CharacterMath
|
|
106
|
+
|
|
107
|
+
enum Kind { case jump, walk, eat, wipe, rest }
|
|
108
|
+
|
|
109
|
+
struct Move {
|
|
110
|
+
var kind: Kind
|
|
111
|
+
var from, to: P.Point
|
|
112
|
+
var start, duration: Double
|
|
113
|
+
var line = 0
|
|
114
|
+
var words = 0..<0
|
|
115
|
+
var end: Double { start + duration }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
struct Frame {
|
|
119
|
+
/// Its feet.
|
|
120
|
+
var at = P.Point()
|
|
121
|
+
/// -1 turned left, 0 facing you, 1 turned right.
|
|
122
|
+
var face = 0.0
|
|
123
|
+
var pose = M.Pose()
|
|
124
|
+
var t = 0.0
|
|
125
|
+
/// What is covered with the page's colour, and which colour.
|
|
126
|
+
var gone: [(rect: P.Rect, paper: UInt32)] = []
|
|
127
|
+
/// The cloth, while it wipes.
|
|
128
|
+
var cloth: P.Rect?
|
|
129
|
+
var done = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let lines: [P.Line]
|
|
133
|
+
private(set) var moves: [Move] = []
|
|
134
|
+
var duration: Double { moves.last?.end ?? 0 }
|
|
135
|
+
|
|
136
|
+
/// `start` is where its feet are when play begins. Steps that name a
|
|
137
|
+
/// line or a word that is not there are skipped.
|
|
138
|
+
init(lines: [P.Line], steps: [P.Step], start: P.Point) {
|
|
139
|
+
self.lines = lines
|
|
140
|
+
var at = start, now = 0.0
|
|
141
|
+
func add(_ kind: Kind, to: P.Point, _ seconds: Double, line: Int = 0, words: Range<Int> = 0..<0) -> Move {
|
|
142
|
+
Move(kind: kind, from: at, to: to, start: now, duration: seconds, line: line, words: words)
|
|
143
|
+
}
|
|
144
|
+
/// Get to `to` first: a jump, unless it is already there.
|
|
145
|
+
func reach(_ to: P.Point, into list: inout [Move]) {
|
|
146
|
+
guard at != to else { return }
|
|
147
|
+
let m = add(.jump, to: to, 0.5 + hypot(to.x - at.x, to.y - at.y) / 900)
|
|
148
|
+
list.append(m); at = to; now = m.end
|
|
149
|
+
}
|
|
150
|
+
var list: [Move] = []
|
|
151
|
+
for step in steps {
|
|
152
|
+
switch step {
|
|
153
|
+
case .jump(let i):
|
|
154
|
+
guard lines.indices.contains(i) else { continue }
|
|
155
|
+
reach(P.Point(x: lines[i].rect.x, y: lines[i].rect.y), into: &list)
|
|
156
|
+
case .walk(let i):
|
|
157
|
+
guard lines.indices.contains(i) else { continue }
|
|
158
|
+
let r = lines[i].rect
|
|
159
|
+
if at.y != r.y || at.x < r.x || at.x > r.maxX { reach(P.Point(x: r.x, y: r.y), into: &list) }
|
|
160
|
+
let m = add(.walk, to: P.Point(x: r.maxX, y: r.y), (r.maxX - at.x) / P.walkSpeed, line: i)
|
|
161
|
+
list.append(m); at = m.to; now = m.end
|
|
162
|
+
case .eat(let i, let words):
|
|
163
|
+
guard lines.indices.contains(i), !words.isEmpty, words.upperBound <= lines[i].words.count else { continue }
|
|
164
|
+
// In the line, its middle at the middle of the letters.
|
|
165
|
+
let y = lines[i].rect.midY + P.middle
|
|
166
|
+
let first = lines[i].words[words.lowerBound].rect, last = lines[i].words[words.upperBound - 1].rect
|
|
167
|
+
reach(P.Point(x: first.x - P.mouth - 4, y: y), into: &list)
|
|
168
|
+
let count = lines[i].words[words].reduce(0) { $0 + max($1.text.count, 1) }
|
|
169
|
+
let m = add(.eat, to: P.Point(x: last.maxX, y: y), Double(count) * P.perLetter, line: i, words: words)
|
|
170
|
+
list.append(m); at = m.to; now = m.end
|
|
171
|
+
case .wipe(let i):
|
|
172
|
+
guard lines.indices.contains(i) else { continue }
|
|
173
|
+
let r = lines[i].rect
|
|
174
|
+
reach(P.Point(x: r.x, y: r.y), into: &list)
|
|
175
|
+
let m = add(.wipe, to: P.Point(x: r.maxX, y: r.y), r.w / P.wipeSpeed, line: i)
|
|
176
|
+
list.append(m); at = m.to; now = m.end
|
|
177
|
+
case .rest(let seconds):
|
|
178
|
+
let m = add(.rest, to: at, max(seconds, 0))
|
|
179
|
+
list.append(m); now = m.end
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
moves = list
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func frame(at t: Double) -> Frame {
|
|
186
|
+
guard let i = moves.lastIndex(where: { $0.start <= t }) else { return Frame(done: true) }
|
|
187
|
+
let m = moves[i], p = M.span(t, m.start, m.end)
|
|
188
|
+
var f = Frame(t: t, done: t >= duration)
|
|
189
|
+
|
|
190
|
+
f.at = P.Point(x: m.from.x + (m.to.x - m.from.x) * p, y: m.from.y + (m.to.y - m.from.y) * p)
|
|
191
|
+
if m.kind == .jump {
|
|
192
|
+
// An arc over both ends, kept inside the picture.
|
|
193
|
+
let rise = 30 + hypot(m.to.x - m.from.x, m.to.y - m.from.y) * 0.12
|
|
194
|
+
f.at.y -= min(rise, max(min(m.from.y, m.to.y) - P.top, 0)) * 4 * p * (1 - p)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The look of this move, reached from the look of the one before
|
|
198
|
+
// in a fifth of a second: the eyes and the body do not snap.
|
|
199
|
+
let into = M.ease(M.span(t, m.start, m.start + 0.2))
|
|
200
|
+
let before = i > 0 ? look(moves[i - 1]) : look(m)
|
|
201
|
+
let now = look(m)
|
|
202
|
+
f.face = before.face + (now.face - before.face) * into
|
|
203
|
+
f.pose = M.blend(before.pose, now.pose, eyes: into, body: into, marks: into)
|
|
204
|
+
switch m.kind {
|
|
205
|
+
case .walk: f.pose.lift += 3 * into * abs(sin(2 * .pi * t * 2.2))
|
|
206
|
+
case .jump:
|
|
207
|
+
let air = sin(.pi * p)
|
|
208
|
+
f.pose.sy *= 1 + 0.12 * air; f.pose.sx *= 1 - 0.08 * air
|
|
209
|
+
case .eat:
|
|
210
|
+
let bite = into * (0.5 + 0.5 * sin(2 * .pi * t / P.perLetter))
|
|
211
|
+
f.pose.sy *= 1 - 0.08 * bite; f.pose.sx *= 1 + 0.05 * bite
|
|
212
|
+
case .wipe: f.pose.tilt += 5 * into * sin(2 * .pi * t * 5)
|
|
213
|
+
case .rest: break
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for done in moves[...i] {
|
|
217
|
+
let x = done.end <= t ? done.to.x : f.at.x
|
|
218
|
+
switch done.kind {
|
|
219
|
+
case .eat:
|
|
220
|
+
for word in lines[done.line].words[done.words] {
|
|
221
|
+
let gone = P.letters(of: word).filter { $0.maxX <= x + P.mouth }
|
|
222
|
+
guard let last = gone.last else { continue }
|
|
223
|
+
f.gone.append((cover(P.Rect(x: word.rect.x, y: word.rect.y, w: last.maxX - word.rect.x, h: word.rect.h)),
|
|
224
|
+
lines[done.line].paper))
|
|
225
|
+
}
|
|
226
|
+
case .wipe:
|
|
227
|
+
let r = lines[done.line].rect
|
|
228
|
+
if x > r.x { f.gone.append((cover(P.Rect(x: r.x, y: r.y, w: min(x, r.maxX) - r.x, h: r.h)), lines[done.line].paper)) }
|
|
229
|
+
if done.end > t { f.cloth = P.Rect(x: x - 9, y: r.y - 2, w: 18, h: r.h + 4) }
|
|
230
|
+
default: break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return f
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/// A little more than the letters, so nothing of them shows at the edges.
|
|
237
|
+
private func cover(_ r: P.Rect) -> P.Rect { P.Rect(x: r.x - 1, y: r.y - 2, w: r.w + 2, h: r.h + 4) }
|
|
238
|
+
|
|
239
|
+
/// Which way it faces during a move, and its pose without the motion.
|
|
240
|
+
private func look(_ m: Move) -> (face: Double, pose: M.Pose) {
|
|
241
|
+
let way: Double = m.to.x >= m.from.x ? 1 : -1
|
|
242
|
+
switch m.kind {
|
|
243
|
+
case .jump: return (way, M.Pose(tilt: 6 * way, gy: -2))
|
|
244
|
+
case .walk: return (way, M.Pose(tilt: 7 * way))
|
|
245
|
+
case .eat: return (way, M.Pose(size: 1.12))
|
|
246
|
+
case .wipe: return (way, M.Pose(tilt: 12 * way, gy: 4, lid: 0.3))
|
|
247
|
+
case .rest: return (0, M.Pose())
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Vision
|
|
3
|
+
|
|
4
|
+
/// Play mode's stage (#505): one picture of the screen and the lines of
|
|
5
|
+
/// text on it. The picture is read here, on this Mac, by Apple's Vision
|
|
6
|
+
/// framework; it is kept in memory only and nothing is uploaded.
|
|
7
|
+
///
|
|
8
|
+
/// Taking the picture needs the Screen Recording permission. Without it
|
|
9
|
+
/// macOS gives a picture of the desktop alone rather than an error, so the
|
|
10
|
+
/// permission is checked first.
|
|
11
|
+
enum PlayRead {
|
|
12
|
+
static var allowed: Bool { CGPreflightScreenCaptureAccess() }
|
|
13
|
+
|
|
14
|
+
/// Shows the system's request, once; after that macOS only answers no.
|
|
15
|
+
static func ask() { CGRequestScreenCaptureAccess() }
|
|
16
|
+
|
|
17
|
+
/// What is on `screen` now.
|
|
18
|
+
static func picture(of screen: NSScreen) -> CGImage? {
|
|
19
|
+
// Screens are measured from the bottom of the first one; the
|
|
20
|
+
// picture is asked for from its top.
|
|
21
|
+
guard let first = NSScreen.screens.first else { return nil }
|
|
22
|
+
let f = screen.frame
|
|
23
|
+
let rect = CGRect(x: f.minX, y: first.frame.maxY - f.maxY, width: f.width, height: f.height)
|
|
24
|
+
return CGWindowListCreateImage(rect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// The lines of text in `image`, top to bottom, in points of a picture
|
|
28
|
+
/// `size` across with its origin top-left. Slow (a second or so for a
|
|
29
|
+
/// whole screen): call it off the main thread.
|
|
30
|
+
static func lines(in image: CGImage, size: CGSize) -> [PlayMath.Line] {
|
|
31
|
+
let request = VNRecognizeTextRequest()
|
|
32
|
+
request.recognitionLevel = .accurate
|
|
33
|
+
request.recognitionLanguages = ["fr-FR", "en-US"]
|
|
34
|
+
// Where the letters are matters here, not what they spell.
|
|
35
|
+
request.usesLanguageCorrection = false
|
|
36
|
+
guard (try? VNImageRequestHandler(cgImage: image, options: [:]).perform([request])) != nil,
|
|
37
|
+
let found = request.results else { return [] }
|
|
38
|
+
let pixels = Pixels(image, size: size)
|
|
39
|
+
|
|
40
|
+
var lines: [PlayMath.Line] = []
|
|
41
|
+
for observation in found {
|
|
42
|
+
guard let text = observation.topCandidates(1).first else { continue }
|
|
43
|
+
let s = text.string
|
|
44
|
+
var words: [PlayMath.Word] = []
|
|
45
|
+
var i = s.startIndex
|
|
46
|
+
while i < s.endIndex {
|
|
47
|
+
guard !s[i].isWhitespace else { i = s.index(after: i); continue }
|
|
48
|
+
let end = s[i...].firstIndex(where: \.isWhitespace) ?? s.endIndex
|
|
49
|
+
// Vision's boxes are 0…1 from the bottom-left of the image.
|
|
50
|
+
if let b = (try? text.boundingBox(for: i..<end))?.boundingBox {
|
|
51
|
+
words.append(PlayMath.Word(text: String(s[i..<end]),
|
|
52
|
+
rect: PlayMath.Rect(x: b.minX * size.width, y: (1 - b.maxY) * size.height,
|
|
53
|
+
w: b.width * size.width, h: b.height * size.height)))
|
|
54
|
+
}
|
|
55
|
+
i = end
|
|
56
|
+
}
|
|
57
|
+
guard let first = words.first, let last = words.last else { continue }
|
|
58
|
+
let top = words.map(\.rect.y).min()!, bottom = words.map(\.rect.maxY).max()!
|
|
59
|
+
let rect = PlayMath.Rect(x: first.rect.x, y: top, w: last.rect.maxX - first.rect.x, h: bottom - top)
|
|
60
|
+
lines.append(PlayMath.Line(words: words, rect: rect, paper: pixels?.paper(around: rect) ?? 0xFFFFFF))
|
|
61
|
+
}
|
|
62
|
+
return lines.sorted { abs($0.rect.y - $1.rect.y) > 6 ? $0.rect.y < $1.rect.y : $0.rect.x < $1.rect.x }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// The picture at one pixel a point, to read colours from.
|
|
66
|
+
private struct Pixels {
|
|
67
|
+
let data: [UInt8]
|
|
68
|
+
let width, height: Int
|
|
69
|
+
|
|
70
|
+
init?(_ image: CGImage, size: CGSize) {
|
|
71
|
+
let w = Int(size.width), h = Int(size.height)
|
|
72
|
+
guard w > 0, h > 0 else { return nil }
|
|
73
|
+
var bytes = [UInt8](repeating: 0, count: w * h * 4)
|
|
74
|
+
let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in
|
|
75
|
+
guard let ctx = CGContext(data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8,
|
|
76
|
+
bytesPerRow: w * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
|
77
|
+
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return false }
|
|
78
|
+
ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
guard drawn else { return nil }
|
|
82
|
+
data = bytes; width = w; height = h
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// The page's colour around a line: what most points just above
|
|
86
|
+
/// and just under it share.
|
|
87
|
+
func paper(around r: PlayMath.Rect) -> UInt32? {
|
|
88
|
+
var samples: [UInt32] = []
|
|
89
|
+
for y in [Int(r.y) - 3, Int(r.maxY) + 3] where y >= 0 && y < height {
|
|
90
|
+
for x in stride(from: max(Int(r.x), 0), to: min(Int(r.maxX), width), by: 3) {
|
|
91
|
+
let i = (y * width + x) * 4
|
|
92
|
+
samples.append(UInt32(data[i]) << 16 | UInt32(data[i + 1]) << 8 | UInt32(data[i + 2]))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return PlayMath.common(samples)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// What play mode (#505) draws over the picture: the page's colour where
|
|
4
|
+
/// words are gone, the cloth, and the character. The context is flipped,
|
|
5
|
+
/// in the picture's points.
|
|
6
|
+
enum PlayDraw {
|
|
7
|
+
static func draw(_ frame: Play.Frame, in ctx: CGContext, stops: [NSColor]) {
|
|
8
|
+
for gone in frame.gone {
|
|
9
|
+
let c = gone.paper
|
|
10
|
+
ctx.setFillColor(CGColor(srgbRed: CGFloat(c >> 16 & 0xFF) / 255, green: CGFloat(c >> 8 & 0xFF) / 255,
|
|
11
|
+
blue: CGFloat(c & 0xFF) / 255, alpha: 1))
|
|
12
|
+
ctx.fill(cg(gone.rect))
|
|
13
|
+
}
|
|
14
|
+
if let cloth = frame.cloth {
|
|
15
|
+
let path = CGPath(roundedRect: cg(cloth), cornerWidth: 4, cornerHeight: 4, transform: nil)
|
|
16
|
+
ctx.addPath(path)
|
|
17
|
+
ctx.setFillColor(stops[4].cgColor)
|
|
18
|
+
ctx.fillPath()
|
|
19
|
+
ctx.addPath(path)
|
|
20
|
+
ctx.setStrokeColor(stops[1].cgColor)
|
|
21
|
+
ctx.setLineWidth(1)
|
|
22
|
+
ctx.strokePath()
|
|
23
|
+
}
|
|
24
|
+
let shown = CharacterSim.Frame(pose: frame.pose, x: frame.at.x, face: frame.face, t: frame.t)
|
|
25
|
+
CharacterDraw.draw(shown, in: ctx, edge: CGPoint(x: frame.at.x, y: frame.at.y), origin: 0,
|
|
26
|
+
unit: CGFloat(PlayMath.unit), stops: stops)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private static func cg(_ r: PlayMath.Rect) -> CGRect { CGRect(x: r.x, y: r.y, width: r.w, height: r.h) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The stage: a frozen picture of the screen laid over everything, with
|
|
33
|
+
/// the character playing on it. The real page is never touched: nothing
|
|
34
|
+
/// is clicked, typed or changed, and what looks eaten or wiped is only
|
|
35
|
+
/// painted over on the picture.
|
|
36
|
+
///
|
|
37
|
+
/// The page behind goes stale while the picture is up, so play ends on
|
|
38
|
+
/// the first real input: any key, a click, a scroll, another app or Space
|
|
39
|
+
/// coming forward. Unlike the character on its edge, this window does
|
|
40
|
+
/// take keys and clicks, for that one purpose.
|
|
41
|
+
@MainActor
|
|
42
|
+
final class PlayHost {
|
|
43
|
+
private var window: NSPanel?
|
|
44
|
+
private var timer: Timer?
|
|
45
|
+
private var observers: [(NotificationCenter, NSObjectProtocol)] = []
|
|
46
|
+
private var script: Play?
|
|
47
|
+
private var began = 0.0
|
|
48
|
+
/// Raised by each `start`, so a text read that outlives its play can
|
|
49
|
+
/// tell that the picture now up is not the one it read.
|
|
50
|
+
private(set) var number = 0
|
|
51
|
+
/// Told each time a play is over.
|
|
52
|
+
var onEnd: (() -> Void)?
|
|
53
|
+
|
|
54
|
+
var running: Bool { window != nil }
|
|
55
|
+
|
|
56
|
+
/// Lay `picture` over `screen`, the character waiting at `foot` (the
|
|
57
|
+
/// picture's points) until `play(_:)` gives it a script: reading the
|
|
58
|
+
/// text takes a moment, and the page must not move meanwhile.
|
|
59
|
+
func start(picture: CGImage, on screen: NSScreen, stops: [NSColor], at foot: PlayMath.Point) {
|
|
60
|
+
guard !running else { return }
|
|
61
|
+
script = nil
|
|
62
|
+
number += 1
|
|
63
|
+
let panel = PlayWindow(contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel],
|
|
64
|
+
backing: .buffered, defer: false)
|
|
65
|
+
panel.level = .screenSaver
|
|
66
|
+
panel.collectionBehavior = [.fullScreenAuxiliary]
|
|
67
|
+
panel.hasShadow = false
|
|
68
|
+
panel.hidesOnDeactivate = false
|
|
69
|
+
|
|
70
|
+
let bounds = NSRect(origin: .zero, size: screen.frame.size)
|
|
71
|
+
let backdrop = NSView(frame: bounds)
|
|
72
|
+
backdrop.wantsLayer = true
|
|
73
|
+
backdrop.layer?.contents = picture
|
|
74
|
+
let view = PlayView(frame: bounds)
|
|
75
|
+
view.stops = stops
|
|
76
|
+
view.shown = Play.Frame(at: foot, pose: CharacterMath.pose(.working))
|
|
77
|
+
view.onInput = { [weak self] in self?.end("a key or a click") }
|
|
78
|
+
backdrop.addSubview(view)
|
|
79
|
+
panel.contentView = backdrop
|
|
80
|
+
panel.setFrame(screen.frame, display: true)
|
|
81
|
+
window = panel
|
|
82
|
+
panel.makeKeyAndOrderFront(nil)
|
|
83
|
+
panel.makeFirstResponder(view)
|
|
84
|
+
|
|
85
|
+
func watch(_ center: NotificationCenter, _ name: Notification.Name, _ object: Any?, _ why: String) {
|
|
86
|
+
observers.append((center, center.addObserver(forName: name, object: object, queue: .main) { [weak self] _ in
|
|
87
|
+
MainActor.assumeIsolated { self?.end(why) }
|
|
88
|
+
}))
|
|
89
|
+
}
|
|
90
|
+
watch(.default, NSWindow.didResignKeyNotification, panel, "another window came forward")
|
|
91
|
+
watch(NSWorkspace.shared.notificationCenter, NSWorkspace.activeSpaceDidChangeNotification, nil, "the Space changed")
|
|
92
|
+
watch(.default, NSApplication.didChangeScreenParametersNotification, nil, "the screens changed")
|
|
93
|
+
|
|
94
|
+
let shownAt = ProcessInfo.processInfo.systemUptime
|
|
95
|
+
let frames = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self, weak view] _ in
|
|
96
|
+
MainActor.assumeIsolated {
|
|
97
|
+
guard let self, let view else { return }
|
|
98
|
+
let now = ProcessInfo.processInfo.systemUptime
|
|
99
|
+
if let script = self.script {
|
|
100
|
+
view.shown = script.frame(at: now - self.began)
|
|
101
|
+
} else {
|
|
102
|
+
// Its thinking dots hop while it waits.
|
|
103
|
+
view.shown.t = now - shownAt
|
|
104
|
+
}
|
|
105
|
+
view.needsDisplay = true
|
|
106
|
+
if view.shown.done { self.end("the script ended") }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
RunLoop.main.add(frames, forMode: .common)
|
|
110
|
+
timer = frames
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// The script to play on the picture that is up. Nothing happens if
|
|
114
|
+
/// the play has ended meanwhile.
|
|
115
|
+
func play(_ script: Play) {
|
|
116
|
+
guard running, self.script == nil else { return }
|
|
117
|
+
self.script = script
|
|
118
|
+
began = ProcessInfo.processInfo.systemUptime
|
|
119
|
+
Log.info("play: started, \(script.lines.count) lines read, \(String(format: "%.1f", script.duration)) s")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Take the picture away and drop it. Safe to call when no play runs.
|
|
123
|
+
func end(_ why: String) {
|
|
124
|
+
guard let panel = window else { return }
|
|
125
|
+
window = nil
|
|
126
|
+
timer?.invalidate()
|
|
127
|
+
timer = nil
|
|
128
|
+
for (center, observer) in observers { center.removeObserver(observer) }
|
|
129
|
+
observers = []
|
|
130
|
+
panel.orderOut(nil)
|
|
131
|
+
panel.contentView?.layer?.contents = nil
|
|
132
|
+
panel.contentView = nil
|
|
133
|
+
Log.info("play: ended, \(why)")
|
|
134
|
+
onEnd?()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Borderless, and still able to take the keys that end the play.
|
|
139
|
+
private final class PlayWindow: NSPanel {
|
|
140
|
+
override var canBecomeKey: Bool { true }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// The drawing over the picture. Every key, click and scroll ends the play.
|
|
144
|
+
private final class PlayView: NSView {
|
|
145
|
+
var shown = Play.Frame()
|
|
146
|
+
var stops = CharacterDraw.stops(tint: Brand.accent, colors: nil)
|
|
147
|
+
var onInput: (() -> Void)?
|
|
148
|
+
|
|
149
|
+
override var isFlipped: Bool { true }
|
|
150
|
+
override var acceptsFirstResponder: Bool { true }
|
|
151
|
+
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
152
|
+
|
|
153
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
154
|
+
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
|
155
|
+
PlayDraw.draw(shown, in: ctx, stops: stops)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
override func keyDown(with event: NSEvent) { onInput?() }
|
|
159
|
+
override func performKeyEquivalent(with event: NSEvent) -> Bool { onInput?(); return true }
|
|
160
|
+
override func mouseDown(with event: NSEvent) { onInput?() }
|
|
161
|
+
override func rightMouseDown(with event: NSEvent) { onInput?() }
|
|
162
|
+
override func otherMouseDown(with event: NSEvent) { onInput?() }
|
|
163
|
+
override func scrollWheel(with event: NSEvent) { onInput?() }
|
|
164
|
+
}
|
|
@@ -55,6 +55,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
55
55
|
/// Bring a hidden character back, and let its bubble show again.
|
|
56
56
|
var onShowCharacter: (() -> Void)?
|
|
57
57
|
var onSettings: (() -> Void)?
|
|
58
|
+
/// Play mode (#505): whether a play can start now, and start one.
|
|
59
|
+
var playReady: () -> Bool = { false }
|
|
60
|
+
var onPlay: (() -> Void)?
|
|
58
61
|
|
|
59
62
|
override init() {
|
|
60
63
|
super.init()
|
|
@@ -248,6 +251,14 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
248
251
|
let orb = action("Animated orb", #selector(toggleOrb), key: "")
|
|
249
252
|
orb.state = Config.animatedOrb ? .on : .off
|
|
250
253
|
menu.addItem(orb)
|
|
254
|
+
let mode = action("Play mode", #selector(togglePlayMode), key: "")
|
|
255
|
+
mode.state = Config.playMode ? .on : .off
|
|
256
|
+
menu.addItem(mode)
|
|
257
|
+
if Config.playMode {
|
|
258
|
+
let play = action("Play on this page", #selector(startPlay), key: "")
|
|
259
|
+
play.isEnabled = playReady()
|
|
260
|
+
menu.addItem(play)
|
|
261
|
+
}
|
|
251
262
|
// The character hides with its bubble, and has no bubble at rest.
|
|
252
263
|
switch characterVisible() {
|
|
253
264
|
case true?: menu.addItem(action("Hide character", #selector(hidePill), key: ""))
|
|
@@ -313,6 +324,9 @@ final class StatusMenu: NSObject, NSMenuDelegate {
|
|
|
313
324
|
onAnimatedOrbChanged?(Config.animatedOrb)
|
|
314
325
|
}
|
|
315
326
|
|
|
327
|
+
@objc private func togglePlayMode() { Config.playMode.toggle() }
|
|
328
|
+
@objc private func startPlay() { onPlay?() }
|
|
329
|
+
|
|
316
330
|
@objc private func hidePill() { onHidePill?() }
|
|
317
331
|
@objc private func showCharacter() { onShowCharacter?() }
|
|
318
332
|
@objc private func resetPosition() { onResetPosition?() }
|
|
@@ -20,6 +20,8 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
20
20
|
private let statusMenu = StatusMenu()
|
|
21
21
|
/// Stands in for the pill's orb when voice.look is "character".
|
|
22
22
|
private let character = CharacterHost()
|
|
23
|
+
/// The character playing on a frozen picture of the screen (#505).
|
|
24
|
+
private let play = PlayHost()
|
|
23
25
|
/// The agent whose by-name answer is on screen now, while no turn runs.
|
|
24
26
|
private var asideSpeaker: String?
|
|
25
27
|
private let recorder = Recorder()
|
|
@@ -216,6 +218,52 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
216
218
|
Log.info("settings: talk \(saved.general.hotkeys.talk), speech to text \(saved.general.stt)")
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
/// A play can start: the character shows and may move, and the
|
|
222
|
+
/// assistant has nothing to say or hear.
|
|
223
|
+
private var canPlay: Bool {
|
|
224
|
+
character.onScreen == true && Config.animatedOrb
|
|
225
|
+
&& !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
226
|
+
&& !busy && !recorder.isRecording && !play.running
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// "Play on this page" (#505): one picture of the character's screen, the
|
|
230
|
+
/// lines of text read on it, and the character plays the built-in
|
|
231
|
+
/// script on the picture. The page itself is never touched.
|
|
232
|
+
private func startPlay() {
|
|
233
|
+
let foot = character.foot
|
|
234
|
+
guard canPlay, let screen = NSScreen.screens.first(where: { $0.frame.contains(foot) }) ?? NSScreen.screens.first
|
|
235
|
+
else { return }
|
|
236
|
+
guard PlayRead.allowed else {
|
|
237
|
+
PlayRead.ask()
|
|
238
|
+
Log.warn("play: no Screen Recording permission; allow AgentX Voice in System Settings, then quit and reopen it")
|
|
239
|
+
panel.render(.error("Allow Screen Recording"))
|
|
240
|
+
resetSoon()
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
let stops = character.stops, size = screen.frame.size
|
|
244
|
+
let start = PlayMath.Point(x: foot.x - screen.frame.minX, y: screen.frame.maxY - foot.y)
|
|
245
|
+
// Off its edge, so it is not in the picture twice.
|
|
246
|
+
character.setShown(false)
|
|
247
|
+
Task { @MainActor in
|
|
248
|
+
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
249
|
+
guard let picture = PlayRead.picture(of: screen), !busy, !recorder.isRecording else {
|
|
250
|
+
character.setShown(settings?.general.look == "character")
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
play.start(picture: picture, on: screen, stops: stops, at: start)
|
|
254
|
+
let number = play.number
|
|
255
|
+
let asked = ProcessInfo.processInfo.systemUptime
|
|
256
|
+
let lines = await Task.detached { PlayRead.lines(in: picture, size: size) }.value
|
|
257
|
+
Log.info("play: \(lines.count) lines read in \(Int((ProcessInfo.processInfo.systemUptime - asked) * 1000)) ms")
|
|
258
|
+
// Ended while its text was read, and maybe started again since:
|
|
259
|
+
// these lines belong to a picture that is gone.
|
|
260
|
+
guard play.running, play.number == number else { return }
|
|
261
|
+
let steps = PlayMath.demo(lines, width: size.width, height: size.height)
|
|
262
|
+
if steps.isEmpty { play.end("no line of text to play on") }
|
|
263
|
+
else { play.play(Play(lines: lines, steps: steps, start: start)) }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
219
267
|
/// Who the pill names and whose colour its orb wears: the agent a
|
|
220
268
|
/// shortcut is asking, a by-name answer being spoken, else the agent
|
|
221
269
|
/// our turn is asking, else the target.
|
|
@@ -337,6 +385,14 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
337
385
|
}
|
|
338
386
|
panel.onLook = { [weak self] state, tint, colors in
|
|
339
387
|
self?.character.show(state.activity, tint: tint, colors: colors)
|
|
388
|
+
// The assistant has something to show: play gives way.
|
|
389
|
+
if state.activity != .idle { self?.play.end("the assistant is busy") }
|
|
390
|
+
}
|
|
391
|
+
statusMenu.playReady = { [weak self] in self?.canPlay ?? false }
|
|
392
|
+
statusMenu.onPlay = { [weak self] in self?.startPlay() }
|
|
393
|
+
play.onEnd = { [weak self] in
|
|
394
|
+
guard let self else { return }
|
|
395
|
+
self.character.setShown(self.settings?.general.look == "character")
|
|
340
396
|
}
|
|
341
397
|
panel.onShown = { [weak self] in self?.character.redraw() }
|
|
342
398
|
panel.onDismiss = { [weak self] in self?.dismissPill() }
|
|
@@ -548,6 +604,7 @@ final class App: NSObject, NSApplicationDelegate {
|
|
|
548
604
|
// A held key overrides any hands-free window that is open, so the
|
|
549
605
|
// two ways of talking never fight over the microphone.
|
|
550
606
|
stopPolling()
|
|
607
|
+
play.end("the talk key")
|
|
551
608
|
guard !recorder.isRecording else { return }
|
|
552
609
|
// A dismissed pill comes back with the talk key.
|
|
553
610
|
summonPill()
|
package/apps/mac-voice/build.sh
CHANGED
|
@@ -18,7 +18,7 @@ rm -rf build && mkdir -p "$BIN" "$APP/Contents/Resources"
|
|
|
18
18
|
swiftc -O \
|
|
19
19
|
-o "$BIN/AgentXVoice" \
|
|
20
20
|
Sources/AgentXVoice/*.swift \
|
|
21
|
-
-framework AppKit -framework AVFoundation -framework Carbon -framework CoreML -framework SwiftUI \
|
|
21
|
+
-framework AppKit -framework AVFoundation -framework Carbon -framework CoreML -framework SwiftUI -framework Vision \
|
|
22
22
|
-target arm64-apple-macosx14.0
|
|
23
23
|
|
|
24
24
|
# The on-device models from a terminal: fetch them, transcribe a file, see
|