返回 CodeWhale
pet-audio.ts
根目录 / pet / src / core / pet-audio.ts
1 import { CHANNELS, PET_MAX_SECONDS } from './pet-sim.js';
2 import { clamp, stableHash } from './model.js';
3 import type { WorldFrame } from './pet-world.js';
4
5 export interface PetVoice {
6 id: string; start: number; duration: number; frequency: number;
7 gain: number; pan: number; kind: 'tone' | 'noise';
8 }
9
10 /** Core emits score events; WebAudio / AVAudioEngine only present their PCM.
11 * Calling at any display cadence produces the same score when all world ticks
12 * are supplied. Calling twice for a world tick cannot retrigger a voice. */
13 export class PetScore {
14 private lastWindow = -1;
15 private lastSequence = -1;
16 private lastAddress = false;
17 checkpoint(): [number, number, boolean] { return [this.lastWindow, this.lastSequence, this.lastAddress]; }
18 restore(value: unknown): void {
19 if (!Array.isArray(value) || value.length !== 3
20 || !value.slice(0, 2).every(n => Number.isSafeInteger(n) && n >= -1 && n <= PET_MAX_SECONDS * 2.5) || typeof value[2] !== 'boolean')
21 throw new Error('Invalid pet score checkpoint.');
22 [this.lastWindow, this.lastSequence, this.lastAddress] = value as [number, number, boolean];
23 }
24 voices(frame: WorldFrame): PetVoice[] {
25 const time = frame.timeMs / 1000, window = Math.floor((frame.timeMs + 1e-7) / 400);
26 const out: PetVoice[] = [];
27 const add = (id: string, frequency: number, duration: number, gain: number, pan = 0, delay = 0, kind: PetVoice['kind'] = 'tone') =>
28 out.push({ id, start: time + delay, duration, frequency, gain, pan, kind });
29 const t = frame.telemetry, fresh = t !== undefined && t.sequence !== this.lastSequence;
30 if (fresh) {
31 this.lastSequence = t.sequence;
32 for (let c = 0; c < CHANNELS.length; c++) {
33 const channel = CHANNELS[c], n = t.onsets[c];
34 if (!n || channel.sustained || ['human', 'error'].includes(channel.key)) continue;
35 add(`onset:${t.sequence}:${c}`, channel.freq, .24, .035 * Math.min(2, Math.sqrt(n)), (c / 12 - .5) * .7);
36 }
37 if (t.errors) add(`tear:${t.sequence}`, CHANNELS.find(c => c.key === 'error')!.freq, .22, .05, 0, 0, 'noise');
38 }
39 const address = frame.state.channel === 'human' && frame.state.attention > .5;
40 if (address && (!this.lastAddress || fresh && t!.onsets[11] > 0)) {
41 const frequency = CHANNELS[11].freq;
42 for (let i = 0; i < 3; i++) add(`address:${frame.timeMs}:${i}`, frequency * [1, 1.25, 1.5][i], .23, .032, 0, i * .14);
43 }
44 this.lastAddress = address;
45 if (window !== this.lastWindow) {
46 this.lastWindow = window;
47 if (frame.state.observed >= .92) {
48 const channel = CHANNELS.find(c => c.key === frame.state.channel)!;
49 if (channel.sustained && frame.behaviour !== 'doze') {
50 const peers = frame.state.channel === 'agent' ? Math.max(1, frame.pod.filter(p => p.present).length) : 1;
51 for (let i = 0; i < peers; i++) add(`sustain:${window}:${i}`, channel.freq * (1 + (i - (peers - 1) / 2) * .004),
52 .44, (.02 + frame.state.activity * .02) / Math.sqrt(peers), peers === 1 ? 0 : i / (peers - 1) - .5);
53 }
54 if (frame.behaviour === 'doze' && window % 5 === 0) {
55 add(`heart:${window}:0`, 49, .3, .035); add(`heart:${window}:1`, 49, .24, .023, 0, .33);
56 }
57 if (frame.needs === 'call' && window % 10 === 0) add(`call:${window}`, CHANNELS[11].freq * 1.5, .38, .03);
58 }
59 }
60 return out;
61 }
62 }
63
64 /** Sample-addressed noise: no global RNG, identical samples when chunked/seeking. */
65 function noise(seed: number, sample: number): number {
66 let x = (seed + Math.imul(sample, 0x6D2B79F5)) >>> 0;
67 x = Math.imul(x ^ x >>> 15, x | 1); x ^= x + Math.imul(x ^ x >>> 7, x | 61);
68 return ((x ^ x >>> 14) >>> 0) / 2147483648 - 1;
69 }
70
71 export function renderPetPCM(voices: readonly PetVoice[], startSample: number, length: number, sampleRate = 48_000): { left: Float32Array; right: Float32Array } {
72 if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 96000
73 || !Number.isSafeInteger(startSample) || startSample < 0 || !Number.isInteger(length) || length < 0 || length > sampleRate * 120)
74 throw new Error('Invalid pet PCM range.');
75 const left = new Float32Array(length), right = new Float32Array(length);
76 for (const v of voices) {
77 if (![v.start, v.duration, v.frequency, v.gain, v.pan].every(Number.isFinite)
78 || v.start < 0 || v.duration <= 0 || v.duration > 10 || v.frequency <= 0 || v.frequency > sampleRate / 2
79 || v.gain < 0 || v.gain > 1 || Math.abs(v.pan) > 1 || !['tone', 'noise'].includes(v.kind)) throw new Error('Invalid pet voice.');
80 const first = Math.max(startSample, Math.ceil(v.start * sampleRate));
81 const last = Math.min(startSample + length, Math.ceil((v.start + v.duration) * sampleRate));
82 const pan = (v.pan + 1) * Math.PI / 4, seed = (0xC0FFEE ^ stableHash(v.id)) >>> 0;
83 for (let absolute = first; absolute < last; absolute++) {
84 const age = absolute / sampleRate - v.start;
85 const envelope = Math.min(1, age / .015, (v.duration - age) / .045);
86 const sample = v.kind === 'noise' ? noise(seed, absolute) * Math.exp(-age * 14)
87 : Math.sin(2 * Math.PI * v.frequency * age) * .88 + Math.sin(4 * Math.PI * v.frequency * age) * .12;
88 const value = sample * Math.max(0, envelope) * v.gain, at = absolute - startSample;
89 left[at] += value * Math.cos(pan); right[at] += value * Math.sin(pan);
90 }
91 }
92 // Limiting is a presentation operation and cannot perturb voice scheduling.
93 for (let i = 0; i < length; i++) { left[i] = clamp(left[i], -1, 1); right[i] = clamp(right[i], -1, 1); }
94 return { left, right };
95 }
96
96 lines TYPESCRIPT