diff --git a/src/app/features/soundscape/device.ts b/src/app/features/soundscape/device.ts new file mode 100644 index 00000000..ecdf1593 --- /dev/null +++ b/src/app/features/soundscape/device.ts @@ -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); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts new file mode 100644 index 00000000..7d7b2476 --- /dev/null +++ b/src/app/features/soundscape/frame.ts @@ -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 = (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; + 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(); + + 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); + } +} diff --git a/src/app/features/soundscape/scene.ts b/src/app/features/soundscape/scene.ts new file mode 100644 index 00000000..027cc27a --- /dev/null +++ b/src/app/features/soundscape/scene.ts @@ -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 = { 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); diff --git a/src/app/features/soundscape/socket.ts b/src/app/features/soundscape/socket.ts new file mode 100644 index 00000000..e1d23010 --- /dev/null +++ b/src/app/features/soundscape/socket.ts @@ -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, + }; +}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts new file mode 100644 index 00000000..e07db5b9 --- /dev/null +++ b/src/app/features/soundscape/soundscape.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts new file mode 100644 index 00000000..d20e55ad --- /dev/null +++ b/src/app/features/soundscape/voices.ts @@ -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 = { Low: 0, Mid: 1, High: 2, Peak: 3 }; +/** Grammar section 3: Idle and Waiting default silent. */ +const SILENT_KINDS: ReadonlySet = new Set(['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 })); +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 6a894350..d4deb39c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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 = () => {