soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear)
Reads the grammar frame (AquaSynth docs/soundscape-grammar.md @ aeae510) into a
per-mind table, plans voices (neutral overrides all, Muted, cap, master mute,
weak-device off) and the drawn set. No activity source exists yet
(ratatoskr❓activity-feed-source) and no audio assets, so nothing sounds.
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
b33d8de956
commit
c4c00d7127
7 changed files with 393 additions and 0 deletions
12
src/app/features/soundscape/device.ts
Normal file
12
src/app/features/soundscape/device.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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);
|
||||
95
src/app/features/soundscape/frame.ts
Normal file
95
src/app/features/soundscape/frame.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
8
src/app/features/soundscape/scene.ts
Normal file
8
src/app/features/soundscape/scene.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
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);
|
||||
47
src/app/features/soundscape/socket.ts
Normal file
47
src/app/features/soundscape/socket.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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,
|
||||
};
|
||||
};
|
||||
171
src/app/features/soundscape/soundscape.test.ts
Normal file
171
src/app/features/soundscape/soundscape.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
49
src/app/features/soundscape/voices.ts
Normal file
49
src/app/features/soundscape/voices.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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 }));
|
||||
};
|
||||
|
|
@ -47,6 +47,12 @@ 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 = {
|
||||
|
|
@ -81,6 +87,11 @@ const defaultSettings: Settings = {
|
|||
dateFormatString: 'D MMM YYYY',
|
||||
|
||||
developerTools: false,
|
||||
|
||||
soundscapeEnabled: false,
|
||||
soundscapeMuted: true,
|
||||
soundscapeVolume: 0.5,
|
||||
soundscapeVoiceCap: 4,
|
||||
};
|
||||
|
||||
export const getSettings = () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue