Skip to content

Commit e2292ec

Browse files
feat(voice): add adjustable lip-sync controls (#67)
* feat(voice): add adjustable lip-sync controls * test(voice): keep lip-sync normalization test pure * fix(voice): stack lip-sync sliders and add section dividers Match Audio source field-label layout (title → control → hint) and Settings-style panel dividers between Voice sections. --------- Co-authored-by: rosspeili <vpeilivanidis@gmail.com>
1 parent 8fe4177 commit e2292ec

18 files changed

Lines changed: 220 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- **Adjustable lip-sync response** — Gear → **Voice** now exposes **Sensitivity** (`0.25`–`4.00`) for quiet inputs and **Mouth limit** (`0.10`–`1.00`) for the VRM expression ceiling. Both values persist in `config.yaml`, default to the previous response at `1.00`, and reset on double-click.
1112
- **SECURITY.md** — supported versions, private reporting (`input@arpacorp.net` or GitHub Security Advisories), in-scope areas (installer, loopback agent bus/MCP, capture, VRoid OAuth, persistence), and out-of-scope boundaries. Linked from README and CONTRIBUTING. (#26)
1213
- **Asset license manifest** — [`docs/assets-manifest.yml`](docs/assets-manifest.yml) inventories bundled VRM/VRMA/environment media, derived thumbnails, installer branding, documentation screenshots, and runtime VRM libraries with paths, licenses, credit lines, and audit status. Validated in `npm test` via `avatar/scripts/validate-assets-manifest.mjs`. Linked from [Assets & credits](docs/assets-and-credits.md), README, CONTRIBUTING, and the maintainer release checklist. (#11)
1314
- **Local agent bus** — Settings → **Agents**. An opt-in loopback server (`127.0.0.1:47903`, off by default) so scripts and agent frameworks can drive the avatar: `POST /v1/command` and a WebSocket at `/v1/socket` as peers, both dispatching the *existing* stage commands (`animation.play`, `animation.default`, `animation.stop`, `avatar.set`, `environment.set`, `audio.source`) rather than a second set of names. `GET /v1/state` lists what is on stage with **id and label**, since a custom folder derives animation ids from file paths and a caller cannot invent them — the label works as a play id, and `playableOnce` says which clips accept `"mode": "once"`. Omitting `mode` still means *select*, which persists to `config.yaml`; agents almost always want `once`, and every example says so. Validation happens in the Electron main process against a catalog the window reports, so a request is answered with the same error codes the UI produces and the accepted action is applied by the window — a `200` means accepted, not that the model finished loading. A token is minted on first enable, reused after that, and stored encrypted with the OS keychain rather than in `config.yaml` (which the renderer rewrites on every change); it travels in `Authorization: Bearer`, never a query string. Anything carrying an `Origin` header is refused on both transports, so a web page — including a local dev server — cannot drive the avatar behind your back. Fixed port with no silent fallback, because the copied `curl` example names one. The WebSocket replies to what it is sent and pushes nothing; every reply carries an `id`, so events could be added later as frames without one. MCP is deliberately not in this: an adapter can sit on top of these HTTP commands later — which is what `/mcp` below now does. See [Local agent bus](docs/agents/local-bus.md). (#6)

‎avatar/src/components/AvatarStage.jsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
resolveAnimationId,
77
} from '../config/animations';
88
import { getAudioSourceOptions, getDefaultAudioSourceId } from '../config/audioSources';
9+
import {
10+
defaultLipSyncMouthStrength,
11+
defaultLipSyncSensitivity,
12+
} from '../config/lipSyncSensitivity';
913
import {
1014
customEnvironments,
1115
defaultColor,
@@ -90,6 +94,8 @@ export function AvatarStage() {
9094
const [audioFile, setAudioFile] = useState(null);
9195
const [windowSourceId, setWindowSourceId] = useState(null);
9296
const [audioSourceId, setAudioSourceId] = useState(getDefaultAudioSourceId);
97+
const [lipSyncSensitivity, setLipSyncSensitivity] = useState(defaultLipSyncSensitivity);
98+
const [lipSyncMouthStrength, setLipSyncMouthStrength] = useState(defaultLipSyncMouthStrength);
9399
const [settingsPath, setSettingsPath] = useState(null);
94100
// Session-only: a VRoid Hub character is never persisted to config.yaml
95101
// (kept in memory only, per VRoid Hub's licensing rules for linked-app
@@ -126,7 +132,7 @@ export function AvatarStage() {
126132
const { level, levelRef, speaking, status: audioStatus, error: audioError, restart } = useAudioSource(
127133
audioSourceId,
128134
audioFile,
129-
{ windowSourceId },
135+
{ windowSourceId, sensitivity: lipSyncSensitivity },
130136
);
131137

132138
const lipSyncEnabled = audioSourceId !== 'none' && audioStatus === 'active';
@@ -156,6 +162,8 @@ export function AvatarStage() {
156162
rotation: [...settings.avatarTransform.rotation],
157163
});
158164
setAudioSourceId(settings.audioSourceId);
165+
setLipSyncSensitivity(settings.lipSyncSensitivity);
166+
setLipSyncMouthStrength(settings.lipSyncMouthStrength);
159167
setWindowSourceId(settings.windowSourceId);
160168
setOverlayMode(settings.overlayMode);
161169
document.documentElement.classList.toggle('vox-desktop-windowed', !settings.overlayMode);
@@ -241,6 +249,8 @@ export function AvatarStage() {
241249
light,
242250
avatarTransform: avatar,
243251
audioSourceId,
252+
lipSyncSensitivity,
253+
lipSyncMouthStrength,
244254
windowSourceId,
245255
overlayMode,
246256
windowScale,
@@ -262,6 +272,8 @@ export function AvatarStage() {
262272
light,
263273
avatar,
264274
audioSourceId,
275+
lipSyncSensitivity,
276+
lipSyncMouthStrength,
265277
windowSourceId,
266278
overlayMode,
267279
windowScale,
@@ -1022,6 +1034,7 @@ export function AvatarStage() {
10221034
avatarRotation={avatar.rotation}
10231035
audioLevel={level}
10241036
lipSyncEnabled={lipSyncEnabled}
1037+
lipSyncMouthStrength={lipSyncMouthStrength}
10251038
speaking={speaking}
10261039
onLoaded={handleAvatarLoaded}
10271040
/>
@@ -1067,6 +1080,10 @@ export function AvatarStage() {
10671080
audioStatus={audioStatus}
10681081
audioError={audioError}
10691082
onRestartAudio={() => void restart()}
1083+
lipSyncSensitivity={lipSyncSensitivity}
1084+
onLipSyncSensitivityChange={setLipSyncSensitivity}
1085+
lipSyncMouthStrength={lipSyncMouthStrength}
1086+
onLipSyncMouthStrengthChange={setLipSyncMouthStrength}
10701087
/>
10711088
)}
10721089

‎avatar/src/components/avatar/VrmAvatar.jsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function VrmAvatar({
2727
avatarRotation,
2828
audioLevel = 0,
2929
lipSyncEnabled = false,
30+
lipSyncMouthStrength = 1,
3031
speaking = false,
3132
onLoaded,
3233
}) {
@@ -41,7 +42,7 @@ export function VrmAvatar({
4142
const overlayToken = motionOverlay?.token ?? 0;
4243

4344
const { play, playSequence, cancel, returnToRest, update: updateMixer } = useVrmAnimation(vrm);
44-
const updateLipSync = useAmplitudeLipSync(vrm);
45+
const updateLipSync = useAmplitudeLipSync(vrm, lipSyncMouthStrength);
4546
const updateBlink = useBlink(vrm);
4647

4748
useEffect(() => {

‎avatar/src/components/panels/VoicePanel.jsx‎

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {
55
labelAudioCaptureStatus,
66
} from '../../lib/audioCaptureCopy';
77
import { getDesktopApi } from '../../lib/desktopMode';
8-
import { PanelSelect } from '../ui/PanelPrimitives';
8+
import {
9+
defaultLipSyncMouthStrength,
10+
defaultLipSyncSensitivity,
11+
} from '../../config/lipSyncSensitivity';
12+
import { PanelSelect, SliderRow, Divider } from '../ui/PanelPrimitives';
913

1014
export function VoicePanel({
1115
audioSourceId,
@@ -16,6 +20,10 @@ export function VoicePanel({
1620
audioStatus,
1721
audioError,
1822
onRestartAudio,
23+
lipSyncSensitivity,
24+
onLipSyncSensitivityChange,
25+
lipSyncMouthStrength,
26+
onLipSyncMouthStrengthChange,
1927
}) {
2028
const [windowSources, setWindowSources] = useState([]);
2129
const audioSourceOptions = getAudioSourceOptions();
@@ -63,8 +71,48 @@ export function VoicePanel({
6371
{audioSourceOptions.find((option) => option.id === audioSourceId)?.description}
6472
</p>
6573

74+
{audioSourceId !== 'none' && (
75+
<>
76+
<Divider />
77+
78+
<SliderRow
79+
stacked
80+
id="lip-sync-sensitivity"
81+
label="Sensitivity"
82+
min={0.25}
83+
max={4}
84+
step={0.25}
85+
value={lipSyncSensitivity}
86+
onChange={onLipSyncSensitivityChange}
87+
onDoubleClick={() => onLipSyncSensitivityChange(defaultLipSyncSensitivity)}
88+
/>
89+
<p className="panel-hint">
90+
Raise this when audio is detected but the mouth barely moves. Double-click to reset.
91+
</p>
92+
93+
<Divider />
94+
95+
<SliderRow
96+
stacked
97+
id="lip-sync-mouth-limit"
98+
label="Mouth limit"
99+
min={0.1}
100+
max={1}
101+
step={0.05}
102+
value={lipSyncMouthStrength}
103+
onChange={onLipSyncMouthStrengthChange}
104+
onDoubleClick={() => onLipSyncMouthStrengthChange(defaultLipSyncMouthStrength)}
105+
/>
106+
<p className="panel-hint">
107+
Limits the maximum VRM mouth-expression weight. Double-click to reset.
108+
</p>
109+
</>
110+
)}
111+
66112
{audioSourceId === 'window' && (
67113
<>
114+
<Divider />
115+
68116
<label className="field-label" htmlFor="window-source-select">
69117
Window or screen
70118
</label>
@@ -79,15 +127,20 @@ export function VoicePanel({
79127
)}
80128

81129
{audioSourceId === 'file' && (
82-
<div className="file-picker-row">
83-
<input
84-
type="file"
85-
accept="audio/*"
86-
onChange={(event) => setAudioFile(event.target.files?.[0] ?? null)}
87-
/>
88-
</div>
130+
<>
131+
<Divider />
132+
<div className="file-picker-row">
133+
<input
134+
type="file"
135+
accept="audio/*"
136+
onChange={(event) => setAudioFile(event.target.files?.[0] ?? null)}
137+
/>
138+
</div>
139+
</>
89140
)}
90141

142+
<Divider />
143+
91144
<div className="voice-status-row">
92145
<span
93146
className={`voice-status voice-status--${audioStatus}`}

‎avatar/src/components/ui/PanelPrimitives.jsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,24 @@ export function Divider() {
1818
return <div className="panel-divider" />;
1919
}
2020

21-
export function SliderRow({ label, min, max, step, value, onChange, onDoubleClick }) {
22-
return (
23-
<div className="slider-row">
24-
<span className="slider-row__label">{label}</span>
21+
export function SliderRow({
22+
label,
23+
id,
24+
min,
25+
max,
26+
step,
27+
value,
28+
onChange,
29+
onDoubleClick,
30+
/** Stacked: field-label above, slider+value below (Voice / form fields). Default is inline (Camera). */
31+
stacked = false,
32+
}) {
33+
const inputId = id ?? undefined;
34+
const control = (
35+
<div className={`slider-row${stacked ? ' slider-row--control' : ''}`}>
36+
{!stacked && <span className="slider-row__label">{label}</span>}
2537
<input
38+
id={inputId}
2639
type="range"
2740
min={min}
2841
max={max}
@@ -31,10 +44,22 @@ export function SliderRow({ label, min, max, step, value, onChange, onDoubleClic
3144
onChange={(event) => onChange(parseFloat(event.target.value))}
3245
onDoubleClick={onDoubleClick}
3346
className="slider-row__input"
47+
aria-label={stacked ? undefined : label}
3448
/>
3549
<span className="slider-row__value">{typeof value === 'number' ? value.toFixed(2) : value}</span>
3650
</div>
3751
);
52+
53+
if (!stacked) return control;
54+
55+
return (
56+
<>
57+
<label className="field-label" htmlFor={inputId}>
58+
{label}
59+
</label>
60+
{control}
61+
</>
62+
);
3863
}
3964

4065
const SELECT_MENU_MAX_HEIGHT = 148;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const defaultLipSyncSensitivity = 1;
2+
export const minLipSyncSensitivity = 0.25;
3+
export const maxLipSyncSensitivity = 4;
4+
export const defaultLipSyncMouthStrength = 1;
5+
export const minLipSyncMouthStrength = 0.1;
6+
export const maxLipSyncMouthStrength = 1;
7+
8+
/** @param {unknown} value */
9+
export function normalizeLipSyncSensitivity(value) {
10+
if (value == null) return defaultLipSyncSensitivity;
11+
const numeric = Number(value);
12+
if (!Number.isFinite(numeric)) return defaultLipSyncSensitivity;
13+
return Math.min(maxLipSyncSensitivity, Math.max(minLipSyncSensitivity, numeric));
14+
}
15+
16+
/** @param {unknown} value */
17+
export function normalizeLipSyncMouthStrength(value) {
18+
if (value == null) return defaultLipSyncMouthStrength;
19+
const numeric = Number(value);
20+
if (!Number.isFinite(numeric)) return defaultLipSyncMouthStrength;
21+
return Math.min(maxLipSyncMouthStrength, Math.max(minLipSyncMouthStrength, numeric));
22+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
import {
4+
defaultLipSyncMouthStrength,
5+
defaultLipSyncSensitivity,
6+
normalizeLipSyncMouthStrength,
7+
normalizeLipSyncSensitivity,
8+
} from './lipSyncSensitivity.js';
9+
10+
test('normalizeLipSyncSensitivity keeps supported values', () => {
11+
assert.equal(normalizeLipSyncSensitivity(0.25), 0.25);
12+
assert.equal(normalizeLipSyncSensitivity(1), 1);
13+
assert.equal(normalizeLipSyncSensitivity('2.5'), 2.5);
14+
assert.equal(normalizeLipSyncSensitivity(4), 4);
15+
});
16+
17+
test('normalizeLipSyncMouthStrength clamps to the VRM expression range', () => {
18+
assert.equal(normalizeLipSyncMouthStrength(0), 0.1);
19+
assert.equal(normalizeLipSyncMouthStrength(0.65), 0.65);
20+
assert.equal(normalizeLipSyncMouthStrength(2), 1);
21+
assert.equal(normalizeLipSyncMouthStrength(null), defaultLipSyncMouthStrength);
22+
assert.equal(normalizeLipSyncMouthStrength(undefined), defaultLipSyncMouthStrength);
23+
});
24+
25+
test('normalizeLipSyncSensitivity clamps or defaults invalid values', () => {
26+
assert.equal(normalizeLipSyncSensitivity(0), 0.25);
27+
assert.equal(normalizeLipSyncSensitivity(10), 4);
28+
assert.equal(normalizeLipSyncSensitivity('nope'), defaultLipSyncSensitivity);
29+
assert.equal(normalizeLipSyncSensitivity(null), defaultLipSyncSensitivity);
30+
assert.equal(normalizeLipSyncSensitivity(undefined), defaultLipSyncSensitivity);
31+
});

‎avatar/src/config/userSettings.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { defaultAvatar, defaultCamera, defaultLight } from './defaults';
66
import { defaultColor, normalizeEnvironmentSelection } from './environmentSelection';
77
import { defaultWindowScale, normalizeWindowScale } from './windowScale';
88
import { createEmptyDeck, normalizeMotionDeck } from '../motion-deck/motionDeck';
9+
import {
10+
defaultLipSyncMouthStrength,
11+
defaultLipSyncSensitivity,
12+
normalizeLipSyncMouthStrength,
13+
normalizeLipSyncSensitivity,
14+
} from './lipSyncSensitivity';
915

1016
// 2: avatarTransform.rotation is the user's framing rotation only. Version 1
1117
// seeded it from a default that had the VRM 0.0 180° facing flip baked in, so
@@ -53,6 +59,8 @@ export function createDefaultUserSettings() {
5359
rotation: [...defaultAvatar.rotation],
5460
},
5561
audioSourceId: getDefaultAudioSourceId(),
62+
lipSyncSensitivity: defaultLipSyncSensitivity,
63+
lipSyncMouthStrength: defaultLipSyncMouthStrength,
5664
windowSourceId: null,
5765
overlayMode: true,
5866
windowScale: defaultWindowScale,
@@ -198,6 +206,8 @@ export function normalizeUserSettings(raw) {
198206
),
199207
},
200208
audioSourceId: asString(data.audioSourceId, defaults.audioSourceId),
209+
lipSyncSensitivity: normalizeLipSyncSensitivity(data.lipSyncSensitivity),
210+
lipSyncMouthStrength: normalizeLipSyncMouthStrength(data.lipSyncMouthStrength),
201211
windowSourceId: typeof data.windowSourceId === 'string' ? data.windowSourceId : null,
202212
overlayMode: data.overlayMode !== false,
203213
windowScale: normalizeWindowScale(asNumber(data.windowScale, defaults.windowScale)),
@@ -227,6 +237,8 @@ export function snapshotUserSettings(state) {
227237
light: state.light,
228238
avatarTransform: state.avatarTransform,
229239
audioSourceId: state.audioSourceId,
240+
lipSyncSensitivity: state.lipSyncSensitivity,
241+
lipSyncMouthStrength: state.lipSyncMouthStrength,
230242
windowSourceId: state.windowSourceId ?? null,
231243
overlayMode: state.overlayMode,
232244
windowScale: state.windowScale,

‎avatar/src/hooks/useAmplitudeLipSync.js‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const VISEMES = ['aa', 'ee', 'ih', 'oh', 'ou'];
88
*
99
* @param {import('@pixiv/three-vrm').VRM | null} vrm
1010
*/
11-
export function useAmplitudeLipSync(vrm) {
11+
export function useAmplitudeLipSync(vrm, mouthStrength = 1) {
1212
const smoothed = useRef(0);
1313
const phase = useRef(0);
1414

@@ -27,10 +27,13 @@ export function useAmplitudeLipSync(vrm) {
2727
for (let index = 0; index < VISEMES.length; index += 1) {
2828
const shape = Math.max(0, 1 - Math.abs(index - active) * 0.72);
2929
const flutter = 0.74 + Math.sin(phase.current * 5.7 + index) * 0.18;
30-
manager.setValue(VISEMES[index], Math.min(0.62, smoothed.current * shape * flutter));
30+
manager.setValue(
31+
VISEMES[index],
32+
Math.min(mouthStrength, smoothed.current * shape * flutter),
33+
);
3134
}
3235
},
33-
[vrm],
36+
[mouthStrength, vrm],
3437
);
3538
}
3639

0 commit comments

Comments
 (0)