soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9
A neutral frame yields no kind and no energy: nothing drawn, nothing sounded, never an
Idle. For a non-neutral human, a kind outside {Idle, Working, Speaking} or omitted becomes
Idle and Peak reads as High. Minds and personas keep all seven kinds. The subset is
provisional until Bifrost's steward cuts the list.
Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c4c00d7127
commit
cc99a3439d
3 changed files with 97 additions and 32 deletions
|
|
@ -1,6 +1,7 @@
|
|||
// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510,
|
||||
// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9,
|
||||
// section 2). The Heimdall session produces it; this file only reads it. Shape follows
|
||||
// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it.
|
||||
// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human
|
||||
// subset below is PROVISIONAL until Bifrost's steward cuts the list.
|
||||
|
||||
export type Estate = 'human' | 'mind' | 'persona';
|
||||
export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert';
|
||||
|
|
@ -8,18 +9,27 @@ export type Energy = 'Low' | 'Mid' | 'High' | 'Peak';
|
|||
export type Size = 'S' | 'M' | 'L';
|
||||
export type Override = 'None' | 'Muted';
|
||||
|
||||
export interface ActivityFrame {
|
||||
interface FrameBase {
|
||||
mind: string;
|
||||
estate: Estate;
|
||||
kind: Kind;
|
||||
energy: Energy;
|
||||
size: Size;
|
||||
neutral: boolean;
|
||||
override: Override;
|
||||
seq: number;
|
||||
/** Producer observation time, whole seconds (grammar: 1 s resolution). */
|
||||
at: number;
|
||||
}
|
||||
/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */
|
||||
export interface NeutralFrame extends FrameBase {
|
||||
neutral: true;
|
||||
kind?: undefined;
|
||||
energy?: undefined;
|
||||
}
|
||||
export interface LiveFrame extends FrameBase {
|
||||
neutral: false;
|
||||
kind: Kind;
|
||||
energy: Energy;
|
||||
}
|
||||
export type ActivityFrame = NeutralFrame | LiveFrame;
|
||||
|
||||
const ESTATES: readonly Estate[] = ['human', 'mind', 'persona'];
|
||||
const KINDS: readonly Kind[] = [
|
||||
|
|
@ -40,39 +50,40 @@ export const STALE_AFTER_S = 10;
|
|||
const oneOf = <T extends string>(set: readonly T[], v: unknown): T | undefined =>
|
||||
set.find((s) => s === v);
|
||||
|
||||
/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */
|
||||
export const clampEnergy = (estate: Estate, energy: Energy): Energy =>
|
||||
/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */
|
||||
export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking'];
|
||||
|
||||
/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */
|
||||
const clampKind = (estate: Estate, kind: unknown): Kind | undefined =>
|
||||
estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind);
|
||||
|
||||
/** A human's energy is three buckets: Peak reads as High. */
|
||||
const clampEnergy = (estate: Estate, energy: Energy): Energy =>
|
||||
estate === 'human' && energy === 'Peak' ? 'High' : energy;
|
||||
|
||||
/**
|
||||
* Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a
|
||||
* room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3).
|
||||
* Returns undefined for anything malformed; the caller treats that as no frame (A4).
|
||||
* `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent
|
||||
* them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4).
|
||||
*/
|
||||
export const readFrame = (raw: unknown): ActivityFrame | undefined => {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (typeof r.neutral !== 'boolean') return undefined;
|
||||
const estate = oneOf(ESTATES, r.estate);
|
||||
const kind = oneOf(KINDS, r.kind);
|
||||
const energy = oneOf(ENERGIES, r.energy);
|
||||
const size = oneOf(SIZES, r.size);
|
||||
const override =
|
||||
r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override);
|
||||
if (!estate || !kind || !energy || !size || !override) return undefined;
|
||||
if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean')
|
||||
return undefined;
|
||||
if (!estate || !size || !override) return undefined;
|
||||
if (typeof r.mind !== 'string' || r.mind === '') return undefined;
|
||||
if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined;
|
||||
return {
|
||||
mind: r.mind,
|
||||
estate,
|
||||
kind,
|
||||
energy: clampEnergy(estate, energy),
|
||||
size,
|
||||
neutral: r.neutral,
|
||||
override,
|
||||
seq: r.seq as number,
|
||||
at: r.at as number,
|
||||
};
|
||||
const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number };
|
||||
if (r.neutral) return { ...base, neutral: true };
|
||||
const kind = clampKind(estate, r.kind);
|
||||
const energy = oneOf(ENERGIES, r.energy);
|
||||
if (!kind || !energy) return undefined;
|
||||
return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) };
|
||||
};
|
||||
|
||||
/** Latest frame per mind (S1: consumers drop seq <= last). */
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame';
|
||||
import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame';
|
||||
import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene';
|
||||
import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices';
|
||||
import { isWeakDevice } from './device';
|
||||
import { createAudioSocket, silentSource, type ActivitySource } from './socket';
|
||||
|
||||
const frame = (o: Partial<ActivityFrame> = {}): ActivityFrame => ({
|
||||
const frame = (o: Partial<LiveFrame> = {}): LiveFrame => ({
|
||||
mind: 'void',
|
||||
estate: 'mind',
|
||||
kind: 'Working',
|
||||
|
|
@ -17,6 +17,11 @@ const frame = (o: Partial<ActivityFrame> = {}): ActivityFrame => ({
|
|||
at: 100,
|
||||
...o,
|
||||
});
|
||||
const neutralFrame = (o: Partial<ActivityFrame> = {}): ActivityFrame =>
|
||||
({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame);
|
||||
/** A raw frame as a producer sends it, with the named keys omitted. */
|
||||
const without = (f: object, ...keys: string[]) =>
|
||||
Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k)));
|
||||
const noop = () => undefined;
|
||||
const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 };
|
||||
|
||||
|
|
@ -25,9 +30,7 @@ describe('neutral overrides everything', () => {
|
|||
const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) =>
|
||||
(['Speaking', 'Alert', 'Working'] as const).flatMap((kind) =>
|
||||
(['High', 'Peak'] as const).flatMap((energy) =>
|
||||
(['S', 'M', 'L'] as const).map((size) =>
|
||||
frame({ neutral: true, estate, kind, energy, size })
|
||||
)
|
||||
(['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size }))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
@ -45,6 +48,55 @@ describe('neutral overrides everything', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('neutral is read first', () => {
|
||||
it('yields no kind and no energy, even when the producer sent them', () => {
|
||||
(['human', 'mind', 'persona'] as const).forEach((estate) => {
|
||||
const f = readFrame({
|
||||
...frame({ estate, kind: 'Speaking', energy: 'High' }),
|
||||
neutral: true,
|
||||
});
|
||||
expect(f).toBeDefined();
|
||||
expect(f?.neutral).toBe(true);
|
||||
expect(f?.kind).toBeUndefined();
|
||||
expect(f?.energy).toBeUndefined();
|
||||
expect(Object.keys(f ?? {})).not.toContain('kind');
|
||||
expect(Object.keys(f ?? {})).not.toContain('energy');
|
||||
});
|
||||
});
|
||||
it('a neutral human with kind omitted is not an Idle', () => {
|
||||
const bare = without(frame({ estate: 'human' }), 'kind', 'energy');
|
||||
const f = readFrame({ ...bare, neutral: true });
|
||||
expect(f?.kind).toBeUndefined();
|
||||
expect(drawnCreatures([f as ActivityFrame])).toEqual([]);
|
||||
expect(planVoices([f as ActivityFrame], on)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('human kinds are the provisional subset', () => {
|
||||
it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => {
|
||||
expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle');
|
||||
});
|
||||
it('keeps Idle, Working and Speaking', () => {
|
||||
(['Idle', 'Working', 'Speaking'] as const).forEach((kind) =>
|
||||
expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind)
|
||||
);
|
||||
});
|
||||
it('an omitted kind is Idle for a non-neutral human only', () => {
|
||||
const noKind = without(frame({ estate: 'human' }), 'kind');
|
||||
expect(readFrame(noKind)?.kind).toBe('Idle');
|
||||
expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined();
|
||||
});
|
||||
it('minds and personas keep all seven kinds, and omitted is malformed for them', () => {
|
||||
(['mind', 'persona'] as const).forEach((estate) => {
|
||||
(['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach(
|
||||
(kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind)
|
||||
);
|
||||
const noKind = without(frame({ estate }), 'kind');
|
||||
expect(readFrame(noKind)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('human energy is clamped to three buckets by the consumer', () => {
|
||||
it('turns Peak into High for humans only', () => {
|
||||
expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ActivityFrame, Energy, Kind } from './frame';
|
||||
import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame';
|
||||
|
||||
/** Grammar V3 (proposed, unmeasured): voices audible at once. */
|
||||
export const DEFAULT_VOICE_CAP = 4;
|
||||
|
|
@ -42,7 +42,9 @@ export const planVoices = (
|
|||
if (gain === 0) return [];
|
||||
const cap = Math.max(0, Math.floor(prefs.voiceCap));
|
||||
return frames
|
||||
.filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind))
|
||||
.filter(
|
||||
(f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)
|
||||
)
|
||||
.sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind))
|
||||
.slice(0, cap)
|
||||
.map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue