@kernel.chat/kbot 3.94.0 → 3.97.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.
@@ -1,32 +1,78 @@
1
1
  // kbot Audio Engine — Procedural sound generation for the stream
2
- //
3
- // Tools: audio_status, audio_mood
4
- //
5
- // v1: Audio Description System
6
- // The stream currently pipes silent audio via ffmpeg's anullsrc filter.
7
- // Piping actual PCM audio requires significant architecture changes (separate
8
- // pipe fd, real-time audio synthesis, mixing). For v1, this engine generates
9
- // textual audio atmosphere descriptions that the narrative engine and renderer
10
- // can display as italic stage directions (like a screenplay).
11
- //
12
- // v2 roadmap: Generate actual PCM Float32 audio and pipe to ffmpeg via pipe:3.
13
- //
14
- // FUTURE: Generate actual PCM audio
15
- // export function generatePCMAudio(engine: AudioEngine, sampleRate: number, samples: number): Float32Array
16
- // This would generate procedural audio using:
17
- // - Oscillators (sine, square, sawtooth for chiptune)
18
- // - Noise generators (white noise for wind/rain)
19
- // - Envelope generators (ADSR for notes)
20
- // - Simple reverb (delay line)
21
- // - Mix to mono PCM, pipe to ffmpeg via separate audio input
22
- // ffmpeg would need: -f f32le -ar 44100 -ac 1 -i pipe:3 (additional pipe for audio)
2
+ // Tools: audio_status, audio_mood, audio_pcm_status
3
+ // v1: Text description system (fallback). v2: Real PCM Float32 synthesis.
4
+ // Output: Float32Array piped to ffmpeg via -f f32le -ar 44100 -ac 1 -i pipe:3
23
5
  import { registerTool } from './index.js';
6
+ // ─── Constants ───────────────────────────────────────────────────
7
+ const DEFAULT_SAMPLE_RATE = 44100;
8
+ const TWO_PI = Math.PI * 2;
9
+ /** MIDI note to frequency (A4 = 440 Hz) */
10
+ function midiToFreq(note) {
11
+ return 440 * Math.pow(2, (note - 69) / 12);
12
+ }
13
+ // Scale intervals (semitones from root)
14
+ const SCALES = {
15
+ pentatonic: [0, 2, 4, 7, 9],
16
+ minor: [0, 2, 3, 5, 7, 8, 10],
17
+ major: [0, 2, 4, 5, 7, 9, 11],
18
+ };
19
+ /** Pick a scale based on mood */
20
+ function moodToScale(mood) {
21
+ switch (mood) {
22
+ case 'calm': return 'pentatonic';
23
+ case 'tense': return 'minor';
24
+ case 'epic': return 'minor';
25
+ case 'dreamy': return 'pentatonic';
26
+ case 'playful': return 'major';
27
+ default: return 'pentatonic';
28
+ }
29
+ }
30
+ /** Base MIDI note for mood (root note + octave) */
31
+ function moodToRoot(mood) {
32
+ switch (mood) {
33
+ case 'calm': return 60; // C4
34
+ case 'tense': return 57; // A3
35
+ case 'epic': return 48; // C3
36
+ case 'dreamy': return 64; // E4
37
+ case 'playful': return 60; // C4
38
+ default: return 60;
39
+ }
40
+ }
41
+ function createDefaultChannel(waveform, vol, filterFreq) {
42
+ return {
43
+ pattern: new Array(16).fill(0),
44
+ waveform,
45
+ envelope: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.15 },
46
+ volume: vol,
47
+ filterType: 'lowpass',
48
+ filterFreq,
49
+ filterQ: 1.0,
50
+ phase: 0,
51
+ envStage: 'off',
52
+ envLevel: 0,
53
+ envTime: 0,
54
+ currentNote: 0,
55
+ };
56
+ }
57
+ function createDelayLine(sampleRate) {
58
+ const delaySamples = Math.floor(sampleRate * 0.3); // 300ms delay
59
+ return {
60
+ buffer: new Float32Array(delaySamples),
61
+ writeIndex: 0,
62
+ delaySamples,
63
+ feedback: 0.3,
64
+ mix: 0.2,
65
+ };
66
+ }
24
67
  // ─── Factory ─────────────────────────────────────────────────────
