evOo-local/apps/web/src/lib/oo-vocals.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

177 lines
4.7 KiB
TypeScript

import { ooAudioBus } from "./oo-audio-bus";
import { readOoSoundEnabled } from "./oo-sound-pref";
export type OoVocalReaction = "ouh" | "mhm" | "aaah" | "mm" | "nod-down" | "nod-up" | "hm";
const COOLDOWN_MIN_MS = 8_000;
const COOLDOWN_MAX_MS = 12_000;
let lastPlayedAt = 0;
let nextCooldownMs = COOLDOWN_MIN_MS;
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function randomCooldown(): number {
return COOLDOWN_MIN_MS + Math.random() * (COOLDOWN_MAX_MS - COOLDOWN_MIN_MS);
}
function vocalFormant(
ctx: AudioContext,
destination: AudioNode,
baseHz: number,
startAt: number,
duration: number,
gainPeak: number,
) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = baseHz * 2.4;
filter.Q.value = 4.5;
osc.type = "triangle";
osc.frequency.setValueAtTime(baseHz, startAt);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(gainPeak, startAt + 0.025);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
osc.connect(filter);
filter.connect(gain);
gain.connect(destination);
osc.start(startAt);
osc.stop(startAt + duration + 0.02);
}
function playReactionOnContext(ctx: AudioContext, reaction: OoVocalReaction) {
const master = ooAudioBus.sfxDestination(ctx);
const t0 = ctx.currentTime + 0.01;
switch (reaction) {
case "ouh":
vocalFormant(ctx, master, 280, t0, 0.14, 0.055);
vocalFormant(ctx, master, 360, t0 + 0.06, 0.1, 0.04);
break;
case "mhm":
vocalFormant(ctx, master, 180, t0, 0.12, 0.042);
vocalFormant(ctx, master, 200, t0 + 0.1, 0.08, 0.032);
break;
case "aaah":
vocalFormant(ctx, master, 320, t0, 0.18, 0.048);
vocalFormant(ctx, master, 400, t0 + 0.08, 0.14, 0.038);
break;
case "mm":
vocalFormant(ctx, master, 160, t0, 0.16, 0.04);
break;
case "hm":
vocalFormant(ctx, master, 175, t0, 0.09, 0.038);
break;
case "nod-down":
vocalFormant(ctx, master, 165, t0, 0.07, 0.036);
break;
case "nod-up":
vocalFormant(ctx, master, 205, t0, 0.1, 0.038);
vocalFormant(ctx, master, 245, t0 + 0.08, 0.12, 0.032);
break;
default:
break;
}
}
class OoVocalsEngine {
/** State-change vocal — respects cooldown and reduced-motion. No loops. */
react(reaction: OoVocalReaction) {
if (!readOoSoundEnabled()) return;
if (prefersReducedMotion()) return;
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
const now = Date.now();
if (now - lastPlayedAt < nextCooldownMs) return;
const ctx = ooAudioBus.ensureContext();
if (!ctx) return;
const run = () => {
try {
playReactionOnContext(ctx, reaction);
lastPlayedAt = Date.now();
nextCooldownMs = randomCooldown();
} catch {
// Ignore playback failures.
}
};
if (ctx.state === "suspended") {
void ctx
.resume()
.then(run)
.catch(() => undefined);
return;
}
run();
}
/** Nod: mh on down, Mhh on up — single cooldown window. */
reactNod() {
if (!readOoSoundEnabled()) return;
if (prefersReducedMotion()) return;
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
const now = Date.now();
if (now - lastPlayedAt < nextCooldownMs) return;
const ctx = ooAudioBus.ensureContext();
if (!ctx) return;
const run = () => {
try {
const t0 = ctx.currentTime + 0.01;
const master = ooAudioBus.sfxDestination(ctx);
vocalFormant(ctx, master, 165, t0, 0.07, 0.036);
vocalFormant(ctx, master, 205, t0 + 0.09, 0.1, 0.038);
vocalFormant(ctx, master, 245, t0 + 0.17, 0.12, 0.032);
lastPlayedAt = Date.now();
nextCooldownMs = randomCooldown();
} catch {
// Ignore playback failures.
}
};
if (ctx.state === "suspended") {
void ctx
.resume()
.then(run)
.catch(() => undefined);
return;
}
run();
}
reactForEvent(kind: "need-you" | "success" | "nod" | "curious") {
switch (kind) {
case "need-you":
this.react("ouh");
break;
case "success":
this.react("hm");
break;
case "nod":
this.reactNod();
break;
case "curious":
this.react("mm");
break;
default:
break;
}
}
}
export const ooVocals = new OoVocalsEngine();
export function resetOoVocalsCooldownForTests(): void {
lastPlayedAt = 0;
nextCooldownMs = COOLDOWN_MIN_MS;
}