diff --git a/src/app/features/soundscape/device.ts b/src/app/features/soundscape/device.ts deleted file mode 100644 index ecdf1593..00000000 --- a/src/app/features/soundscape/device.ts +++ /dev/null @@ -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); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts deleted file mode 100644 index 7d7b2476..00000000 --- a/src/app/features/soundscape/frame.ts +++ /dev/null @@ -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 = (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 deleted file mode 100644 index 027cc27a..00000000 --- a/src/app/features/soundscape/scene.ts +++ /dev/null @@ -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 = { 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 deleted file mode 100644 index e1d23010..00000000 --- a/src/app/features/soundscape/socket.ts +++ /dev/null @@ -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, - }; -}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts deleted file mode 100644 index e07db5b9..00000000 --- a/src/app/features/soundscape/soundscape.test.ts +++ /dev/null @@ -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 => ({ - 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 deleted file mode 100644 index d20e55ad..00000000 --- a/src/app/features/soundscape/voices.ts +++ /dev/null @@ -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 = { 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 d4deb39c..6a894350 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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 = () => {