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

97 lines
2.7 KiB
TypeScript

import { ooAudioBus } from "./oo-audio-bus";
import { readOoSoundEnabled } from "./oo-sound-pref";
const SESSION_KEY = "evoo_voice_mark_played";
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function hasPlayedThisSession(): boolean {
if (typeof window === "undefined" || !window.sessionStorage) return true;
return window.sessionStorage.getItem(SESSION_KEY) === "1";
}
function markPlayed(): void {
if (typeof window === "undefined" || !window.sessionStorage) return;
window.sessionStorage.setItem(SESSION_KEY, "1");
}
function playVoiceMarkOnContext(ctx: AudioContext) {
const master = ooAudioBus.sfxDestination(ctx);
const t0 = ctx.currentTime + 0.01;
const eOsc = ctx.createOscillator();
const eGain = ctx.createGain();
const eFilter = ctx.createBiquadFilter();
eFilter.type = "bandpass";
eFilter.frequency.value = 480;
eFilter.Q.value = 5;
eOsc.type = "triangle";
eOsc.frequency.setValueAtTime(320, t0);
eGain.gain.setValueAtTime(0.0001, t0);
eGain.gain.exponentialRampToValueAtTime(0.05, t0 + 0.02);
eGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12);
eOsc.connect(eFilter);
eFilter.connect(eGain);
eGain.connect(master);
eOsc.start(t0);
eOsc.stop(t0 + 0.14);
const ooOsc = ctx.createOscillator();
const ooGain = ctx.createGain();
const ooFilter = ctx.createBiquadFilter();
ooFilter.type = "bandpass";
ooFilter.frequency.value = 360;
ooFilter.Q.value = 4;
ooOsc.type = "sine";
ooOsc.frequency.setValueAtTime(280, t0 + 0.1);
ooOsc.frequency.exponentialRampToValueAtTime(220, t0 + 0.38);
ooGain.gain.setValueAtTime(0.0001, t0 + 0.1);
ooGain.gain.exponentialRampToValueAtTime(0.055, t0 + 0.14);
ooGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.42);
ooOsc.connect(ooFilter);
ooFilter.connect(ooGain);
ooGain.connect(master);
ooOsc.start(t0 + 0.1);
ooOsc.stop(t0 + 0.45);
}
class OoVoiceMarkEngine {
maybePlayColdOpen() {
if (!readOoSoundEnabled()) return;
if (prefersReducedMotion()) return;
if (hasPlayedThisSession()) return;
const ctx = ooAudioBus.ensureContext();
if (!ctx) return;
const run = () => {
try {
playVoiceMarkOnContext(ctx);
markPlayed();
} catch {
// Ignore playback failures.
}
};
if (ctx.state === "suspended") {
void ctx
.resume()
.then(run)
.catch(() => undefined);
return;
}
run();
}
}
export const ooVoiceMark = new OoVoiceMarkEngine();
export function resetOoVoiceMarkForTests(): void {
if (typeof window !== "undefined" && window.sessionStorage) {
window.sessionStorage.removeItem(SESSION_KEY);
}
}