Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build/lib/stylelint/vscode-known-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"--vscode-chat-avatarBackground",
"--vscode-chat-avatarForeground",
"--vscode-chat-checkpointSeparator",
"--vscode-chat-dictationActiveMicGlow",
"--vscode-chat-editedFileForeground",
"--vscode-chat-inputWorkingBorderColor1",
"--vscode-chat-inputWorkingBorderColor2",
Expand Down Expand Up @@ -970,6 +971,8 @@
"--chat-editing-last-edit-shift",
"--chat-voice-icon-glow-color",
"--sessions-voice-icon-glow-color",
"--dictation-mic-glow-radius",
"--dictation-mic-level",
"--chat-current-response-min-height",
"--chat-smooth-delay",
"--chat-smooth-duration",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,46 @@

import './media/dictationMicGlow.css';
import { getWindow } from '../../../../../base/browser/dom.js';
import { Event } from '../../../../../base/common/event.js';
import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
import { readVoiceGlowIntensity } from '../voiceClient/voiceGlow.js';
import { ChatSpeechToTextState, IChatSpeechToTextService } from './chatSpeechToTextService.js';

const SPEAKING_THRESHOLD = 0.08;
const GLOW_LEVEL_CLASSES = [
'dictation-mic-glow-level-1',
'dictation-mic-glow-level-2',
'dictation-mic-glow-level-3',
'dictation-mic-glow-level-4',
] as const;
export type DictationMicGlowPhase = 'off' | 'live' | 'settling';

/** `off` while preparing too, so the glow doesn't compete with the download ring. */
export function getDictationMicGlowPhase(state: ChatSpeechToTextState, isPreparingModel: boolean): DictationMicGlowPhase {
if (isPreparingModel || state === ChatSpeechToTextState.Idle) {
return 'off';
}
return state === ChatSpeechToTextState.Recording ? 'live' : 'settling';
}

/**
* Adds audio-reactive feedback to a dictation microphone while recording.
* Asymmetric, so the glow swells into speech but drifts back out of it. Tracking
* the level symmetrically reads as a level meter rather than as ambient light.
*/
export function easeDictationMicLevel(current: number, target: number): number {
return current + (target - current) * (target > current ? 0.1 : 0.035);
}

/** Lifts quiet speech and clamps loud speech, so the glow breathes rather than flashes. */
export function shapeDictationMicLevel(level: number): number {
return Math.min(1, Math.pow(Math.min(1, Math.max(0, level)), 0.7) * 1.15);
}

/** Held while settling, and whenever no analyser is available. */
const RESTING_LEVEL = 0.12;

/** Held with reduced motion, so the glow is present but static. */
const REDUCED_MOTION_LEVEL = 0.45;

