evOo-local/apps/web/src/lib/oo-sound.ts
cynarAI 5ae8f15931 feat(web): Ingo UX — Proaktivität, Oo-Gesicht, dezentes Audio (#76)
* feat(web): Ingo UX — proactivity signals, Oo face richness, soft audio

Priority S: Heute Brief buckets (Needs you ≤3 / Doing / Done), roster doing-verbs
and Braucht-dich chips, sidebar badge on Heute, need-you ping on waiting states.

Priority A/B: Web Audio cues (app-start shimmer, interact tick, success-resolve,
need-you ouh), reaction vocals with cooldown, optional eOo cold-open voice mark.
Oo eyes gain iris/specular detail and asymmetric blink; hands reduced to rest
except punctuation poses; speaking mouth animates.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): biome format and unused import in HeuteBrief

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): a11y and import order for Heute badge

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* docs+motion: Motion God craft — pose stagger, audio bus, ducking

Add MOTION-EVOO-CHARACTER.md (Brand Soul brief): eyes lead, mouth ~80%,
hands punctuation, 40–80 ms eye→mouth lag, 220–320 ms springs, SFX LUFS
and duck-under-TTS/PTT, nod mh/Mhh, reduced-motion rules.

Implement motion-tokens, staggered mouthPose in EvooMascot, shared
oo-audio-bus with voice-lane ducking, mint→gold success, hm nod vocal.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): align conversation roster with bot-only sidebar groups after rebase

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 14:39:40 +02:00

180 lines
5.4 KiB
TypeScript

import { ooAudioBus } from "./oo-audio-bus";
import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref";
export type OoSoundCue =
| "app-start"
| "interact"
| "success-resolve"
| "need-you"
| "work-complete"
| "listening-start"
| "listening-stop"
| "speaking-start"
| "speaking-done"
| "attention"
| "message-sent"
| "handoff"
| "group-spark";
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tone(
ctx: AudioContext,
destination: AudioNode,
frequency: number,
startAt: number,
duration: number,
options: {
type?: OscillatorType;
gain?: number;
attack?: number;
release?: number;
} = {},
) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const peak = options.gain ?? 0.08;
const attack = options.attack ?? 0.012;
const release = options.release ?? Math.min(duration * 0.55, 0.18);
osc.type = options.type ?? "sine";
osc.frequency.setValueAtTime(frequency, startAt);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(peak, startAt + attack);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration - release);
osc.connect(gain);
gain.connect(destination);
osc.start(startAt);
osc.stop(startAt + duration);
}
function sweep(
ctx: AudioContext,
destination: AudioNode,
fromHz: number,
toHz: number,
startAt: number,
duration: number,
options: { gain?: number; type?: OscillatorType } = {},
) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const peak = options.gain ?? 0.05;
osc.type = options.type ?? "sine";
osc.frequency.setValueAtTime(fromHz, startAt);
osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 1), startAt + duration * 0.85);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(peak, startAt + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
osc.connect(gain);
gain.connect(destination);
osc.start(startAt);
osc.stop(startAt + duration);
}
function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) {
const master = ooAudioBus.sfxDestination(ctx);
const t0 = ctx.currentTime + 0.01;
switch (cue) {
case "app-start":
sweep(ctx, master, 523.25, 659.25, t0, 0.14, { gain: 0.038, type: "sine" });
sweep(ctx, master, 659.25, 493.88, t0 + 0.1, 0.18, { gain: 0.032, type: "triangle" });
break;
case "interact":
tone(ctx, master, 1046.5, t0, 0.06, {
gain: 0.018,
attack: 0.003,
release: 0.04,
type: "triangle",
});
break;
case "success-resolve":
case "work-complete":
// mint → gold resolve (~270 ms)
sweep(ctx, master, 523.25, 659.25, t0, 0.12, { gain: 0.042, type: "sine" });
sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.036, type: "triangle" });
tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.03, type: "sine" });
break;
case "need-you":
sweep(ctx, master, 220, 330, t0, 0.1, { gain: 0.042, type: "triangle" });
tone(ctx, master, 277.18, t0 + 0.09, 0.12, { gain: 0.035, release: 0.08 });
break;
case "listening-start":
tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" });
tone(ctx, master, 330, t0 + 0.04, 0.28, { gain: 0.035 });
break;
case "listening-stop":
tone(ctx, master, 330, t0, 0.18, { gain: 0.03, release: 0.12 });
break;
case "speaking-start":
tone(ctx, master, 440, t0, 0.2, { gain: 0.04, type: "sine" });
break;
case "speaking-done":
tone(ctx, master, 523.25, t0, 0.24, { gain: 0.035 });
tone(ctx, master, 392, t0 + 0.08, 0.3, { gain: 0.028, release: 0.14 });
break;
case "attention":
tone(ctx, master, 277.18, t0, 0.16, { gain: 0.05, type: "triangle" });
tone(ctx, master, 233.08, t0 + 0.12, 0.22, { gain: 0.045 });
break;
case "message-sent":
tone(ctx, master, 880, t0, 0.1, { gain: 0.022, attack: 0.004, release: 0.06 });
break;
case "handoff":
tone(ctx, master, 349.23, t0, 0.2, { gain: 0.04 });
tone(ctx, master, 466.16, t0 + 0.1, 0.24, { gain: 0.035, type: "triangle" });
break;
case "group-spark":
tone(ctx, master, 587.33, t0, 0.11, { gain: 0.032, attack: 0.004, type: "triangle" });
tone(ctx, master, 739.99, t0 + 0.06, 0.14, { gain: 0.028, type: "sine" });
tone(ctx, master, 880, t0 + 0.12, 0.1, { gain: 0.022, release: 0.05 });
break;
default:
break;
}
}
class OoSoundEngine {
private enabled = readOoSoundEnabled();
isEnabled(): boolean {
return this.enabled;
}
setEnabled(next: boolean) {
this.enabled = next;
writeOoSoundEnabled(next);
}
play(cue: OoSoundCue) {
if (!this.enabled) return;
if (prefersReducedMotion()) return;
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
const ctx = ooAudioBus.ensureContext();
if (!ctx) return;
const run = () => {
try {
playCueOnContext(ctx, cue);
} catch {
// Ignore playback failures (autoplay policy, suspended context).
}
};
if (ctx.state === "suspended") {
void ctx
.resume()
.then(run)
.catch(() => undefined);
return;
}
run();
}
}
export const ooSound = new OoSoundEngine();