You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/sema/resolver.ts

162 lines
5.3 KiB

sema: revive resolver + signatures + real SoundChannel Restores the resolver-driven architecture that was deleted in the channel-modules refactor (commit a0a1485b). Each signal now flows through a single resolution that produces an `EffectiveSignature`, and each channel reads its own slice — so the visual hold, the sound earcon, and (future) motion / color / presence channels all share a single family-level perceptual budget defined in `SEMA_MAP`. New surfaces: - `src/uix/sema/sema-map.ts` — typed perceptual map (port of the legacy `sema-map.json`) with per-family `base` (motion / sound / color / presence) + `activeChannels` and per-intent `deltas` using the number / `{op:'add'}` / `{op:'multiply'}` / `{op:'replace'}` / recursive-merge semantics from before. - `src/uix/sema/resolver.ts` — pure `resolveSignature(signal)` that returns an `EffectiveSignature`. Empty signature when family is missing so channels skip rather than throw. Intent deltas only apply to valenced families. Optional `soundPack` per-event sample URLs. - `src/uix/sema/chans/sound.ts` — real `SoundChannel` (~290 lines, ported from a0a1485b^). Single AudioContext + master GainNode, auto-unlock for Safari/iOS via `click` / `touchstart` / `keydown` with capture, ADSR-lite synthesis (sine + 5th → biquad lowpass → envelope, AM modulator when roughness > 0.2), contour via `osc.detune`, optional sample playback with `AudioBuffer` cache. Errors absorbed silently — sema is ornamental. Architectural changes: - `Channel.handle(signal, effective)` — every channel now receives the resolved signature alongside the signal. Channels can ignore it. - `EngineSemantic.emit` resolves once per emit, dispatches both args. - `VisualChannel` hold resolution chain is now (highest priority first): 1. `signal.hold` 2. `effective.motion.duration` ← NEW: from sema-map 3. family fallback table (SEMA_DURATIONS) 4. engine `defaultHold` Behavior change: families with motion in the map (alert: 220ms, contact: 80ms, commit: 180ms, …) now use those values instead of the previous label-based 600ms / 120ms / 240ms fallbacks. The fallback table remains as a safety net for families without motion (sustain). - `EngineSemanticOptions` adds `sound?: true | false | SoundChannelOptions | Channel`. Sound is opt-in (audible side effects). Layouts wire `defineEngineSemantic({ sound: true })`. Tests: - New `resolver.test.ts` (7 tests) — locks down family base, intent deltas, multiplicative ops, transitional-family no-op, deep-clone. - `visual.test.ts` — every `handle` call now passes a stub `EffectiveSignature`. Two new tests: motion.duration takes the hold, and signal.hold overrides effective.motion.duration. - `engine.test.ts` — `makeChannel` mock signature updated. - `emit.test.ts` — alert family default test now expects 220ms (from sema-map) instead of 600ms. Verified: - vitest: 6 files / 53 tests green - smoke: 68/68 soma routes 200 OK - morfo-check: 66/66 morfos validate
5 months ago
/**
* resolveSignature(signal) — turns a `SemanticSignal` into the per-channel
* `EffectiveSignature` used by every channel.
*
* The engine resolves once per `emit(...)` and dispatches `(signal,
* effective)` to each registered channel. Channels read their own slice
* from `effective` (e.g. `SoundChannel` reads `effective.sound`) and skip
* if their slice is missing or their channel id is not in
* `activeChannels`.
*
* Resolution rule:
* 1. Look up `family` in `SEMA_MAP.families` to get the `base`
* signatures + `activeChannels`.
* 2. If the family is valenced and the signal carries an `intent`,
* apply the intent's `deltas` per channel (numeric add, op-based
* replace/multiply/add, recursive merge).
* 3. If the family has a `soundPack` entry for the signal name, attach
* the sample URL to `effective.sound.sampleUrl`.
* 4. Always return an `EffectiveSignature`. If `family` is missing,
* return one with empty channels — channels skip rather than throw.
*
* Pure function, no state. Override behaviour (runtime overrides, CSEM
* selectors) lived in the prior `Resolver` class — out of scope for this
* iteration; can be re-added without breaking the function signature.
*/
import type { SemanticSignal } from './signal'
import type { SemaFamily, SemaIntent } from './types'
import {
SEMA_MAP,
SEMA_VALENCED_FAMILY_LIST,
type ColorSignature,
type DeltaOp,
type DeltaValue,
type MotionSignature,
type PresenceSignature,
type SemaActiveChannel,
type SemaMap,
type SoundSignature
} from './sema-map'
export type {
ColorSignature,
MotionSignature,
PresenceSignature,
SemaActiveChannel,
SoundSignature
} from './sema-map'
export interface EffectiveSignature {
family: SemaFamily | undefined
intent: SemaIntent | undefined
activeChannels: readonly SemaActiveChannel[]
motion?: MotionSignature
sound?: SoundSignature
color?: ColorSignature
presence?: PresenceSignature
}
/**
* Empty signature returned when the input has no family. Channels see an
* `EffectiveSignature` with no active channels and skip their work.
*/
const EMPTY_SIGNATURE: EffectiveSignature = {
family: undefined,
intent: undefined,
activeChannels: []
}
export function resolveSignature(
signal: SemanticSignal,
map: SemaMap = SEMA_MAP
): EffectiveSignature {
if (!signal.family) return EMPTY_SIGNATURE
const familyEntry = map.families[signal.family]
if (!familyEntry) return EMPTY_SIGNATURE
let signature: EffectiveSignature = {
family: signal.family,
intent: signal.intent,
activeChannels: [...familyEntry.activeChannels],
motion: familyEntry.base.motion ? deepClone(familyEntry.base.motion) : undefined,
sound: familyEntry.base.sound ? deepClone(familyEntry.base.sound) : undefined,
color: familyEntry.base.color ? deepClone(familyEntry.base.color) : undefined,
presence: familyEntry.base.presence ? deepClone(familyEntry.base.presence) : undefined
}
// Intent deltas only apply to valenced families.
if (signal.intent && SEMA_VALENCED_FAMILY_LIST.includes(signal.family as never)) {
const intentEntry = map.intents[signal.intent]
if (intentEntry) {
signature = applyIntentDeltas(signature, intentEntry.deltas)
}
}
// Optional per-event sample URL pack (e.g. `'commit-affirm'` → '/sounds/...').
const eventLabel = composeEventLabel(signal.family, signal.intent)
const sampleUrl = map.soundPack[eventLabel]
if (sampleUrl && signature.sound) {
signature.sound.sampleUrl = sampleUrl
}
return signature
}
// ── Internals ──────────────────────────────────────────────────────────────
function composeEventLabel(family: SemaFamily, intent?: SemaIntent): string {
return intent ? `${family}-${intent}` : family
}
function deepClone<T>(value: T): T {
return structuredClone(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isDeltaOp(value: unknown): value is DeltaOp {
return isRecord(value) && typeof value.op === 'string'
}
function applyLeaf(base: unknown, delta: DeltaValue): unknown {
if (typeof delta === 'number') {
return typeof base === 'number' ? base + delta : delta
}
if (typeof delta === 'string' || typeof delta === 'boolean' || delta === null) {
return delta
}
if (isDeltaOp(delta)) {
if (delta.op === 'replace') return delta.value
if (typeof base !== 'number') return base
if (delta.op === 'multiply') return base * delta.factor
return base + delta.value
}
if (!isRecord(delta)) return base
if (!isRecord(base)) return base
const out: Record<string, unknown> = deepClone(base)
for (const [key, nextDelta] of Object.entries(delta)) {
out[key] = applyLeaf(out[key], nextDelta as DeltaValue)
}
return out
}
function applyIntentDeltas(
signature: EffectiveSignature,
deltas: Partial<Record<SemaActiveChannel, Record<string, DeltaValue>>>
): EffectiveSignature {
const next: EffectiveSignature = deepClone(signature)
for (const channel of next.activeChannels) {
const delta = deltas[channel]
if (!delta) continue
const current = next[channel]
if (!current) continue
;(next as unknown as Record<string, unknown>)[channel] = applyLeaf(current, delta)
}
return next
}

Powered by TurnKey Linux.