evOo-local/apps/web/src/lib/oo-vocals.ts

183 lines
5.0 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 = 1_600;
const COOLDOWN_MAX_MS = 2_800;
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 boostedPeak = Math.min(0.22, gainPeak * 3.2);
osc.type = "sine";
osc.frequency.setValueAtTime(baseHz, startAt);
osc.frequency.exponentialRampToValueAtTime(baseHz * 1.12, startAt + duration * 0.75);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(boostedPeak, startAt + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
if (typeof ctx.createBiquadFilter === "function") {
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = baseHz * 1.6;
filter.Q.value = 1.8;
osc.connect(filter);
filter.connect(gain);
} else {
osc.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;
}