Compare commits

..
7 changed files with 0 additions and 393 deletions

View file

@ -1,12 +0,0 @@
export interface DeviceHints {
deviceMemory?: number;
hardwareConcurrency?: number;
}
/**
* Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar
* section 7, item 4). Unknown hints (Safari reports neither) do not count as weak.
*/
export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean =>
(deviceMemory !== undefined && deviceMemory < 4) ||
(hardwareConcurrency !== undefined && hardwareConcurrency < 4);

View file

@ -1,95 +0,0 @@
// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510,
// 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.
export type Estate = 'human' | 'mind' | 'persona';
export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert';
export type Energy = 'Low' | 'Mid' | 'High' | 'Peak';
export type Size = 'S' | 'M' | 'L';
export type Override = 'None' | 'Muted';
export interface ActivityFrame {
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;
}
const ESTATES: readonly Estate[] = ['human', 'mind', 'persona'];
const KINDS: readonly Kind[] = [
'Idle',
'Reading',
'Thinking',
'Working',
'Speaking',
'Waiting',
'Alert',
];
const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak'];
const SIZES: readonly Size[] = ['S', 'M', 'L'];
/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */
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 =>
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).
*/
export const readFrame = (raw: unknown): ActivityFrame | undefined => {
if (typeof raw !== 'object' || raw === null) return undefined;
const r = raw as Record<string, unknown>;
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 (!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,
};
};
/** Latest frame per mind (S1: consumers drop seq <= last). */
export class FrameTable {
private frames = new Map<string, ActivityFrame>();
offer(raw: unknown): boolean {
const f = readFrame(raw);
if (!f) return false;
const last = this.frames.get(f.mind);
if (last && f.seq <= last.seq) return false;
this.frames.set(f.mind, f);
return true;
}
/** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */
fresh(nowS: number): ActivityFrame[] {
return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S);
}
}

View file

@ -1,8 +0,0 @@
import type { ActivityFrame, Size } from './frame';
/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */
export const PLACEHOLDER_SIZE_SCALE: Record<Size, number> = { S: 0.7, M: 1, L: 1.4 };
/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */
export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] =>
frames.filter((f) => !f.neutral);

View file

@ -1,47 +0,0 @@
import { FrameTable } from './frame';
import { drawnCreatures } from './scene';
import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices';
import { isWeakDevice, type DeviceHints } from './device';
/**
* Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the
* Heimdall session feed exists the only source is `silentSource`. A source never reads a room's
* member list (A1): being in a room says nothing about being active.
*/
export interface ActivitySource {
subscribe(onFrame: (raw: unknown) => void): () => void;
}
const noop = () => undefined;
export const silentSource: ActivitySource = { subscribe: () => noop };
/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */
export interface VoiceSink {
play(commands: readonly VoiceCommand[]): void;
}
export interface AudioSocketDeps {
source: ActivitySource;
sink: VoiceSink;
prefs: () => SoundscapePrefs;
device: () => DeviceHints;
/** Whole seconds. */
nowS: () => number;
}
export const createAudioSocket = (deps: AudioSocketDeps) => {
const table = new FrameTable();
const unsubscribe = deps.source.subscribe((raw) => table.offer(raw));
return {
/** Called by the scheduler tick; a hidden page simply stops calling it (S5). */
tick(): VoiceCommand[] {
const prefs = deps.prefs();
const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) };
const commands = planVoices(table.fresh(deps.nowS()), effective);
deps.sink.play(commands);
return commands;
},
/** What the orbit renderer draws: the same table, neutral removed. */
drawn: () => drawnCreatures(table.fresh(deps.nowS())),
close: unsubscribe,
};
};

View file

