Reachy: the official head wobbler (speech tapper port) and silent emotion moves
Browse filesPorts the official Reachy Mini head wobbler into the browser simulator and makes the Reachy emotion moves silent.
- `source/src/game/reachy/speechTapper.js`: a line-for-line port of `reachy_mini/motion/speech_tapper.py` (SwayRollRT: 16 kHz PCM, 20 ms frames, 50 ms hops, the VAD with hysteresis and attack/release, the loudness curve, the six oscillators with the daemon's seeded phases).
- `source/src/game/reachy/headWobbler.js`: runs the tapper offline on a decoded line (for wav lines) and, for the browser's speech synthesis (no samples), on a synthetic voice: voiced bursts at a natural syllable rate (~4 Hz, jittered) while the utterance plays, silence otherwise. Replaces the fixed sine sway.
- `reachyController.js`: the tapper's offsets are composed on the head target in the world frame before the IK exactly as the daemon's backend does (`compose_world_offset`: R = R_off R_abs, t = t_abs + t_off).
- `emotionController.js`: `reachySay` drives the wobbler while speaking; the Reachy emotion moves play SILENT (movement only, no .ogg), as the real robot player does with `play_move(sound=False)`: only Reachy's spoken voice is heard. The .ogg files are still loaded harmlessly by nothing; they can stay.
Tested in RemiFabre/microduck-reachy-simulator (same code at the root `src/` layout there); recorded videos of the conversation script and of a two-robot scene look right: small syllable-synchronous head motion while Reachy speaks, no wobble on the moves.
|
@@ -2,13 +2,16 @@ import { playMicroduckEmotion } from "../microduck/microduckEmotions.js";
|
|
| 2 |
import { EMOTIONS, loadReachyEmotion } from "./emotions.js";
|
| 3 |
import { playUrl } from "../audio.js";
|
| 4 |
import { signed } from "../signed.js";
|
|
|
|
| 5 |
|
| 6 |
let selectedReachyVoice = null;
|
| 7 |
|
| 8 |
export function reachySay(text, reachy = null) {
|
| 9 |
try { globalThis.dispatchEvent?.(new CustomEvent("reachy-speech-start", { detail: { text: String(text) } })); } catch { /* recording hook is optional */ }
|
| 10 |
if (!globalThis.speechSynthesis || !globalThis.SpeechSynthesisUtterance) return Promise.resolve();
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
return new Promise((resolve) => {
|
| 13 |
let spoken = false;
|
| 14 |
const speak = () => {
|
|
@@ -26,7 +29,7 @@ export function reachySay(text, reachy = null) {
|
|
| 26 |
// Same playful childlike profile for every sentence.
|
| 27 |
utterance.pitch = 1.28;
|
| 28 |
utterance.rate = 1.02;
|
| 29 |
-
const done = () => {
|
| 30 |
utterance.onend = done;
|
| 31 |
utterance.onerror = done;
|
| 32 |
speechSynthesis.cancel();
|
|
@@ -85,9 +88,8 @@ export function createEmotionController({ runtime, reachy, setState }) {
|
|
| 85 |
|
| 86 |
async function playReachy(emotion, definition) {
|
| 87 |
const motion = await loadReachyEmotion(definition.motion);
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
finally { reachy.setWobbler(false, "emotion"); }
|
| 92 |
}
|
| 93 |
}
|
|
|
|
| 2 |
import { EMOTIONS, loadReachyEmotion } from "./emotions.js";
|
| 3 |
import { playUrl } from "../audio.js";
|
| 4 |
import { signed } from "../signed.js";
|
| 5 |
+
import { createSyntheticSpeech } from "../reachy/headWobbler.js";
|
| 6 |
|
| 7 |
let selectedReachyVoice = null;
|
| 8 |
|
| 9 |
export function reachySay(text, reachy = null) {
|
| 10 |
try { globalThis.dispatchEvent?.(new CustomEvent("reachy-speech-start", { detail: { text: String(text) } })); } catch { /* recording hook is optional */ }
|
| 11 |
if (!globalThis.speechSynthesis || !globalThis.SpeechSynthesisUtterance) return Promise.resolve();
|
| 12 |
+
// The official head wobbler (the daemon's speech tapper) on a synthetic voice: speech synthesis
|
| 13 |
+
// gives no samples to tap, so voiced bursts at a syllable rate stand in while the line plays.
|
| 14 |
+
const stopTaps = reachy?.setSpeechOffsets ? createSyntheticSpeech((o) => reachy.setSpeechOffsets(o)) : () => {};
|
| 15 |
return new Promise((resolve) => {
|
| 16 |
let spoken = false;
|
| 17 |
const speak = () => {
|
|
|
|
| 29 |
// Same playful childlike profile for every sentence.
|
| 30 |
utterance.pitch = 1.28;
|
| 31 |
utterance.rate = 1.02;
|
| 32 |
+
const done = () => { stopTaps(); resolve(); };
|
| 33 |
utterance.onend = done;
|
| 34 |
utterance.onerror = done;
|
| 35 |
speechSynthesis.cancel();
|
|
|
|
| 88 |
|
| 89 |
async function playReachy(emotion, definition) {
|
| 90 |
const motion = await loadReachyEmotion(definition.motion);
|
| 91 |
+
// Movement only: the real player plays the library moves silent (`play_move(sound=False)`),
|
| 92 |
+
// only Reachy's spoken voice is heard; the wobble follows the voice, not the move.
|
| 93 |
+
await reachy.playMotion(motion);
|
|
|
|
| 94 |
}
|
| 95 |
}
|
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// The official head wobbler, offline: the daemon feeds the speaker's PCM (resampled to 16 kHz)
|
| 2 |
+
// through the speech tapper and applies each 50 ms hop's offsets at that hop's playback time.
|
| 3 |
+
// Here the whole line is known in advance, so the hops are computed once from the decoded wav
|
| 4 |
+
// and applied against the audio clock while it plays (same timeline, deterministic).
|
| 5 |
+
import { SwayRollRT, HOP_MS } from "./speechTapper.js";
|
| 6 |
+
|
| 7 |
+
export const ZERO_OFFSETS = Object.freeze({ x: 0, y: 0, z: 0, roll: 0, pitch: 0, yaw: 0 });
|
| 8 |
+
const TAPPER_RATE = 16000;
|
| 9 |
+
|
| 10 |
+
// AudioBuffer -> [{x,y,z,roll,pitch,yaw}] per 50 ms hop, from t = 0 of the buffer.
|
| 11 |
+
export async function analyseLine(buffer) {
|
| 12 |
+
let pcm;
|
| 13 |
+
if (buffer.sampleRate === TAPPER_RATE && buffer.numberOfChannels === 1) pcm = buffer.getChannelData(0);
|
| 14 |
+
else {
|
| 15 |
+
const frames = Math.ceil(buffer.duration * TAPPER_RATE);
|
| 16 |
+
const off = new OfflineAudioContext(1, frames, TAPPER_RATE);
|
| 17 |
+
const src = off.createBufferSource(); src.buffer = buffer; src.connect(off.destination); src.start();
|
| 18 |
+
pcm = (await off.startRendering()).getChannelData(0);
|
| 19 |
+
}
|
| 20 |
+
return new SwayRollRT(TAPPER_RATE).feed(pcm);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// The hop in force `seconds` into the line (the daemon fires hop i at play_at + i * 50 ms).
|
| 24 |
+
export function offsetsAt(hops, seconds) {
|
| 25 |
+
if (!hops?.length || seconds < 0) return ZERO_OFFSETS;
|
| 26 |
+
const i = Math.floor(seconds * 1000 / HOP_MS);
|
| 27 |
+
return i < hops.length ? hops[i] : ZERO_OFFSETS;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Speech without samples (the browser's speech synthesis): feed the tapper a synthetic voice
|
| 31 |
+
// instead of a fixed sine, so the wobble keeps the official character (VAD, syllable-rate taps,
|
| 32 |
+
// loudness curve) while an utterance plays. Voiced bursts at a natural syllable rate, ~4 Hz with
|
| 33 |
+
// jitter, 55-65 % duty, noise at a speech-like level; silence otherwise. One hop (50 ms) per tick.
|
| 34 |
+
export function createSyntheticSpeech(apply) {
|
| 35 |
+
const tapper = new SwayRollRT(TAPPER_RATE);
|
| 36 |
+
const hop = Math.floor(TAPPER_RATE * HOP_MS / 1000);
|
| 37 |
+
let t = 0, nextSyllable = 0, voicedUntil = 0, level = 0.09, seed = 12345;
|
| 38 |
+
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
|
| 39 |
+
const timer = setInterval(() => {
|
| 40 |
+
if (t >= nextSyllable) {
|
| 41 |
+
const period = 0.25 * (0.8 + 0.4 * rnd()); // ~4 Hz, jittered
|
| 42 |
+
voicedUntil = t + period * (0.55 + 0.1 * rnd());
|
| 43 |
+
nextSyllable = t + period;
|
| 44 |
+
level = 0.06 + 0.06 * rnd(); // -25 .. -19 dBFS rms
|
| 45 |
+
if (rnd() < 0.12) nextSyllable += 0.3; // a breath between phrases now and then
|
| 46 |
+
}
|
| 47 |
+
const voiced = t < voicedUntil;
|
| 48 |
+
const pcm = new Float32Array(hop);
|
| 49 |
+
const a = voiced ? level : 0.0007;
|
| 50 |
+
for (let i = 0; i < hop; i++) pcm[i] = a * (rnd() * 2 - 1) * 1.7;
|
| 51 |
+
for (const o of tapper.feed(pcm)) apply(o);
|
| 52 |
+
t += HOP_MS / 1000;
|
| 53 |
+
}, HOP_MS);
|
| 54 |
+
return () => { clearInterval(timer); apply(ZERO_OFFSETS); };
|
| 55 |
+
}
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { MOTOR_NAMES, HOME, INITIAL_MOTOR_POSITIONS, poseToTarget, interpolateTarget, createReachyKinematics } from "./reachyKinematics.js";
|
| 2 |
import { REACHY_PREFIX } from "./reachyWorld.js";
|
| 3 |
import { abortError } from "../simulationTimeline.js";
|
| 4 |
-
import { Matrix4 } from "three";
|
| 5 |
|
| 6 |
export function createReachyController({ mujoco, getWorld, calibration, timeline, onState = () => {}, isLocked = () => false }) {
|
| 7 |
const ik = createReachyKinematics(calibration);
|
|
@@ -9,6 +9,10 @@ export function createReachyController({ mujoco, getWorld, calibration, timeline
|
|
| 9 |
let boundModel, addresses = [];
|
| 10 |
let active = null, queue = [], speed = 1;
|
| 11 |
const wobblerSources = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const publish = (extra = {}) => onState({ playing: !!active, queue: queue.map((q) => q.label), speed, ...extra });
|
| 13 |
function apply(next) {
|
| 14 |
const motors = ik(next); // validate the complete target before changing any motor
|
|
@@ -26,9 +30,16 @@ export function createReachyController({ mujoco, getWorld, calibration, timeline
|
|
| 26 |
boundModel = model;
|
| 27 |
}
|
| 28 |
let output = motorTargets;
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
addresses.forEach((id, i) => { data.ctrl[id] = output[i]; });
|
| 34 |
}
|
|
@@ -62,6 +73,8 @@ export function createReachyController({ mujoco, getWorld, calibration, timeline
|
|
| 62 |
if (value) wobblerSources.add(source);
|
| 63 |
else wobblerSources.delete(source);
|
| 64 |
},
|
|
|
|
|
|
|
| 65 |
get target() { return target; },
|
| 66 |
get motorTargets() { return [...motorTargets]; },
|
| 67 |
stop() { active?.abort(); },
|
|
|
|
| 1 |
import { MOTOR_NAMES, HOME, INITIAL_MOTOR_POSITIONS, poseToTarget, interpolateTarget, createReachyKinematics } from "./reachyKinematics.js";
|
| 2 |
import { REACHY_PREFIX } from "./reachyWorld.js";
|
| 3 |
import { abortError } from "../simulationTimeline.js";
|
| 4 |
+
import { Matrix4, Euler } from "three";
|
| 5 |
|
| 6 |
export function createReachyController({ mujoco, getWorld, calibration, timeline, onState = () => {}, isLocked = () => false }) {
|
| 7 |
const ik = createReachyKinematics(calibration);
|
|
|
|
| 9 |
let boundModel, addresses = [];
|
| 10 |
let active = null, queue = [], speed = 1;
|
| 11 |
const wobblerSources = new Set();
|
| 12 |
+
// The official head wobbler's per-hop offsets (reachy_mini/motion/speech_tapper.py, ported in
|
| 13 |
+
// ./speechTapper.js): {x, y, z} metres, {roll, pitch, yaw} radians, composed in the world frame
|
| 14 |
+
// before the IK exactly as the daemon does (R = R_off R_abs, t = t_abs + t_off).
|
| 15 |
+
let speechOffsets = { x: 0, y: 0, z: 0, roll: 0, pitch: 0, yaw: 0 };
|
| 16 |
const publish = (extra = {}) => onState({ playing: !!active, queue: queue.map((q) => q.label), speed, ...extra });
|
| 17 |
function apply(next) {
|
| 18 |
const motors = ik(next); // validate the complete target before changing any motor
|
|
|
|
| 30 |
boundModel = model;
|
| 31 |
}
|
| 32 |
let output = motorTargets;
|
| 33 |
+
const o = speechOffsets;
|
| 34 |
+
if (o.x || o.y || o.z || o.roll || o.pitch || o.yaw) {
|
| 35 |
+
const rot = new Matrix4().makeRotationFromEuler(new Euler(o.roll, o.pitch, o.yaw, "ZYX"));
|
| 36 |
+
const head = target.head.clone();
|
| 37 |
+
const e = head.elements;
|
| 38 |
+
const tx = e[12], ty = e[13], tz = e[14];
|
| 39 |
+
head.setPosition(0, 0, 0);
|
| 40 |
+
head.premultiply(rot);
|
| 41 |
+
head.setPosition(tx + o.x, ty + o.y, tz + o.z);
|
| 42 |
+
try { output = ik({ ...target, head }); } catch { output = motorTargets; }
|
| 43 |
}
|
| 44 |
addresses.forEach((id, i) => { data.ctrl[id] = output[i]; });
|
| 45 |
}
|
|
|
|
| 73 |
if (value) wobblerSources.add(source);
|
| 74 |
else wobblerSources.delete(source);
|
| 75 |
},
|
| 76 |
+
// The official wobbler's offsets for this hop (see ./headWobbler.js).
|
| 77 |
+
setSpeechOffsets(offsets) { speechOffsets = offsets || { x: 0, y: 0, z: 0, roll: 0, pitch: 0, yaw: 0 }; },
|
| 78 |
get target() { return target; },
|
| 79 |
get motorTargets() { return [...motorTargets]; },
|
| 80 |
stop() { active?.abort(); },
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Port of the OFFICIAL Reachy Mini speech tapper (reachy_mini/motion/speech_tapper.py,
|
| 2 |
+
// "SwayRollRT"): 16 kHz float PCM in, one sway dict per 50 ms hop out. Same tunables, same
|
| 3 |
+
// VAD (hysteresis + attack / release), same loudness curve, same six oscillators; the seeded
|
| 4 |
+
// oscillator phases are the numpy default_rng(7) values the daemon draws.
|
| 5 |
+
export const FRAME_MS = 20;
|
| 6 |
+
export const HOP_MS = 50;
|
| 7 |
+
const SWAY_MASTER = 1.5;
|
| 8 |
+
const SENS_DB_OFFSET = 4.0;
|
| 9 |
+
const VAD_DB_ON = -35.0, VAD_DB_OFF = -45.0;
|
| 10 |
+
const VAD_ATTACK_MS = 40, VAD_RELEASE_MS = 250;
|
| 11 |
+
const ENV_FOLLOW_GAIN = 0.65;
|
| 12 |
+
const SWAY_F_PITCH = 2.2, SWAY_A_PITCH_DEG = 4.5;
|
| 13 |
+
const SWAY_F_YAW = 0.6, SWAY_A_YAW_DEG = 7.5;
|
| 14 |
+
const SWAY_F_ROLL = 1.3, SWAY_A_ROLL_DEG = 2.25;
|
| 15 |
+
const SWAY_F_X = 0.35, SWAY_A_X_MM = 4.5;
|
| 16 |
+
const SWAY_F_Y = 0.45, SWAY_A_Y_MM = 3.75;
|
| 17 |
+
const SWAY_F_Z = 0.25, SWAY_A_Z_MM = 2.25;
|
| 18 |
+
const SWAY_DB_LOW = -46.0, SWAY_DB_HIGH = -18.0;
|
| 19 |
+
const LOUDNESS_GAMMA = 0.9;
|
| 20 |
+
const SWAY_ATTACK_MS = 50, SWAY_RELEASE_MS = 250;
|
| 21 |
+
const ATTACK_FR = Math.max(1, Math.floor(VAD_ATTACK_MS / HOP_MS));
|
| 22 |
+
const RELEASE_FR = Math.max(1, Math.floor(VAD_RELEASE_MS / HOP_MS));
|
| 23 |
+
const SWAY_ATTACK_FR = Math.max(1, Math.floor(SWAY_ATTACK_MS / HOP_MS));
|
| 24 |
+
const SWAY_RELEASE_FR = Math.max(1, Math.floor(SWAY_RELEASE_MS / HOP_MS));
|
| 25 |
+
// np.random.default_rng(7).random() * 2 pi, six draws, in the daemon's order (pitch, yaw, roll, x, y, z).
|
| 26 |
+
const PHASES = [3.927590651355011, 5.637360571650786, 4.873776931938056, 1.4150185072200883, 1.8860003910648933, 5.488698173149897];
|
| 27 |
+
const rad = Math.PI / 180;
|
| 28 |
+
|
| 29 |
+
function rmsDbfs(x) {
|
| 30 |
+
let s = 0;
|
| 31 |
+
for (let i = 0; i < x.length; i++) s += x[i] * x[i];
|
| 32 |
+
const rms = Math.sqrt(s / x.length + 1e-12);
|
| 33 |
+
return 20 * Math.log10(rms + 1e-12);
|
| 34 |
+
}
|
| 35 |
+
function loudnessGain(db) {
|
| 36 |
+
let t = (db + SENS_DB_OFFSET - SWAY_DB_LOW) / (SWAY_DB_HIGH - SWAY_DB_LOW);
|
| 37 |
+
t = Math.min(1, Math.max(0, t));
|
| 38 |
+
return LOUDNESS_GAMMA !== 1 ? t ** LOUDNESS_GAMMA : t;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export class SwayRollRT {
|
| 42 |
+
constructor(sampleRate = 16000) {
|
| 43 |
+
this.sampleRate = sampleRate | 0;
|
| 44 |
+
this.frame = Math.floor(this.sampleRate * FRAME_MS / 1000);
|
| 45 |
+
this.hop = Math.floor(this.sampleRate * HOP_MS / 1000);
|
| 46 |
+
this.reset();
|
| 47 |
+
}
|
| 48 |
+
reset() {
|
| 49 |
+
this.samples = new Float32Array(0); this.carry = new Float32Array(0);
|
| 50 |
+
this.vadOn = false; this.vadAbove = 0; this.vadBelow = 0;
|
| 51 |
+
this.swayEnv = 0; this.swayUp = 0; this.swayDown = 0; this.t = 0;
|
| 52 |
+
}
|
| 53 |
+
// Feed float32 mono PCM at this.sampleRate; returns [{pitch, yaw, roll, x, y, z}] per hop
|
| 54 |
+
// (radians and metres, the units the daemon composes on the head pose).
|
| 55 |
+
feed(pcm) {
|
| 56 |
+
if (!pcm.length) return [];
|
| 57 |
+
if (this.carry.length) { const c = new Float32Array(this.carry.length + pcm.length); c.set(this.carry); c.set(pcm, this.carry.length); this.carry = c; }
|
| 58 |
+
else this.carry = pcm;
|
| 59 |
+
const out = [];
|
| 60 |
+
while (this.carry.length >= this.hop) {
|
| 61 |
+
const hop = this.carry.subarray(0, this.hop);
|
| 62 |
+
this.carry = this.carry.subarray(this.hop);
|
| 63 |
+
if (this.samples.length) { const c = new Float32Array(this.samples.length + hop.length); c.set(this.samples); c.set(hop, this.samples.length); this.samples = c.subarray(Math.max(0, c.length - this.frame)); }
|
| 64 |
+
else this.samples = hop.subarray(Math.max(0, hop.length - this.frame)).slice();
|
| 65 |
+
if (this.samples.length < this.frame) { this.t += HOP_MS / 1000; continue; }
|
| 66 |
+
const db = rmsDbfs(this.samples);
|
| 67 |
+
if (db >= VAD_DB_ON) { this.vadAbove += 1; this.vadBelow = 0; if (!this.vadOn && this.vadAbove >= ATTACK_FR) this.vadOn = true; }
|
| 68 |
+
else if (db <= VAD_DB_OFF) { this.vadBelow += 1; this.vadAbove = 0; if (this.vadOn && this.vadBelow >= RELEASE_FR) this.vadOn = false; }
|
| 69 |
+
if (this.vadOn) { this.swayUp = Math.min(SWAY_ATTACK_FR, this.swayUp + 1); this.swayDown = 0; }
|
| 70 |
+
else { this.swayDown = Math.min(SWAY_RELEASE_FR, this.swayDown + 1); this.swayUp = 0; }
|
| 71 |
+
const up = this.swayUp / SWAY_ATTACK_FR, down = 1 - this.swayDown / SWAY_RELEASE_FR;
|
| 72 |
+
const target = this.vadOn ? up : down;
|
| 73 |
+
this.swayEnv += ENV_FOLLOW_GAIN * (target - this.swayEnv);
|
| 74 |
+
this.swayEnv = Math.min(1, Math.max(0, this.swayEnv));
|
| 75 |
+
const loud = loudnessGain(db) * SWAY_MASTER, env = this.swayEnv;
|
| 76 |
+
this.t += HOP_MS / 1000;
|
| 77 |
+
const w = 2 * Math.PI * this.t, g = loud * env;
|
| 78 |
+
out.push({
|
| 79 |
+
pitch: SWAY_A_PITCH_DEG * rad * g * Math.sin(w * SWAY_F_PITCH + PHASES[0]),
|
| 80 |
+
yaw: SWAY_A_YAW_DEG * rad * g * Math.sin(w * SWAY_F_YAW + PHASES[1]),
|
| 81 |
+
roll: SWAY_A_ROLL_DEG * rad * g * Math.sin(w * SWAY_F_ROLL + PHASES[2]),
|
| 82 |
+
x: SWAY_A_X_MM / 1000 * g * Math.sin(w * SWAY_F_X + PHASES[3]),
|
| 83 |
+
y: SWAY_A_Y_MM / 1000 * g * Math.sin(w * SWAY_F_Y + PHASES[4]),
|
| 84 |
+
z: SWAY_A_Z_MM / 1000 * g * Math.sin(w * SWAY_F_Z + PHASES[5]),
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
return out;
|
| 88 |
+
}
|
| 89 |
+
}
|