| 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 |