* 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>
97 lines
2.7 KiB
TypeScript
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);
|
|
}
|
|
}
|