clauddy 1.24.0 → 1.26.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/README.md +46 -1
- package/bin/clauddy.js +12 -2
- package/main.js +154 -3
- package/package.json +3 -1
- package/preload.js +5 -0
- package/reminders.js +53 -0
- package/renderer/companion.js +48 -0
- package/renderer/index.html +37 -3
- package/renderer/pet.js +549 -12
- package/renderer/style.css +533 -31
- package/renderer/voice.js +243 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// ---- the pet's voice ----
|
|
2
|
+
// Two channels. The bubble carries the meaning: a short remark, only on a
|
|
3
|
+
// transition, and only when there's something specific to say — a pet that
|
|
4
|
+
// talks on a timer is Clippy. The sound carries the personality: chiptune
|
|
5
|
+
// blips, one square-wave note per letter, Animal Crossing style. Oscillators
|
|
6
|
+
// only, so no assets, no dependency, and no language baked into the sound.
|
|
7
|
+
;(() => {
|
|
8
|
+
const BASE_HZ = 400
|
|
9
|
+
const SPREAD_HZ = 250
|
|
10
|
+
const STEP_MS = 115
|
|
11
|
+
const MAX_BLIPS = 14 // a sentence is a babble, not a recital
|
|
12
|
+
const GAIN = 0.05
|
|
13
|
+
|
|
14
|
+
// the tone doubles as state: hot is faster and higher, sleepy slower and lower
|
|
15
|
+
const MOODS = {
|
|
16
|
+
normal: { speed: 1, pitch: 1 },
|
|
17
|
+
fire: { speed: 0.72, pitch: 1.25 },
|
|
18
|
+
sleepy: { speed: 1.35, pitch: 0.8 },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// letters and digits each get a fixed pitch, so the same word always sounds
|
|
22
|
+
// the same; anything else is a rest
|
|
23
|
+
function pitchOf(ch) {
|
|
24
|
+
const c = ch.toLowerCase()
|
|
25
|
+
if (c >= 'a' && c <= 'z') return BASE_HZ + ((c.charCodeAt(0) - 97) / 25) * SPREAD_HZ
|
|
26
|
+
if (c >= '0' && c <= '9') return BASE_HZ + ((c.charCodeAt(0) - 48) / 9) * SPREAD_HZ
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// the schedule for a line: [{ at, hz, dur }] in ms, pure so it can be tested
|
|
31
|
+
function blipPlan(text, mood = 'normal') {
|
|
32
|
+
const m = MOODS[mood] || MOODS.normal
|
|
33
|
+
const step = STEP_MS * m.speed
|
|
34
|
+
const plan = []
|
|
35
|
+
let t = 0
|
|
36
|
+
for (const ch of String(text)) {
|
|
37
|
+
if (plan.length >= MAX_BLIPS) break
|
|
38
|
+
const hz = pitchOf(ch)
|
|
39
|
+
if (hz != null) {
|
|
40
|
+
plan.push({ at: Math.round(t), hz: Math.round(hz * m.pitch), dur: Math.round(step * 0.6) })
|
|
41
|
+
t += step
|
|
42
|
+
} else if (/[.,!?—:;]/.test(ch)) {
|
|
43
|
+
t += step * 1.5 // a breath on punctuation
|
|
44
|
+
} else if (ch === ' ') {
|
|
45
|
+
t += step * 0.4
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return plan
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `makeCtx` builds the AudioContext lazily: creating one up front would hold
|
|
52
|
+
// an audio device open for a pet that, by default, never makes a sound
|
|
53
|
+
function createBlipper(makeCtx) {
|
|
54
|
+
let ctx = null
|
|
55
|
+
return {
|
|
56
|
+
play(text, mood) {
|
|
57
|
+
try {
|
|
58
|
+
ctx = ctx || makeCtx()
|
|
59
|
+
if (!ctx) return 0
|
|
60
|
+
if (ctx.state === 'suspended') ctx.resume?.()
|
|
61
|
+
const plan = blipPlan(text, mood)
|
|
62
|
+
const t0 = ctx.currentTime + 0.02
|
|
63
|
+
for (const b of plan) {
|
|
64
|
+
const start = t0 + b.at / 1000
|
|
65
|
+
const end = start + b.dur / 1000
|
|
66
|
+
const osc = ctx.createOscillator()
|
|
67
|
+
const gain = ctx.createGain()
|
|
68
|
+
osc.type = 'square'
|
|
69
|
+
osc.frequency.setValueAtTime(b.hz, start)
|
|
70
|
+
// a quick attack and decay, or every note clicks
|
|
71
|
+
gain.gain.setValueAtTime(0, start)
|
|
72
|
+
gain.gain.linearRampToValueAtTime(GAIN, start + 0.008)
|
|
73
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, end)
|
|
74
|
+
osc.connect(gain)
|
|
75
|
+
gain.connect(ctx.destination)
|
|
76
|
+
osc.start(start)
|
|
77
|
+
osc.stop(end + 0.01)
|
|
78
|
+
}
|
|
79
|
+
return plan.length
|
|
80
|
+
} catch {
|
|
81
|
+
return 0 // no audio device, or a context the OS took away: stay quiet
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- remarks ----
|
|
88
|
+
// Each returns { text, short }, or null when there's nothing worth saying.
|
|
89
|
+
// `text` is a full, spoken sentence for the scene; `short` is the glance the
|
|
90
|
+
// mini face has room for. Numbers arrive preformatted through `fmt` / `fmtDur`
|
|
91
|
+
// so this file stays free of the widget.
|
|
92
|
+
const line = (text, short) => ({ text, short: short || text })
|
|
93
|
+
|
|
94
|
+
// the small hours get their own hello: "Good morning!" at 2am rings false
|
|
95
|
+
const LATE_UNTIL = 5
|
|
96
|
+
function salutation(hour) {
|
|
97
|
+
if (hour < LATE_UNTIL) return 'Still up?'
|
|
98
|
+
return hour < 12 ? 'Good morning!' : hour < 18 ? 'Good afternoon!' : 'Good evening!'
|
|
99
|
+
}
|
|
100
|
+
function hello(hour) {
|
|
101
|
+
if (hour < LATE_UNTIL) return 'Up late!'
|
|
102
|
+
return hour < 12 ? 'Morning!' : hour < 18 ? 'Afternoon!' : 'Evening!'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `days` is the 30-day series, oldest first, today last
|
|
106
|
+
function greetingLine(days, hour, fmt) {
|
|
107
|
+
if (!Array.isArray(days) || days.length < 2) return null
|
|
108
|
+
const y = days[days.length - 2]
|
|
109
|
+
if (!y) return null
|
|
110
|
+
const week = days.slice(-8, -1) // the seven days before today, yesterday included
|
|
111
|
+
const worked = week.filter((v) => v > 0)
|
|
112
|
+
const hi = salutation(hour)
|
|
113
|
+
const short = `${hello(hour)} ${fmt(y)} yesterday.`
|
|
114
|
+
if (worked.length > 1 && y >= Math.max(...week))
|
|
115
|
+
return line(
|
|
116
|
+
`${hi} Yesterday was your heaviest day this week, ${fmt(y)} tokens.`,
|
|
117
|
+
`${hello(hour)} Big day yesterday.`,
|
|
118
|
+
)
|
|
119
|
+
const avg = worked.reduce((n, v) => n + v, 0) / (worked.length || 1)
|
|
120
|
+
if (worked.length > 2 && y < avg * 0.5)
|
|
121
|
+
return line(`${hi} Yesterday was a quiet one, just ${fmt(y)} tokens.`, short)
|
|
122
|
+
return line(`${hi} You spent ${fmt(y)} tokens yesterday.`, short)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fireLine(pct, etaMs, resetMs, fmtDur) {
|
|
126
|
+
const p = Math.round(pct)
|
|
127
|
+
if (etaMs != null)
|
|
128
|
+
return line(
|
|
129
|
+
`Getting warm, ${p}% already. At this pace you'll run out in about ${fmtDur(etaMs)}.`,
|
|
130
|
+
`${p}%, getting warm!`,
|
|
131
|
+
)
|
|
132
|
+
if (resetMs != null)
|
|
133
|
+
return line(
|
|
134
|
+
`Getting warm, ${p}% already. It resets in ${fmtDur(resetMs)}.`,
|
|
135
|
+
`${p}%, getting warm!`,
|
|
136
|
+
)
|
|
137
|
+
return line(`Getting warm, ${p}% already.`, `${p}%, getting warm!`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resetLine(was) {
|
|
141
|
+
return line(`Fresh window! The last one closed at ${Math.round(was)}%.`, 'Fresh window!')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// what each activity was, said the way you'd say it
|
|
145
|
+
const DOING = {
|
|
146
|
+
editing: 'mostly editing code',
|
|
147
|
+
reading: 'mostly reading',
|
|
148
|
+
planning: 'mostly planning',
|
|
149
|
+
running: 'mostly running commands',
|
|
150
|
+
researching: 'mostly researching',
|
|
151
|
+
delegating: 'mostly delegating to agents',
|
|
152
|
+
waiting: 'mostly waiting on you',
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// the recap of the window that just closed (#31), built from whatever is
|
|
156
|
+
// known, so a pet launched mid-session still says something true
|
|
157
|
+
function recapLine({ was, tokens, activeMs, top }, fmt, fmtDur) {
|
|
158
|
+
if (!(tokens > 0)) return resetLine(was ?? 0)
|
|
159
|
+
let text = `Fresh window! That was ${fmt(tokens)} tokens`
|
|
160
|
+
if (activeMs >= 60000) text += ` over ${fmtDur(activeMs)}`
|
|
161
|
+
if (DOING[top]) text += `, ${DOING[top]}`
|
|
162
|
+
return line(`${text}.`, `Fresh window! ${fmt(tokens)} last time.`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Codex speaks through the same pet, so its lines always say whose they are
|
|
166
|
+
function codexFireLine(pct, resetMs, fmtDur) {
|
|
167
|
+
const p = Math.round(pct)
|
|
168
|
+
const text =
|
|
169
|
+
resetMs != null
|
|
170
|
+
? `Codex is at ${p}% now. It resets in ${fmtDur(resetMs)}.`
|
|
171
|
+
: `Codex is at ${p}% now.`
|
|
172
|
+
return line(text, `Codex at ${p}%!`)
|
|
173
|
+
}
|
|
174
|
+
function codexMaxedLine(resetAt) {
|
|
175
|
+
return line(
|
|
176
|
+
resetAt ? `Codex is maxed out. It's back at ${resetAt}.` : 'Codex is maxed out.',
|
|
177
|
+
'Codex is maxed out.',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
function codexResetLine(was) {
|
|
181
|
+
return line(
|
|
182
|
+
`Codex has a fresh window! The last one closed at ${Math.round(was)}%.`,
|
|
183
|
+
'Codex: fresh window!',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function maxedLine(resetAt) {
|
|
188
|
+
return resetAt
|
|
189
|
+
? line(`That's the limit. I'll be back at ${resetAt}.`, `Maxed out till ${resetAt}.`)
|
|
190
|
+
: line("That's the limit. Nap time.", 'Maxed out.')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function welcomeLine(awayMs, pct, fmtDur) {
|
|
194
|
+
const away = fmtDur(awayMs)
|
|
195
|
+
const text =
|
|
196
|
+
pct != null
|
|
197
|
+
? `Welcome back! You were gone ${away}. The session's at ${Math.round(pct)}%.`
|
|
198
|
+
: `Welcome back! You were gone ${away}.`
|
|
199
|
+
return line(text, 'Welcome back!')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function streakLine(ms, fmtDur) {
|
|
203
|
+
return line(`You've been at it for ${fmtDur(ms)} straight. Stretch break?`, 'Stretch break?')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// a record only counts against a real history, and past a floor that makes
|
|
207
|
+
// it more than noise
|
|
208
|
+
const RECORD_FLOOR = 1e6
|
|
209
|
+
function recordLine(days, fmt) {
|
|
210
|
+
if (!Array.isArray(days) || days.length < 8) return null
|
|
211
|
+
const today = days[days.length - 1]
|
|
212
|
+
const before = days.slice(0, -1)
|
|
213
|
+
const best = Math.max(...before)
|
|
214
|
+
if (best <= 0 || today < RECORD_FLOOR || today <= best) return null
|
|
215
|
+
return line(
|
|
216
|
+
`New record! ${fmt(today)} tokens today, your biggest day in a month.`,
|
|
217
|
+
`New record: ${fmt(today)}!`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const api = {
|
|
222
|
+
BASE_HZ,
|
|
223
|
+
SPREAD_HZ,
|
|
224
|
+
STEP_MS,
|
|
225
|
+
MAX_BLIPS,
|
|
226
|
+
pitchOf,
|
|
227
|
+
blipPlan,
|
|
228
|
+
createBlipper,
|
|
229
|
+
greetingLine,
|
|
230
|
+
fireLine,
|
|
231
|
+
resetLine,
|
|
232
|
+
recapLine,
|
|
233
|
+
codexFireLine,
|
|
234
|
+
codexMaxedLine,
|
|
235
|
+
codexResetLine,
|
|
236
|
+
maxedLine,
|
|
237
|
+
welcomeLine,
|
|
238
|
+
streakLine,
|
|
239
|
+
recordLine,
|
|
240
|
+
}
|
|
241
|
+
if (typeof module === 'object' && module.exports) module.exports = api
|
|
242
|
+
else globalThis.Voice = api
|
|
243
|
+
})()
|