25
68
  export function createAudioEngine() {
26
- return {
69
+ const sampleRate = DEFAULT_SAMPLE_RATE;
70
+ const bpm = 70;
71
+ const samplesPerStep = Math.floor((sampleRate * 60) / (bpm * 4)); // 16th notes
72
+ const engine = {
27
73
  currentAmbience: 'peaceful',
28
74
  musicState: {
29
- bpm: 70,
75
+ bpm,
30
76
  key: 'C major',
31
77
  mood: 'calm',
32
78
  playing: true,
@@ -38,7 +84,459 @@ export function createAudioEngine() {
38
84
  lastSoundFrame: 0,
39
85
  ambienceInterval: 720, // 120 seconds at 6fps
40
86
  totalDescriptions: 0,
87
+ // v2 PCM
88
+ pcmEnabled: false,
89
+ sfxQueue: [],
90
+ sequencer: {
91
+ step: 0,
92
+ sampleCounter: 0,
93
+ samplesPerStep,
94
+ channels: {
95
+ melody: createDefaultChannel('square', 0.25, 4000),
96
+ bass: createDefaultChannel('sawtooth', 0.3, 800),
97
+ arp: createDefaultChannel('square', 0.15, 6000),
98
+ drums: createDefaultChannel('noise_white', 0.35, 2000),
99
+ },
100
+ },
101
+ masterVolume: 0.6,
102
+ delayLine: createDelayLine(sampleRate),
103
+ totalSamplesGenerated: 0,
104
+ musicEnabled: true,
41
105
  };
106
+ // Generate initial patterns based on default mood
107
+ regeneratePatterns(engine);
108
+ return engine;
109
+ }
110
+ // ─── PCM Oscillators ─────────────────────────────────────────────
111
+ function oscillator(waveform, phase) {
112
+ switch (waveform) {
113
+ case 'sine':
114
+ return Math.sin(phase);
115
+ case 'square':
116
+ return Math.sin(phase) >= 0 ? 0.8 : -0.8;
117
+ case 'sawtooth':
118
+ return ((phase % TWO_PI) / Math.PI) - 1.0;
119
+ case 'triangle': {
120
+ const t = (phase % TWO_PI) / TWO_PI;
121
+ return t < 0.5 ? (4 * t - 1) : (3 - 4 * t);
122
+ }
123
+ case 'noise_white':
124
+ return (Math.random() * 2) - 1;
125
+ case 'noise_pink': {
126
+ // Voss-McCartney approximation (simplified)
127
+ const w = (Math.random() * 2 - 1) * 0.5;
128
+ return w + (Math.random() * 2 - 1) * 0.3 + (Math.random() * 2 - 1) * 0.2;
129
+ }
130
+ default:
131
+ return 0;
132
+ }
133
+ }
134
+ // ─── ADSR Envelope ───────────────────────────────────────────────
135
+ function tickEnvelope(ch, dt) {
136
+ const env = ch.envelope;
137
+ ch.envTime += dt;
138
+ switch (ch.envStage) {
139
+ case 'attack':
140
+ ch.envLevel = env.attack > 0 ? Math.min(1, ch.envTime / env.attack) : 1;
141
+ if (ch.envTime >= env.attack) {
142
+ ch.envStage = 'decay';
143
+ ch.envTime = 0;
144
+ }
145
+ break;
146
+ case 'decay':
147
+ ch.envLevel = 1 - (1 - env.sustain) * Math.min(1, ch.envTime / env.decay);
148
+ if (ch.envTime >= env.decay) {
149
+ ch.envStage = 'sustain';
150
+ ch.envTime = 0;
151
+ }
152
+ break;
153
+ case 'sustain':
154
+ ch.envLevel = env.sustain;
155
+ break;
156
+ case 'release':
157
+ ch.envLevel = env.sustain * Math.max(0, 1 - ch.envTime / env.release);
158
+ if (ch.envTime >= env.release) {
159
+ ch.envStage = 'off';
160
+ ch.envLevel = 0;
161
+ }
162
+ break;
163
+ case 'off':
164
+ ch.envLevel = 0;
165
+ break;
166
+ }
167
+ }
168
+ function triggerNote(ch, note) {
169
+ ch.currentNote = note;
170
+ ch.envStage = 'attack';
171
+ ch.envTime = 0;
172
+ ch.envLevel = 0;
173
+ }
174
+ function releaseNote(ch) {
175
+ if (ch.envStage !== 'off') {
176
+ ch.envStage = 'release';
177
+ ch.envTime = 0;
178
+ }
179
+ }
180
+ const filterStates = new WeakMap();
181
+ function getFilterState(ch) {
182
+ let s = filterStates.get(ch);
183
+ if (!s) {
184
+ s = { y1: 0, y2: 0, x1: 0, x2: 0 };
185
+ filterStates.set(ch, s);
186
+ }
187
+ return s;
188
+ }
189
+ function applyFilter(ch, input, sampleRate) {
190
+ const s = getFilterState(ch);
191
+ const freq = Math.min(ch.filterFreq, sampleRate * 0.45);
192
+ const w0 = TWO_PI * freq / sampleRate;
193
+ const sinW0 = Math.sin(w0);
194
+ const cosW0 = Math.cos(w0);
195
+ const alpha = sinW0 / (2 * ch.filterQ);
196
+ let b0, b1, b2, a0, a1, a2;
197
+ switch (ch.filterType) {
198
+ case 'lowpass':
199
+ b0 = (1 - cosW0) / 2;
200
+ b1 = 1 - cosW0;
201
+ b2 = (1 - cosW0) / 2;
202
+ a0 = 1 + alpha;
203
+ a1 = -2 * cosW0;
204
+ a2 = 1 - alpha;
205
+ break;
206
+ case 'highpass':
207
+ b0 = (1 + cosW0) / 2;
208
+ b1 = -(1 + cosW0);
209
+ b2 = (1 + cosW0) / 2;
210
+ a0 = 1 + alpha;
211
+ a1 = -2 * cosW0;
212
+ a2 = 1 - alpha;
213
+ break;
214
+ case 'bandpass':
215
+ b0 = alpha;
216
+ b1 = 0;
217
+ b2 = -alpha;
218
+ a0 = 1 + alpha;
219
+ a1 = -2 * cosW0;
220
+ a2 = 1 - alpha;
221
+ break;
222
+ default:
223
+ return input;
224
+ }
225
+ const output = (b0 / a0) * input + (b1 / a0) * s.x1 + (b2 / a0) * s.x2
226
+ - (a1 / a0) * s.y1 - (a2 / a0) * s.y2;
227
+ s.x2 = s.x1;
228
+ s.x1 = input;
229
+ s.y2 = s.y1;
230
+ s.y1 = output;
231
+ return output;
232
+ }
233
+ // ─── Delay Line (reverb/echo) ───────────────────────────────────
234
+ function processDelay(dl, input) {
235
+ const readIndex = (dl.writeIndex - dl.delaySamples + dl.buffer.length) % dl.buffer.length;
236
+ const delayed = dl.buffer[readIndex];
237
+ dl.buffer[dl.writeIndex] = input + delayed * dl.feedback;
238
+ dl.writeIndex = (dl.writeIndex + 1) % dl.buffer.length;
239
+ return input * (1 - dl.mix) + delayed * dl.mix;
240
+ }
241
+ // ─── Pattern Generation ─────────────────────────────────────────
242
+ function generatePattern(scale, root, octaveRange, density, seed) {
243
+ const intervals = SCALES[scale];
244
+ const pattern = new Array(16).fill(0);
245
+ // Deterministic-ish from seed
246
+ let r = seed;
247
+ const next = () => { r = (r * 1103515245 + 12345) & 0x7fffffff; return r / 0x7fffffff; };
248
+ for (let i = 0; i < 16; i++) {
249
+ if (next() < density) {
250
+ const octave = Math.floor(next() * octaveRange);
251
+ const idx = Math.floor(next() * intervals.length);
252
+ pattern[i] = root + intervals[idx] + octave * 12;
253
+ }
254
+ }
255
+ return pattern;
256
+ }
257
+ function generateDrumPattern(mood, seed) {
258
+ const pattern = new Array(16).fill(0);
259
+ let r = seed;
260
+ const next = () => { r = (r * 1103515245 + 12345) & 0x7fffffff; return r / 0x7fffffff; };
261
+ // Kick on beats (MIDI 36), snare on 2 & 4 (38), hat (42)
262
+ for (let i = 0; i < 16; i++) {
263
+ if (i % 4 === 0)
264
+ pattern[i] = 36; // kick
265
+ else if (i % 4 === 2 && mood !== 'calm')
266
+ pattern[i] = 38; // snare
267
+ else if (i % 2 === 0 && next() < 0.4)
268
+ pattern[i] = 42; // hat
269
+ else if (mood === 'epic' && next() < 0.3)
270
+ pattern[i] = 36; // extra kicks for epic
271
+ }
272
+ return pattern;
273
+ }
274
+ /** Regenerate all 4 channel patterns based on the current mood */
275
+ function regeneratePatterns(engine) {
276
+ const mood = engine.musicState.mood;
277
+ const scale = moodToScale(mood);
278
+ const root = moodToRoot(mood);
279
+ const seed = Date.now() & 0xffffff;
280
+ const seq = engine.sequencer;
281
+ const ch = seq.channels;
282
+ // Mood shapes pattern density and channel config
283
+ switch (mood) {
284
+ case 'calm':
285
+ ch.melody.pattern = generatePattern(scale, root + 12, 1, 0.3, seed);
286
+ ch.melody.envelope = { attack: 0.05, decay: 0.2, sustain: 0.4, release: 0.3 };
287
+ ch.bass.pattern = generatePattern(scale, root - 12, 1, 0.2, seed + 1);
288
+ ch.bass.envelope = { attack: 0.02, decay: 0.15, sustain: 0.6, release: 0.2 };
289
+ ch.arp.pattern = generatePattern(scale, root, 2, 0.5, seed + 2);
290
+ ch.arp.envelope = { attack: 0.005, decay: 0.08, sustain: 0.2, release: 0.1 };
291
+ ch.drums.pattern = generateDrumPattern(mood, seed + 3);
292
+ ch.drums.volume = 0.15;
293
+ ch.melody.filterFreq = 3000;
294
+ break;
295
+ case 'tense':
296
+ ch.melody.pattern = generatePattern(scale, root + 12, 1, 0.4, seed);
297
+ ch.melody.envelope = { attack: 0.01, decay: 0.1, sustain: 0.6, release: 0.1 };
298
+ ch.bass.pattern = generatePattern(scale, root - 12, 1, 0.5, seed + 1);
299
+ ch.bass.envelope = { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.1 };
300
+ ch.arp.pattern = generatePattern(scale, root, 2, 0.6, seed + 2);
301
+ ch.arp.envelope = { attack: 0.003, decay: 0.05, sustain: 0.3, release: 0.08 };
302
+ ch.drums.pattern = generateDrumPattern(mood, seed + 3);
303
+ ch.drums.volume = 0.3;
304
+ ch.melody.filterFreq = 2500; // darker
305
+ break;
306
+ case 'epic':
307
+ ch.melody.pattern = generatePattern(scale, root + 12, 2, 0.55, seed);
308
+ ch.melody.envelope = { attack: 0.01, decay: 0.15, sustain: 0.7, release: 0.15 };
309
+ ch.melody.volume = 0.3;
310
+ ch.bass.pattern = generatePattern(scale, root - 12, 1, 0.6, seed + 1);
311
+ ch.bass.envelope = { attack: 0.005, decay: 0.1, sustain: 0.8, release: 0.1 };
312
+ ch.bass.volume = 0.35;
313
+ ch.arp.pattern = generatePattern(scale, root, 2, 0.7, seed + 2);
314
+ ch.arp.envelope = { attack: 0.003, decay: 0.06, sustain: 0.4, release: 0.08 };
315
+ ch.drums.pattern = generateDrumPattern(mood, seed + 3);
316
+ ch.drums.volume = 0.35;
317
+ ch.melody.filterFreq = 5000;
318
+ break;
319
+ case 'dreamy':
320
+ ch.melody.pattern = generatePattern(scale, root + 12, 1, 0.25, seed);
321
+ ch.melody.envelope = { attack: 0.1, decay: 0.3, sustain: 0.5, release: 0.5 };
322
+ ch.melody.waveform = 'sine';
323
+ ch.bass.pattern = generatePattern(scale, root - 12, 1, 0.15, seed + 1);
324
+ ch.bass.envelope = { attack: 0.08, decay: 0.2, sustain: 0.4, release: 0.4 };
325
+ ch.arp.pattern = generatePattern(scale, root, 2, 0.35, seed + 2);
326
+ ch.arp.envelope = { attack: 0.05, decay: 0.15, sustain: 0.3, release: 0.3 };
327
+ ch.drums.pattern = generateDrumPattern(mood, seed + 3);
328
+ ch.drums.volume = 0.1;
329
+ engine.delayLine.mix = 0.4; // more reverb
330
+ engine.delayLine.feedback = 0.45;
331
+ ch.melody.filterFreq = 2000;
332
+ break;
333
+ case 'playful':
334
+ ch.melody.pattern = generatePattern(scale, root + 12, 2, 0.5, seed);
335
+ ch.melody.envelope = { attack: 0.005, decay: 0.08, sustain: 0.4, release: 0.1 };
336
+ ch.bass.pattern = generatePattern(scale, root - 12, 1, 0.4, seed + 1);
337
+ ch.bass.envelope = { attack: 0.005, decay: 0.1, sustain: 0.5, release: 0.1 };
338
+ ch.arp.pattern = generatePattern(scale, root, 2, 0.65, seed + 2);
339
+ ch.arp.envelope = { attack: 0.003, decay: 0.05, sustain: 0.3, release: 0.08 };
340
+ ch.drums.pattern = generateDrumPattern(mood, seed + 3);
341
+ ch.drums.volume = 0.25;
342
+ ch.melody.filterFreq = 6000;
343
+ break;
344
+ }
345
+ // Update tempo
346
+ seq.samplesPerStep = Math.floor((DEFAULT_SAMPLE_RATE * 60) / (engine.musicState.bpm * 4));
347
+ }
348
+ // ─── Render One Channel Sample ───────────────────────────────────
349
+ function renderChannel(ch, sampleRate) {
350
+ if (ch.envStage === 'off')
351
+ return 0;
352
+ const freq = ch.waveform.startsWith('noise') ? 0 : midiToFreq(ch.currentNote);
353
+ let sample;
354
+ if (ch.waveform.startsWith('noise')) {
355
+ sample = oscillator(ch.waveform, 0);
356
+ }
357
+ else {
358
+ sample = oscillator(ch.waveform, ch.phase);
359
+ ch.phase += TWO_PI * freq / sampleRate;
360
+ if (ch.phase > TWO_PI)
361
+ ch.phase -= TWO_PI;
362
+ }
363
+ // Apply envelope
364
+ sample *= ch.envLevel;
365
+ // Apply filter
366
+ sample = applyFilter(ch, sample, sampleRate);
367
+ // Apply channel volume
368
+ sample *= ch.volume;
369
+ return sample;
370
+ }
371
+ // ─── SFX Rendering ──────────────────────────────────────────────
372
+ const SFX_DURATION_SAMPLES = {
373
+ chat: Math.floor(DEFAULT_SAMPLE_RATE * 0.08),
374
+ follow: Math.floor(DEFAULT_SAMPLE_RATE * 0.4),
375
+ achievement: Math.floor(DEFAULT_SAMPLE_RATE * 0.6),
376
+ boss: Math.floor(DEFAULT_SAMPLE_RATE * 0.8),
377
+ raid: Math.floor(DEFAULT_SAMPLE_RATE * 0.7),
378
+ build: Math.floor(DEFAULT_SAMPLE_RATE * 0.12),
379
+ discovery: Math.floor(DEFAULT_SAMPLE_RATE * 0.5),
380
+ };
381
+ function renderSFXSample(sfx, sampleRate) {
382
+ const totalSamples = SFX_DURATION_SAMPLES[sfx.type];
383
+ const elapsed = totalSamples - sfx.samplesRemaining;
384
+ const t = elapsed / sampleRate; // time in seconds
385
+ const progress = elapsed / totalSamples; // 0-1
386
+ // Simple amplitude envelope (quick attack, exponential decay)
387
+ const env = Math.exp(-progress * 5) * (1 - Math.exp(-elapsed * 0.01));
388
+ switch (sfx.type) {
389
+ case 'chat': {
390
+ // Soft blip — high sine with fast decay
391
+ const freq = 1200 + 400 * (1 - progress);
392
+ sfx.phase += TWO_PI * freq / sampleRate;
393
+ return Math.sin(sfx.phase) * env * 0.3;
394
+ }
395
+ case 'follow': {
396
+ // Ascending chime — 3 tones rising
397
+ const stage = Math.floor(progress * 3);
398
+ const freqs = [523, 659, 784]; // C5, E5, G5
399
+ const freq = freqs[Math.min(stage, 2)];
400
+ sfx.phase += TWO_PI * freq / sampleRate;
401
+ return Math.sin(sfx.phase) * env * 0.4;
402
+ }
403
+ case 'achievement': {
404
+ // Fanfare — 3 ascending notes with harmonics
405
+ const stage = Math.floor(progress * 3);
406
+ const freqs = [440, 554, 659]; // A4, C#5, E5
407
+ const freq = freqs[Math.min(stage, 2)];
408
+ sfx.phase += TWO_PI * freq / sampleRate;
409
+ const fundamental = Math.sin(sfx.phase);
410
+ const harmonic = Math.sin(sfx.phase * 2) * 0.3;
411
+ return (fundamental + harmonic) * env * 0.4;
412
+ }
413
+ case 'boss': {
414
+ // Low rumble — detuned bass sine + noise
415
+ sfx.phase += TWO_PI * 55 / sampleRate; // A1
416
+ const bass = Math.sin(sfx.phase) + Math.sin(sfx.phase * 1.01) * 0.5;
417
+ const noise = (Math.random() * 2 - 1) * 0.15;
418
+ return (bass + noise) * env * 0.5;
419
+ }
420
+ case 'raid': {
421
+ // Drum roll — rapid noise bursts
422
+ const rollFreq = 15 + progress * 10; // accelerating
423
+ const burstEnv = Math.abs(Math.sin(TWO_PI * rollFreq * t));
424
+ const noise = (Math.random() * 2 - 1);
425
+ return noise * burstEnv * env * 0.4;
426
+ }
427
+ case 'build': {
428
+ // Thunk — short noise burst with lowpass feel
429
+ const noise = (Math.random() * 2 - 1);
430
+ const thunkEnv = Math.exp(-progress * 20);
431
+ sfx.phase += TWO_PI * 150 / sampleRate;
432
+ return (noise * 0.3 + Math.sin(sfx.phase) * 0.7) * thunkEnv * 0.4;
433
+ }
434
+ case 'discovery': {
435
+ // Shimmer — detuned sine sweep
436
+ const freq = 400 + 1200 * progress;
437
+ sfx.phase += TWO_PI * freq / sampleRate;
438
+ const s1 = Math.sin(sfx.phase);
439
+ const s2 = Math.sin(sfx.phase * 1.005); // slight detune
440
+ const s3 = Math.sin(sfx.phase * 0.995);
441
+ return (s1 + s2 + s3) / 3 * env * 0.35;
442
+ }
443
+ default:
444
+ return 0;
445
+ }
446
+ }
447
+ // ─── Public PCM API ─────────────────────────────────────────────
448
+ /**
449
+ * Trigger a sound effect. The SFX will be mixed into the next generateAudioBuffer call.
450
+ */
451
+ export function triggerSFX(engine, sfx) {
452
+ engine.sfxQueue.push({
453
+ type: sfx,
454
+ triggeredAt: engine.totalSamplesGenerated,
455
+ samplesRemaining: SFX_DURATION_SAMPLES[sfx],
456
+ phase: 0,
457
+ });
458
+ if (engine.sfxQueue.length > 8) { // cap concurrent SFX
459
+ engine.sfxQueue = engine.sfxQueue.slice(-8);
460
+ }
461
+ }
462
+ /**
463
+ * Enable or disable background music generation.
464
+ */
465
+ export function setMusicEnabled(engine, enabled) {
466
+ engine.musicEnabled = enabled;
467
+ }
468
+ /**
469
+ * Generate a buffer of PCM Float32 audio samples.
470
+ * Mixes the 4-channel chiptune sequencer + any active SFX.
471
+ * Output: mono Float32Array, ready to pipe to ffmpeg via -f f32le -ar 44100 -ac 1 -i pipe:3
472
+ *
473
+ * @param engine The audio engine state
474
+ * @param sampleCount Number of samples to generate
475
+ * @param sampleRate Sample rate (default 44100)
476
+ * @returns Float32Array of PCM samples in [-1, 1]
477
+ */
478
+ export function generateAudioBuffer(engine, sampleCount, sampleRate = DEFAULT_SAMPLE_RATE) {
479
+ const buffer = new Float32Array(sampleCount);
480
+ if (!engine.pcmEnabled) {
481
+ // PCM disabled — return silence
482
+ return buffer;
483
+ }
484
+ const seq = engine.sequencer;
485
+ const dt = 1 / sampleRate;
486
+ for (let i = 0; i < sampleCount; i++) {
487
+ let sample = 0;
488
+ // ── Music (4-channel sequencer) ──
489
+ if (engine.musicEnabled && engine.musicState.playing) {
490
+ // Step advance
491
+ seq.sampleCounter++;
492
+ if (seq.sampleCounter >= seq.samplesPerStep) {
493
+ seq.sampleCounter = 0;
494
+ seq.step = (seq.step + 1) % 16;
495
+ // Trigger or release notes on each channel
496
+ const channels = seq.channels;
497
+ for (const key of ['melody', 'bass', 'arp', 'drums']) {
498
+ const ch = channels[key];
499
+ const note = ch.pattern[seq.step];
500
+ if (note > 0) {
501
+ triggerNote(ch, note);
502
+ }
503
+ else {
504
+ releaseNote(ch);
505
+ }
506
+ }
507
+ }
508
+ // Render each channel
509
+ for (const key of ['melody', 'bass', 'arp', 'drums']) {
510
+ const ch = seq.channels[key];
511
+ tickEnvelope(ch, dt);
512
+ sample += renderChannel(ch, sampleRate);
513
+ }
514
+ }
515
+ // ── SFX layer ──
516
+ for (let s = engine.sfxQueue.length - 1; s >= 0; s--) {
517
+ const sfx = engine.sfxQueue[s];
518
+ if (sfx.samplesRemaining > 0) {
519
+ sample += renderSFXSample(sfx, sampleRate);
520
+ sfx.samplesRemaining--;
521
+ }
522
+ else {
523
+ engine.sfxQueue.splice(s, 1);
524
+ }
525
+ }
526
+ // ── Master processing ──
527
+ // Delay (reverb/echo)
528
+ sample = processDelay(engine.delayLine, sample);
529
+ // Master volume
530
+ sample *= engine.masterVolume;
531
+ // Soft clip to prevent harsh distortion
532
+ if (sample > 1)
533
+ sample = 1 - 1 / (sample + 1);
534
+ else if (sample < -1)
535
+ sample = -1 + 1 / (-sample + 1);
536
+ buffer[i] = sample;
537
+ }
538
+ engine.totalSamplesGenerated += sampleCount;
539
+ return buffer;
42
540
  }
43
541
  // ─── Ambience Descriptions ───────────────────────────────────────
44
542
  const AMBIENCE_DESCRIPTIONS = {
@@ -311,6 +809,10 @@ export function tickAudio(engine, biome, weather, mood, timeOfDay, frame) {
311
809
  const newMood = resolveMusicMood(mood);
312
810
  const moodChanged = newMood !== engine.musicState.mood;
313
811
  engine.musicState.mood = newMood;
812
+ // When mood changes and PCM is active, regenerate sequencer patterns
813
+ if (moodChanged && engine.pcmEnabled) {
814
+ regeneratePatterns(engine);
815
+ }
314
816
  // Priority 1: Queued sound events (immediate)
315
817
  const soundDescriptions = drainSoundQueue(engine);
316
818
  if (soundDescriptions.length > 0) {
@@ -371,7 +873,7 @@ export function registerAudioEngineTools() {
371
873
  'notification', 'achievement', 'weather',
372
874
  'footstep', 'build', 'discovery',
373
875
  ],
374
- note: 'v1 generates audio descriptions (text). PCM audio generation is planned for v2.',
876
+ note: 'v1: text descriptions (fallback). v2: real PCM synthesis via generateAudioBuffer().',
375
877
  }, null, 2);
376
878
  },
377
879
  });
@@ -422,5 +924,62 @@ export function registerAudioEngineTools() {
422
924
  }, null, 2);