/**
* Adds audio-reactive feedback to a dictation microphone while recording, so an
* open mic is obvious at a glance rather than being conveyed only by the filled
* mic glyph. The glow is drawn as a pseudo-element on `target`, so hosts that
* rebuild their button contents keep it.
*/
export function setupDictationMicGlow(
target: HTMLElement,
Expand All @@ -30,51 +55,51 @@ export function setupDictationMicGlow(
const window = getWindow(target);
const dataArray = { value: undefined as Uint8Array | undefined };
let animationFrame: number | undefined;
let level = 0;

const setGlowLevel = (level: number) => {
for (let index = 0; index < GLOW_LEVEL_CLASSES.length; index++) {
target.classList.toggle(GLOW_LEVEL_CLASSES[index], index === level - 1);
}
target.classList.toggle('dictation-mic-speaking', level > 0);
const setLevel = (value: number) => {
level = value;
target.style.setProperty('--dictation-mic-level', value.toFixed(3));
};

const stopAnimation = () => {
if (animationFrame !== undefined) {
window.cancelAnimationFrame(animationFrame);
animationFrame = undefined;
}
setGlowLevel(0);
};

const animate = () => {
animationFrame = window.requestAnimationFrame(animate);
const intensity = readVoiceGlowIntensity(service.analyserNode ?? null, dataArray);
const speakingIntensity = Math.max(0, Math.min(1, (intensity - SPEAKING_THRESHOLD) / (1 - SPEAKING_THRESHOLD)));
setGlowLevel(speakingIntensity === 0 ? 0 : Math.min(GLOW_LEVEL_CLASSES.length, Math.ceil(speakingIntensity * GLOW_LEVEL_CLASSES.length)));
// Reuses Voice Mode's reduction, so both features agree on what "level" means.
const measured = service.state === ChatSpeechToTextState.Recording && service.analyserNode
? readVoiceGlowIntensity(service.analyserNode, dataArray)
: RESTING_LEVEL;
setLevel(easeDictationMicLevel(level, shapeDictationMicLevel(measured)));
};

const update = () => {
const active = service.state === ChatSpeechToTextState.Recording;
target.classList.toggle('dictation-mic-active', active);
if (!active) {
stopAnimation();
return;
}
if (accessibilityService.isMotionReduced()) {
const phase = getDictationMicGlowPhase(service.state, service.isPreparingModel);
target.classList.toggle('dictation-mic-active', phase !== 'off');
target.classList.toggle('dictation-mic-settling', phase === 'settling');

// With reduced motion the glow still shows, just held at a steady level.
if (phase === 'off' || accessibilityService.isMotionReduced()) {
stopAnimation();
setGlowLevel(2);
setLevel(phase === 'off' ? 0 : REDUCED_MOTION_LEVEL);
return;
}
if (animationFrame === undefined) {
animationFrame = window.requestAnimationFrame(animate);
}
};

store.add(service.onDidChangeState(update));
store.add(Event.any<unknown>(service.onDidChangeState, service.onDidChangePreparingModel)(update));
store.add(accessibilityService.onDidChangeReducedMotion(update));
store.add(toDisposable(() => {
stopAnimation();
target.classList.remove('dictation-mic-active');
target.classList.remove('dictation-mic-active', 'dictation-mic-settling');
target.style.removeProperty('--dictation-mic-level');
}));
update();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,74 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/* Audio-reactive glow shown inside the dictation mic button while listening (see
dictationMicGlow.ts). Decorative and never interactive. Motion is gated on
`monaco-enable-motion`; with reduced motion the glow still shows, the
animation loop simply never starts. */
.dictation-mic-active {
position: relative;
--dictation-mic-level: 0;
}

.dictation-mic-speaking::after {
/* Keep the mic glyph above the glow so the icon stays crisp. */
.dictation-mic-active > * {
position: relative;
z-index: 1;
}

/* Light gathered at the edge over a faint inner bloom, with the middle left
clear so the glyph is never clouded. Opacity stays in a narrow band so a loud
syllable swells the glow rather than slamming it to full. */
.dictation-mic-active::after {
content: '';
position: absolute;
inset: var(--vscode-spacing-size20);
border-radius: var(--vscode-cornerRadius-circle);
box-shadow: 0 0 var(--vscode-spacing-size80) var(--vscode-focusBorder);
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: var(--dictation-mic-glow-radius, var(--vscode-cornerRadius-small));
background:
radial-gradient(circle at 50% 56%,
color-mix(in srgb, var(--vscode-chat-dictationActiveMicGlow) 22%, transparent) 0%,
color-mix(in srgb, var(--vscode-chat-dictationActiveMicGlow) 8%, transparent) 50%,
transparent 84%),
radial-gradient(circle at 50% 50%,
transparent 34%,
color-mix(in srgb, var(--vscode-chat-dictationActiveMicGlow) 10%, transparent) 68%,
color-mix(in srgb, var(--vscode-chat-dictationActiveMicGlow) 34%, transparent) 100%);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--vscode-chat-dictationActiveMicGlow) 28%, transparent),
inset 0 0 calc(4px + var(--dictation-mic-level) * 6px) color-mix(in srgb, var(--vscode-chat-dictationActiveMicGlow) 40%, transparent);
opacity: calc(0.34 + var(--dictation-mic-level) * 0.42);
transition: opacity 260ms ease-out, box-shadow 320ms ease-out;
will-change: opacity, box-shadow;
}

.dictation-mic-glow-level-1::after {
opacity: 0.3;
/* Recording has stopped and the final transcript is landing. */
.dictation-mic-active.dictation-mic-settling::after {
opacity: 0.2;
}

.dictation-mic-glow-level-2::after {
opacity: 0.5;
/* Keeps the button from ever being completely still during a pause in speech. */
.monaco-workbench.monaco-enable-motion .dictation-mic-active:not(.dictation-mic-settling)::after {
animation: dictation-mic-breathe 5.5s ease-in-out infinite;
}

.dictation-mic-glow-level-3::after {
opacity: 0.7;
@keyframes dictation-mic-breathe {
0%, 100% { filter: brightness(0.9); }
50% { filter: brightness(1.14); }
}

.dictation-mic-glow-level-4::after {
opacity: 0.9;
/* The segmented voice-input pill's cell is rounded more than a toolbar button. */
.chat-voice-input-mode-cell.dictation {
--dictation-mic-glow-radius: 7px;
}

.hc-black .dictation-mic-speaking::after,
.hc-light .dictation-mic-speaking::after {
/* The high-contrast fallback replaces the soft glow with a solid outline, so
force full opacity: the glow-level opacities (0.3 to 0.9) would otherwise make
the contrast border faint, especially at the lower levels. */
/* High-contrast themes replace the soft glow with a solid outline, which must
stay fully opaque to keep its contrast at low levels. */
.hc-black .dictation-mic-active::after,
.hc-light .dictation-mic-active::after {
opacity: 1;
background: none;
box-shadow: none;
outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder, var(--vscode-focusBorder));
}
8 changes: 8 additions & 0 deletions src/vs/workbench/contrib/chat/common/widget/chatColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,11 @@ export const chatInputWorkingBorderColor3 = registerColor(
'chat.inputWorkingBorderColor3',
{ dark: lighten(buttonBackground, 0.5), light: lighten(buttonBackground, 0.3), hcDark: '#000000', hcLight: '#000000' },
localize('chat.inputWorkingBorderColor3', 'Tertiary accent color used by other animated chat input affordances. Not used by the in-flight chat input border.'), true);

// Matches Voice Mode's listening glow (see `voiceGlow.ts`). Deliberately not
// `charts.blue`, which resolves to `editorInfoForeground` — an indigo that reads
// as purple once softened into a wash.
export const chatDictationActiveMicGlow = registerColor(
'chat.dictationActiveMicGlow',
{ dark: '#58A6FF', light: '#2E8BE6', hcDark: '#8CC6FF', hcLight: '#00539C' },
localize('chat.dictationActiveMicGlow', 'Accent color of the glow shown on the microphone while dictation is listening.'));
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { ChatSpeechToTextState } from '../../browser/speechToText/chatSpeechToTextService.js';
import { easeDictationMicLevel, getDictationMicGlowPhase, shapeDictationMicLevel } from '../../browser/speechToText/dictationMicGlow.js';

suite('DictationMicGlow', () => {
ensureNoDisposablesAreLeakedInTestSuite();

test('recording glows, transcribing winds down, and preparing suppresses the glow', () => {
const states = [ChatSpeechToTextState.Idle, ChatSpeechToTextState.Recording, ChatSpeechToTextState.Transcribing];
assert.deepStrictEqual(
{
ready: states.map(state => getDictationMicGlowPhase(state, false)),
preparing: states.map(state => getDictationMicGlowPhase(state, true)),
},
{
ready: ['off', 'live', 'settling'],
preparing: ['off', 'off', 'off'],
}
);
});

test('the level swells faster than it drifts back, so speech reads as ambient', () => {
const rose = easeDictationMicLevel(0, 1);
const fell = 1 - easeDictationMicLevel(1, 0);
assert.deepStrictEqual(
{ rose: Number(rose.toFixed(3)), fell: Number(fell.toFixed(3)), releaseIsSlower: fell < rose },
{ rose: 0.1, fell: 0.035, releaseIsSlower: true }
);
});

test('the level is lifted at the quiet end and clamped at both ends', () => {
assert.deepStrictEqual(
[0, 0.25, 1, 1.5, -1].map(l => Number(shapeDictationMicLevel(l).toFixed(3))),
[0, 0.436, 1, 1, 0]
);
});
});
Loading