@ -1,171 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } 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 => ({
mind: 'void',
estate: 'mind',
kind: 'Working',
energy: 'Mid',
size: 'M',
neutral: false,
override: 'None',
seq: 1,
at: 100,
...o,
});
const noop = () => undefined;
const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 };
describe('neutral overrides everything', () => {
it('draws nothing and sounds nothing for any kind, energy, size or estate', () => {
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 })
)
)
)
);
expect(cases).toHaveLength(54);
cases.forEach((f) => {
expect(drawnCreatures([f])).toEqual([]);
expect(planVoices([f], on)).toEqual([]);
});
});
it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => {
const t = new FrameTable();
t.offer(frame({ at: 100 }));
expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]);
expect(drawnCreatures(t.fresh(100)).length).toBe(1);
});
});
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');
expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak');
expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak');
});
it('keeps the clamp through the voice plan', () => {
const t = new FrameTable();
t.offer(frame({ estate: 'human', energy: 'Peak' }));
expect(planVoices(t.fresh(100), on)[0].energy).toBe('High');
});
});
describe('activity is a stub, never the room member list', () => {
it('the silent source and the socket yield nothing, however many members a room has', () => {
const members = ['@a:x', '@b:x', '@c:x'];
const sock = createAudioSocket({
source: silentSource,
sink: { play: noop },
prefs: () => on,
device: () => ({}),
nowS: () => 100,
});
expect(members.length).toBeGreaterThan(0);
expect(sock.tick()).toEqual([]);
expect(sock.drawn()).toEqual([]);
});
it('only frames the source pushes produce voices', () => {
const pushes: ((raw: unknown) => void)[] = [];
const source: ActivitySource = {
subscribe: (cb) => {
pushes.push(cb);
return noop;
},
};
const sock = createAudioSocket({
source,
sink: { play: noop },
prefs: () => on,
device: () => ({}),
nowS: () => 100,
});
pushes[0](frame({ mind: 'aqua' }));
expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']);
});
});
describe('no vote weights', () => {
it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => {
const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' });
expect(Object.keys(f ?? {}).sort()).toEqual(
['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort()
);
const t = new FrameTable();
t.offer({ ...frame(), weight: 9 });
expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5);
});
});
describe('size scales are placeholders', () => {
it('live in the renderer table, not in the frame, and change nothing about voices', () => {
expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 });
const s = planVoices([frame({ size: 'S' })], on);
const l = planVoices([frame({ size: 'L' })], on);
expect(s).toEqual(l);
});
});
describe('socket defaults and limits', () => {
it('ships off, muted, with the proposed cap', async () => {
vi.stubGlobal('localStorage', { getItem: () => null });
const { getSettings } = await import('../../state/settings');
const s = getSettings();
expect(s.soundscapeEnabled).toBe(false);
expect(s.soundscapeMuted).toBe(true);
expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP);
});
it('master mute, disabled flag and zero volume all silence', () => {
const f = [frame()];
expect(planVoices(f, { ...on, muted: true })).toEqual([]);
expect(planVoices(f, { ...on, enabled: false })).toEqual([]);
expect(planVoices(f, { ...on, volume: 0 })).toEqual([]);
});
it('caps voices, dropping the lowest energy first', () => {
const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) =>
frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] })
);
const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind);
expect(voices).toHaveLength(4);
expect(voices).not.toContain('a');
});
it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => {
const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })];
expect(planVoices(f, on)).toEqual([]);
expect(drawnCreatures(f)).toHaveLength(2);
});
it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => {
expect(isWeakDevice({ deviceMemory: 2 })).toBe(true);
expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true);
expect(isWeakDevice({})).toBe(false);
const played: number[] = [];
const sock = createAudioSocket({
source: {
subscribe: (cb) => {
cb(frame());
return noop;
},
},
sink: { play: (c) => played.push(c.length) },
prefs: () => on,
device: () => ({ deviceMemory: 1 }),
nowS: () => 100,
});
expect(sock.tick()).toEqual([]);
expect(played).toEqual([0]);
});
it('drops frames with seq <= last, and malformed frames', () => {
const t = new FrameTable();
expect(t.offer(frame({ seq: 5 }))).toBe(true);
expect(t.offer(frame({ seq: 5 }))).toBe(false);
expect(t.offer(frame({ seq: 4 }))).toBe(false);
expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false);
});
});

View file

@ -1,49 +0,0 @@
import type { ActivityFrame, Energy, Kind } from './frame';
/** Grammar V3 (proposed, unmeasured): voices audible at once. */
export const DEFAULT_VOICE_CAP = 4;
/** Local listener preferences. Never written back into a frame (O3). */
export interface SoundscapePrefs {
/** The feature flag. Default off. */
enabled: boolean;
/** Master mute. Default on. */
muted: boolean;
/** 0..1 */
volume: number;
voiceCap: number;
}
export interface VoiceCommand {
mind: string;
kind: Kind;
energy: Energy;
gain: number;
}
const ENERGY_RANK: Record<Energy, number> = { Low: 0, Mid: 1, High: 2, Peak: 3 };
/** Grammar section 3: Idle and Waiting default silent. */
const SILENT_KINDS: ReadonlySet<Kind> = new Set<Kind>(['Idle', 'Waiting']);
const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0);
/**
* Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2);
* over the cap the lowest energy drops first (V3, ties by mind id so the result is stable).
* Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight.
* Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen.
*/
export const planVoices = (
frames: readonly ActivityFrame[],
prefs: SoundscapePrefs
): VoiceCommand[] => {
if (!prefs.enabled || prefs.muted) return [];
const gain = clamp01(prefs.volume);
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))
.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 }));
};

View file

@ -47,12 +47,6 @@ export interface Settings {
dateFormatString: string;
developerTools: boolean;
/** Soundscape flag: default off, and muted by default even when on. */
soundscapeEnabled: boolean;
soundscapeMuted: boolean;
soundscapeVolume: number;
soundscapeVoiceCap: number;
}
const defaultSettings: Settings = {
@ -87,11 +81,6 @@ const defaultSettings: Settings = {
dateFormatString: 'D MMM YYYY',
developerTools: false,
soundscapeEnabled: false,
soundscapeMuted: true,
soundscapeVolume: 0.5,
soundscapeVoiceCap: 4,
};
export const getSettings = () => {