423
925
  },
424
926
  });
927
+ registerTool({
928
+ name: 'audio_pcm_status',
929
+ description: 'Get the PCM audio synthesis engine state — sequencer position, channel patterns, ' +
930
+ 'active SFX, delay settings, total samples generated. Shows the v2 real-time audio status.',
931
+ parameters: {},
932
+ tier: 'free',
933
+ execute: async () => {
934
+ const engine = createAudioEngine();
935
+ engine.pcmEnabled = true; // show what PCM state looks like
936
+ const seq = engine.sequencer;
937
+ const channelSummary = (name, ch) => ({
938
+ name,
939
+ waveform: ch.waveform,
940
+ volume: ch.volume,
941
+ filterType: ch.filterType,
942
+ filterFreq: ch.filterFreq,
943
+ envelope: ch.envelope,
944
+ activeNotes: ch.pattern.filter(n => n > 0).length,
945
+ pattern: ch.pattern.map(n => n > 0 ? n : '.').join(' '),
946
+ });
947
+ return JSON.stringify({
948
+ pcmEnabled: engine.pcmEnabled,
949
+ musicEnabled: engine.musicEnabled,
950
+ masterVolume: engine.masterVolume,
951
+ sampleRate: DEFAULT_SAMPLE_RATE,
952
+ format: 'Float32 mono',
953
+ ffmpegPipe: '-f f32le -ar 44100 -ac 1 -i pipe:3',
954
+ sequencer: {
955
+ step: seq.step,
956
+ samplesPerStep: seq.samplesPerStep,
957
+ bpm: engine.musicState.bpm,
958
+ mood: engine.musicState.mood,
959
+ channels: [
960
+ channelSummary('melody', seq.channels.melody),
961
+ channelSummary('bass', seq.channels.bass),
962
+ channelSummary('arp', seq.channels.arp),
963
+ channelSummary('drums', seq.channels.drums),
964
+ ],
965
+ },
966
+ delay: {
967
+ delaySamples: engine.delayLine.delaySamples,
968
+ delayMs: Math.round(engine.delayLine.delaySamples / DEFAULT_SAMPLE_RATE * 1000),
969
+ feedback: engine.delayLine.feedback,
970
+ mix: engine.delayLine.mix,
971
+ },
972
+ sfxQueue: engine.sfxQueue.length,
973
+ supportedSFX: ['chat', 'follow', 'achievement', 'boss', 'raid', 'build', 'discovery'],
974
+ totalSamplesGenerated: engine.totalSamplesGenerated,
975
+ synthesis: {
976
+ oscillators: ['sine', 'square', 'sawtooth', 'triangle', 'noise_white', 'noise_pink'],
977
+ filters: ['lowpass', 'highpass', 'bandpass'],
978
+ envelope: 'ADSR (attack, decay, sustain, release)',
979
+ effects: 'delay line (reverb/echo), soft clipper',
980
+ },
981
+ }, null, 2);
982
+ },
983
+ });
425
984
  }
