Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const sharedOverlay = {
startPurr: 'readonly', stopPurr: 'readonly', playChirp: 'readonly', playMrrp: 'readonly',
// effects.js provides these:
drawThinkBubble: 'readonly', drawWorkBubble: 'readonly', drawDoneSpark: 'readonly', drawHeart: 'readonly',
drawGuitar: 'readonly', drawNote: 'readonly',
};

const CONSUMER_OVERLAY = ['src/renderer.js', 'src/settings-renderer.js', 'src/cat-preview.js'];
Expand All @@ -30,7 +31,7 @@ module.exports = [
{
// Node / CommonJS: main process, workers, scripts, tests, configs, template.js
files: ['**/*.js'],
ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/patterns.js', 'src/audio.js', 'src/effects.js'],
ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/patterns.js', 'src/audio.js', 'src/effects.js', 'src/jam.js'],
languageOptions: { sourceType: 'commonjs', ecmaVersion: 2023, globals: { ...globals.node } },
},
{
Expand All @@ -48,6 +49,12 @@ module.exports = [
files: ['src/effects.js'],
languageOptions: { sourceType: 'script', ecmaVersion: 2023, globals: { ...globals.browser, ctx: 'readonly' } },
},
{
// jam.js: classic overlay <script> ("Lobby Jam" synth) that REUSES audio.js's shared
// AudioContext (audio()) and routes through the shared `master` gain.
files: ['src/jam.js'],
languageOptions: { sourceType: 'script', ecmaVersion: 2023, globals: { ...globals.browser, audio: 'readonly', master: 'readonly' } },
},
{
// cat-sprite.js / patterns.js are dual-loaded: classic <script> in the overlay AND
// CommonJS modules in Node (make-app-icon.js / main.js). They DEFINE shared globals.
Expand Down
338 changes: 338 additions & 0 deletions extras/lobby-jam.html

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const DEFAULTS = {
pinnedNote: '', // fixed message pinned above the cat's head ('' = off)
notifyOn: true, // also pop a Windows toast for reminders/messages
pomodoro: { on: false, focusMin: 25, breakMin: 5 }, // focus/break loops + floating pixel timer
lobbyJam: { on: false, mood: 'cozy' }, // synthesized lo-fi "study music" the cat plays (cozy/dreamy/upbeat)
reminders: [], // [{ id, hhmm: 'HH:MM', message, recur, days, lastFired }]
email: { on: false, host: '', port: 993, user: '', secure: true, intervalMin: 5 }, // IMAP unread alerts (app-password stored separately, encrypted)
calendar: { on: false, icsUrl: '', leadMin: 10 }, // nudge before events from a secret .ics URL
Expand Down Expand Up @@ -81,6 +82,10 @@ function normalize(cfg) {
const p = (c.pomodoro && typeof c.pomodoro === 'object') ? c.pomodoro : {};
return { on: !!p.on, focusMin: clampInt(p.focusMin, 5, 120, 25), breakMin: clampInt(p.breakMin, 1, 60, 5) };
})(),
lobbyJam: (() => {
const lj = (c.lobbyJam && typeof c.lobbyJam === 'object') ? c.lobbyJam : {};
return { on: !!lj.on, mood: ['cozy', 'dreamy', 'upbeat'].includes(lj.mood) ? lj.mood : 'cozy' };
})(),
email: (() => {
const e = (c.email && typeof c.email === 'object') ? c.email : {};
const port = clampInt(e.port, 1, 65535, 993);
Expand Down
42 changes: 41 additions & 1 deletion src/effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// spinner, "done!" burst, love heart). Classic <script> loaded before renderer.js,
// sharing the overlay global scope; draws on the shared canvas context `ctx`.
// Extracted from renderer.js to keep that file focused on the main loop.
/* exported drawThinkBubble, drawWorkBubble, drawDoneSpark, drawHeart */
/* exported drawThinkBubble, drawWorkBubble, drawDoneSpark, drawHeart, drawGuitar, drawNote */

// Thinking indicator: three dots that pulse near the head (AI agent working).
function drawThinkBubble(x, y, t) {
Expand Down Expand Up @@ -58,3 +58,43 @@ function drawHeart(x, y, color, alpha, s) {
r(-4, 2, 8, 2); r(-2, 4, 4, 2); r(-1, 6, 2, 1); // taper to a point
ctx.globalAlpha = 1;
}
// A small acoustic guitar held across the cat's lap while the Lobby Jam plays; the
// strumming paw bobs with `phase` (0..1 within the beat). Drawn in screen coords.
function drawGuitar(x, y, phase) {
ctx.save();
ctx.translate(Math.round(x), Math.round(y));
ctx.rotate(-0.5);
const e = (cx, cy, rx, ry, col) => { ctx.fillStyle = col; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fill(); };
ctx.fillStyle = '#5a3a1c'; ctx.fillRect(-31, -4, 21, 4.5); // neck
ctx.fillStyle = '#3a2410'; ctx.fillRect(-35, -5.5, 5, 7); // headstock
ctx.fillStyle = '#e6d199'; ctx.fillRect(-34, -4.5, 2, 1); ctx.fillRect(-34, -1.5, 2, 1); // tuning pegs
e(-2, 0, 13, 10, '#6e4220'); e(-13, -2, 9, 7, '#6e4220'); // body outline (two bouts)
e(-2, 0, 11.4, 8.6, '#bb7831'); e(-13, -2, 7.6, 5.8, '#bb7831'); // wood
e(-6, -3, 5, 3.4, '#db944b'); // top-left sheen
e(-3, 0, 3, 2.5, '#21130a'); // soundhole
ctx.strokeStyle = '#efe2c0'; ctx.globalAlpha = 0.7; ctx.lineWidth = 0.6;
for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.moveTo(-31, -3 + i * 1.1); ctx.lineTo(6, 1 + i * 1.5); ctx.stroke(); } // strings
ctx.globalAlpha = 1;
ctx.fillStyle = '#3a2410'; ctx.fillRect(4, -1, 3, 3); // bridge
const sp = Math.sin(phase * Math.PI * 2) * 2.4; // strumming paw
e(2, 2 + sp, 3.2, 2.6, '#2c2230'); e(2, 2 + sp, 2.4, 1.9, '#3b3046');
ctx.fillStyle = '#d2a6cf'; ctx.fillRect(0, 1 + sp, 1, 1); ctx.fillRect(3, 1 + sp, 1, 1); // toe beans
ctx.restore();
}
// A floating music note (♪, or ♫ when `kind`). Soft purple, like the thinking dots.
function drawNote(x, y, alpha, kind) {
ctx.save();
ctx.globalAlpha = alpha; ctx.fillStyle = '#c6a6e4';
x = Math.round(x); y = Math.round(y);
ctx.fillRect(x + 3, y - 7, 1.4, 8); // stem
ctx.beginPath(); ctx.ellipse(x + 2, y + 1, 2.3, 1.7, -0.3, 0, Math.PI * 2); ctx.fill(); // note head
if (kind) {
ctx.fillRect(x + 7, y - 8, 1.4, 8);
ctx.beginPath(); ctx.ellipse(x + 6, y, 2.3, 1.7, -0.3, 0, Math.PI * 2); ctx.fill();
ctx.fillRect(x + 4, y - 8, 4, 1.4); // beam (♫)
} else {
ctx.fillRect(x + 4, y - 7, 3, 1.3); // flag (♪)
}
ctx.globalAlpha = 1;
ctx.restore();
}
1 change: 1 addition & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<script src="climb-frames.js"></script>
<script src="audio.js"></script>
<script src="effects.js"></script>
<script src="jam.js"></script>
<script src="renderer.js"></script>
</body>
</html>
168 changes: 168 additions & 0 deletions src/jam.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// src/jam.js — "Lobby Jam": a synthesized, improvising lo-fi study-music loop the cat
// plays. 100% Web Audio (no asset files): Karplus-Strong plucked guitar over lazy jazzy
// voicings + soft bass + brushed percussion, tape-warmed. Classic overlay <script>
// loaded after audio.js — it REUSES that file's shared AudioContext (audio()) and routes
// through the shared `master` gain, so it respects the Volume slider and mixes with
// meow/purr. Exposes window.jamStart(mood) / jamStop() / jamSetMood(mood) / jamBeatPhase().
(() => {
'use strict';
const rnd = (a, b) => a + Math.random() * (b - a);
const pick = (a) => a[(Math.random() * a.length) | 0];
const chance = (p) => Math.random() < p;
const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12);

// chord vocabulary (MIDI sets) + a loose ii-V-I "lobby jazz" progression graph
const CHORDS = {
Cmaj7: [60, 64, 67, 71], Am7: [57, 60, 64, 67], Dm7: [62, 65, 69, 72],
G7: [55, 59, 62, 65], Fmaj7: [53, 57, 60, 64], Em7: [52, 55, 59, 62],
A7: [57, 61, 64, 67], Bm7b5: [59, 62, 65, 69],
};
const NEXT = {
Cmaj7: ['Am7', 'Dm7', 'Fmaj7', 'Em7'], Am7: ['Dm7', 'Fmaj7', 'A7', 'Em7'],
Dm7: ['G7', 'Bm7b5', 'Fmaj7'], G7: ['Cmaj7', 'Em7', 'Am7'],
Fmaj7: ['G7', 'Em7', 'Dm7', 'Bm7b5'], Em7: ['Am7', 'A7', 'Dm7'],
A7: ['Dm7', 'Fmaj7'], Bm7b5: ['G7', 'Em7'],
};
const MEL = [0, 2, 4, 7, 9, 12, 14, 16, 11]; // C-major-ish melody pool
const MOODS = {
cozy: { bpm: 74, swing: 0.16, mel: 0.45, rev: 0.30, bright: 0.5 },
dreamy: { bpm: 62, swing: 0.10, mel: 0.30, rev: 0.46, bright: 0.38 },
upbeat: { bpm: 92, swing: 0.22, mel: 0.60, rev: 0.22, bright: 0.62 },
};

let ac = null, jamBus = null, busInput = null, wetGain = null;
let running = false, mood = MOODS.cozy, timer = null;
let nextTime = 0, beat = 0, cur = 'Cmaj7', t0 = 0;
const LOOKAHEAD = 0.12, TICK = 25;
const ksCache = new Map();

function makeSatCurve(k) {
const n = 1024, c = new Float32Array(n);
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; c[i] = Math.tanh(x * (1 + k * 2)) / Math.tanh(1 + k * 2); }
return c;
}
function makeImpulse(dur, decay) {
const sr = ac.sampleRate, len = (sr * dur) | 0, buf = ac.createBuffer(2, len, sr);
for (let ch = 0; ch < 2; ch++) { const d = buf.getChannelData(ch); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); }
return buf;
}
// master FX chain: compressor -> lowpass(+wow) -> soft saturation -> dry + reverb -> jamBus -> shared master
function buildGraph() {
jamBus = ac.createGain(); jamBus.gain.value = 0.0001;
const comp = ac.createDynamicsCompressor();
comp.threshold.value = -20; comp.ratio.value = 3; comp.attack.value = 0.01; comp.release.value = 0.25;
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600; lp.Q.value = 0.4;
const wow = ac.createOscillator(), wowAmt = ac.createGain();
wow.frequency.value = 0.18; wowAmt.gain.value = 220; wow.connect(wowAmt).connect(lp.frequency); wow.start();
const sat = ac.createWaveShaper(); sat.curve = makeSatCurve(0.6); sat.oversample = '2x';
const dry = ac.createGain(); dry.gain.value = 0.82;
wetGain = ac.createGain(); wetGain.gain.value = mood.rev;
const conv = ac.createConvolver(); conv.buffer = makeImpulse(2.6, 2.4);
comp.connect(lp); lp.connect(sat);
sat.connect(dry); sat.connect(conv); conv.connect(wetGain);
dry.connect(jamBus); wetGain.connect(jamBus);
jamBus.connect(master); // shared overlay master gain (Volume) from audio.js
busInput = comp;
// faint vinyl crackle (routes through jamBus so it mutes when the jam stops)
const sr = ac.sampleRate, clen = sr * 2, cbuf = ac.createBuffer(1, clen, sr), cd = cbuf.getChannelData(0);
for (let i = 0; i < clen; i++) cd[i] = chance(0.0012) ? (Math.random() * 2 - 1) * rnd(0.3, 1) : 0;
const cs = ac.createBufferSource(); cs.buffer = cbuf; cs.loop = true;
const cg = ac.createGain(); cg.gain.value = 0.05;
const chp = ac.createBiquadFilter(); chp.type = 'highpass'; chp.frequency.value = 1400;
cs.connect(chp).connect(cg).connect(jamBus); cs.start();
}
// Karplus-Strong plucked string rendered into a cached AudioBuffer
function ksBuffer(freq, dur, bright) {
const key = Math.round(freq) + ':' + dur.toFixed(2) + ':' + bright.toFixed(2);
if (ksCache.has(key)) return ksCache.get(key);
const sr = ac.sampleRate, N = Math.max(2, Math.round(sr / freq)), len = Math.floor(sr * dur);
const buf = ac.createBuffer(1, len, sr), out = buf.getChannelData(0), line = new Float32Array(N);
for (let i = 0; i < N; i++) line[i] = Math.random() * 2 - 1;
const decay = 0.49 + 0.009 * bright; // loop gain stays < 0.5 -> stable; brighter = longer sustain
let idx = 0;
for (let i = 0; i < len; i++) { const a = line[idx], b = line[(idx + 1) % N]; out[i] = a; line[idx] = (a + b) * decay; idx = (idx + 1) % N; }
const atk = (sr * 0.004) | 0, rel = (sr * 0.18) | 0;
for (let i = 0; i < len; i++) { let e = 1; if (i < atk) e = i / atk; if (i > len - rel) e *= (len - i) / rel; out[i] *= e; }
if (ksCache.size > 220) ksCache.clear();
ksCache.set(key, buf);
return buf;
}
function pluck(midi, when, gain, dur, bright, pan) {
const s = ac.createBufferSource(); s.buffer = ksBuffer(mtof(midi), dur, bright);
const g = ac.createGain(); g.gain.value = gain;
const p = ac.createStereoPanner(); p.pan.value = pan;
s.connect(g).connect(p).connect(busInput); s.start(when);
}
function bassNote(midi, when, dur) {
const o = ac.createOscillator(); o.type = 'triangle'; o.frequency.value = mtof(midi);
const g = ac.createGain();
g.gain.setValueAtTime(0, when); g.gain.linearRampToValueAtTime(0.16, when + 0.02); g.gain.exponentialRampToValueAtTime(0.001, when + dur);
const f = ac.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 420;
o.connect(f).connect(g).connect(busInput); o.start(when); o.stop(when + dur + 0.05);
}
function brush(when) {
const sr = ac.sampleRate, len = (sr * 0.09) | 0, b = ac.createBuffer(1, len, sr), d = b.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.5);
const s = ac.createBufferSource(); s.buffer = b;
const f = ac.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 6000; f.Q.value = 0.6;
const g = ac.createGain(); g.gain.value = 0.05;
s.connect(f).connect(g).connect(busInput); s.start(when);
}
function voiced(name) { return CHORDS[name].map((m, i) => m + (i > 1 && chance(0.18) ? 12 : 0)); }
function scheduleBeat(name, t, spb) {
const ch = voiced(name), onBeat = beat % 2 === 0;
if (onBeat || chance(0.6)) {
const strum = onBeat ? rnd(0.012, 0.03) : rnd(0.006, 0.016);
const notes = chance(0.3) ? [...ch].reverse() : ch;
notes.forEach((m, i) => pluck(m, t + i * strum, (onBeat ? 0.12 : 0.07) * rnd(0.85, 1.05), rnd(1.1, 1.8), mood.bright * rnd(0.85, 1.1), rnd(-0.25, 0.25)));
}
if (onBeat) { const root = ch[0] - 12; bassNote(chance(0.7) ? root : root + pick([7, 5, 3]), t, spb * (chance(0.5) ? 2 : 1) * 0.95); }
if (chance(0.8)) brush(t + spb * 0.5 + (chance(0.5) ? mood.swing * spb : 0));
if (onBeat && chance(0.5)) brush(t);
if (chance(mood.mel)) {
const n = 1 + ((Math.random() * 3) | 0);
for (let i = 0; i < n; i++) { const note = ch[0] + pick(MEL) + (chance(0.4) ? 12 : 0); const off = (i / n) * spb + (chance(0.5) ? mood.swing * spb * 0.5 : 0); pluck(note, t + off, 0.085 * rnd(0.8, 1.1), rnd(0.5, 1.0), mood.bright * 1.15, rnd(-0.4, 0.4)); }
}
}
function scheduler() {
if (!ac || !running) return;
const spb = 60 / mood.bpm;
while (nextTime < ac.currentTime + LOOKAHEAD) {
scheduleBeat(cur, nextTime, spb);
beat++;
if (beat % 4 === 0) cur = chance(0.82) ? pick(NEXT[cur]) : pick(Object.keys(CHORDS));
nextTime += spb + ((beat % 2 === 1) ? mood.swing * spb : 0); // lazy swing feel
}
}

window.jamStart = function (m) {
try {
const ctx = audio(); if (!ctx) return; // reuse + resume the shared AudioContext
ac = ctx; mood = MOODS[m] || MOODS.cozy;
if (!jamBus) buildGraph();
running = true; t0 = ac.currentTime; nextTime = ac.currentTime + 0.15; beat = 0; cur = 'Cmaj7';
jamBus.gain.cancelScheduledValues(ac.currentTime);
jamBus.gain.setValueAtTime(Math.max(0.0001, jamBus.gain.value), ac.currentTime);
jamBus.gain.linearRampToValueAtTime(0.6, ac.currentTime + 1.2); // gentle background level
if (timer) clearInterval(timer);
timer = setInterval(scheduler, TICK);
} catch (e) { /* ignore */ }
};
window.jamStop = function () {
try {
running = false;
if (timer) { clearInterval(timer); timer = null; }
if (jamBus && ac) { jamBus.gain.cancelScheduledValues(ac.currentTime); jamBus.gain.setTargetAtTime(0.0001, ac.currentTime, 0.18); }
} catch (e) { /* ignore */ }
};
window.jamSetMood = function (m) {
mood = MOODS[m] || mood;
try { if (running && wetGain && ac) wetGain.gain.setTargetAtTime(mood.rev, ac.currentTime, 0.3); } catch (e) { /* ignore */ }
};
// 0..1 phase within the current beat — lets the cat bob/strum in time with the music.
window.jamBeatPhase = function () {
if (!running || !ac) return 0;
const spb = 60 / mood.bpm;
return ((ac.currentTime - t0) / spb) % 1;
};
})();
5 changes: 5 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const cal = require('./cal');
const themes = require('./themes');
const { PATTERN_NAMES } = require('./patterns');

// Let the overlay auto-resume the Lobby Jam music at launch without a click — Chromium
// otherwise blocks autoplay until a user gesture.
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');

// AI-agent status file: hooks (e.g. Claude Code) write 'thinking' | 'done' here
// and the cat reacts. See README "AI agent reactions".
const AGENT_FILE = path.join(os.tmpdir(), 'pixelcat-agent.state');
Expand Down Expand Up @@ -390,6 +394,7 @@ function rebuildTrayMenu() {
{ label: 'Wander', type: 'checkbox', checked: !(cfg && cfg.roamOn === false), click: () => persistAndBroadcast({ ...cfg, roamOn: !(cfg && cfg.roamOn !== false) }) },
{ label: onBattery ? 'Low power mode (on battery)' : 'Low power mode', type: 'checkbox', checked: effectiveLowPower(), click: () => persistAndBroadcast({ ...cfg, lowPower: !(cfg && cfg.lowPower) }) },
{ label: 'Sound', type: 'checkbox', checked: !!(cfg && cfg.soundOn), click: () => persistAndBroadcast({ ...cfg, soundOn: !cfg.soundOn }) },
{ label: '🎸 Lobby Jam', type: 'checkbox', checked: !!(cfg && cfg.lobbyJam && cfg.lobbyJam.on), click: () => persistAndBroadcast({ ...cfg, lobbyJam: { ...(cfg.lobbyJam || { mood: 'cozy' }), on: !(cfg.lobbyJam && cfg.lobbyJam.on) } }) },
{ type: 'separator' },
{ label: 'Quit pixelcat', click: () => app.quit() },
]));
Expand Down
Loading