* 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>
177 lines
4.7 KiB
TypeScript
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;
|
|
}
|