* 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>
180 lines
5.4 KiB
TypeScript
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();
|