426
985
  //# sourceMappingURL=audio-engine.js.map
@@ -329,6 +329,12 @@ const LAZY_MODULE_IMPORTS = [
329
329
  { path: './coordination-engine.js', registerFn: 'registerCoordinationEngineTools' },
330
330
  { path: './foundation-engines.js', registerFn: 'registerFoundationEngineTools' },
331
331
  { path: './research-engine.js', registerFn: 'registerResearchEngineTools' },
332
+ { path: './stream-overlay.js', registerFn: 'registerOverlayTools' },
333
+ { path: './stream-weather.js', registerFn: 'registerStreamWeatherTools' },
334
+ { path: './stream-chat-ai.js', registerFn: 'registerStreamChatAITools' },
335
+ { path: './stream-vod.js', registerFn: 'registerStreamVODTools' },
336
+ { path: './stream-commands.js', registerFn: 'registerStreamCommandsTools' },
337
+ { path: '../coordinator.js', registerFn: 'registerCoordinatorTools' },
332
338
  ];
333
339
  /** Track whether lazy tools have been registered */
334
340
  let lazyToolsRegistered = false;
@@ -1,5 +1,23 @@
1
1
  import type { CanvasRenderingContext2D } from 'canvas';
2
2
  export declare function drawRobot(ctx: CanvasRenderingContext2D, x: number, y: number, scale: number, mood: string, frame: number, moodColor?: [number, number, number], weather?: 'clear' | 'rain' | 'snow' | 'storm' | 'stars', isWalking?: boolean, walkPhase?: number): void;
3
+ /**
4
+ * Draw a stocky gorilla/monkey pixel art character (32x32 grid).
5
+ * Drop-in replacement for drawRobot() with the same signature.
6
+ *
7
+ * @param ctx - Canvas 2D rendering context
8
+ * @param x - Top-left X position in canvas pixels
9
+ * @param y - Top-left Y position in canvas pixels
10
+ * @param scale - Pixel scale multiplier (4-10 recommended)
11
+ * @param mood - Current mood: idle, talking, thinking, excited, dancing, walking
12
+ * @param frame - Animation frame counter (incrementing integer)
13
+ * @param moodColor - Optional RGB override for mood accent color
14
+ */
15
+ export declare function drawGorilla(ctx: CanvasRenderingContext2D, x: number, y: number, scale: number, mood: string, frame: number, moodColor?: [number, number, number]): void;
16
+ /**
17
+ * Draw animated mood particles around the gorilla.
18
+ * Same interface as drawMoodParticles but tuned for gorilla position/shape.
19
+ */
20
+ export declare function drawGorillaParticles(ctx: CanvasRenderingContext2D, x: number, y: number, scale: number, mood: string, frame: number): void;
3
21
  /**
4
22
  * Draw animated mood particles around the robot.
5
23
  *