Skip to content

Commit c80f0c4

Browse files
Address review feedback on reading responses aloud
- Bind the stored key to the endpoint it was given for, so that pointing the setting at another host stops reading aloud until a key for that host is entered, rather than sending this one to it. The setup action writes the endpoint before asking for the replacement key, which left a window where the two did not belong together. - Register the endpoint and voice settings statically. They were registered only once speech was already available, so on a platform without a speech synthesizer the setup action could not write the endpoint, and the engine could never become available at all. - Strip keycap sequences that omit the variation selector, which the Unicode grammar allows: `1\u20E3` was read out as "one". - Mention reading aloud in the chat accessibility help, including how to stop.
1 parent f287141 commit c80f0c4

5 files changed

Lines changed: 41 additions & 26 deletions

File tree

‎src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,22 @@ export function registerAccessibilityConfiguration() {
922922
tags: ['accessibility'],
923923
scope: ConfigurationScope.APPLICATION,
924924
},
925+
[AccessibilityVoiceSettingId.MaiSpeechEndpoint]: {
926+
'markdownDescription': localize('voice.maiSpeechEndpoint', "The endpoint of the speech service used to read text aloud, for example `https://eastus2.tts.speech.microsoft.com`. Run `Speech: Set Up Read Aloud` to configure it together with its key, which is stored securely rather than in your settings. Note that the text being read is sent to this service."),
927+
'type': 'string',
928+
'default': '',
929+
// Application scope so that a workspace cannot point reading aloud
930+
// at another server and have the key sent there.
931+
'scope': ConfigurationScope.APPLICATION,
932+
'tags': ['accessibility', 'usesOnlineServices']
933+
},
934+
[AccessibilityVoiceSettingId.MaiVoice]: {
935+
'markdownDescription': localize('voice.maiVoice', "The voice used to read text aloud, for example `en-US-Harper:MAI-Voice-2`. Leave empty to pick a voice for {0} automatically.", `\`#${AccessibilityVoiceSettingId.SpeechLanguage}#\``),
936+
'type': 'string',
937+
'default': '',
938+
'scope': ConfigurationScope.APPLICATION,
939+
'tags': ['accessibility']
940+
},
925941
[AccessibilityWorkbenchSettingId.HideAccessibleView]: {
926942
description: localize('accessibility.hideAccessibleView', "Controls whether the Accessible View is hidden."),
927943
type: 'boolean',
@@ -998,22 +1014,6 @@ export class DynamicSpeechAccessibilityConfiguration extends Disposable implemen
9981014
'enumDescriptions': languagesSorted.map(key => languages[key].name),
9991015
'enumItemLabels': languagesSorted.map(key => languages[key].name)
10001016
},
1001-
[AccessibilityVoiceSettingId.MaiSpeechEndpoint]: {
1002-
'markdownDescription': localize('voice.maiSpeechEndpoint', "The endpoint of the speech service used to read text aloud, for example `https://eastus2.tts.speech.microsoft.com`. Run `Speech: Set Up Read Aloud` to configure it together with its key, which is stored securely rather than in your settings. Note that the text being read is sent to this service."),
1003-
'type': 'string',
1004-
'default': '',
1005-
// Application scope so that a workspace cannot point reading aloud
1006-
// at another server and have the key sent there.
1007-
'scope': ConfigurationScope.APPLICATION,
1008-
'tags': ['accessibility', 'usesOnlineServices']
1009-
},
1010-
[AccessibilityVoiceSettingId.MaiVoice]: {
1011-
'markdownDescription': localize('voice.maiVoice', "The voice used to read text aloud, for example `en-US-Harper:MAI-Voice-2`. Leave empty to pick a voice for {0} automatically.", `\`#${AccessibilityVoiceSettingId.SpeechLanguage}#\``),
1012-
'type': 'string',
1013-
'default': '',
1014-
'scope': ConfigurationScope.APPLICATION,
1015-
'tags': ['accessibility']
1016-
},
10171017
[AccessibilityVoiceSettingId.AutoSynthesize]: {
10181018
'type': 'string',
10191019
'enum': ['on', 'off'],

‎src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui
176176
content.push(localize('chat.find', 'To search the chat transcript, invoke Find in Chat{0}. Find Next{1} and Find Previous{2} move between results, scrolling each one into view.', '<keybinding:workbench.action.chat.find>', '<keybinding:workbench.action.chat.findNext>', '<keybinding:workbench.action.chat.findPrevious>'));
177177
}
178178
content.push(localize('chat.attachments.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference."));
179+
content.push(localize('chat.readAloud', "A response can be read aloud with Read Aloud{0}, which is also the first button in the response toolbar. Reading stops with Stop Read Aloud{1}, or by starting to read another response.", '<keybinding:workbench.action.chat.readChatResponseAloud>', '<keybinding:workbench.action.chat.stopReadChatItemAloud>'));
179180
content.push(localize('chat.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring."));
180181
return content.join('\n');
181182
}

‎src/vs/workbench/contrib/speech/browser/maiSpeechCredentials.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,26 @@ export class MaiSpeechCredentialsService extends Disposable implements IMaiSpeec
107107

108108
private async refreshHasKey(): Promise<void> {
109109
const wasConfigured = this.isConfigured;
110-
this.hasKey = !!await this.readKey();
110+
this.hasKey = !!await this.resolve();
111111

112112
if (this.isConfigured !== wasConfigured) {
113113
this._onDidChangeConfigured.fire();
114114
}
115115
}
116116

117-
private async readKey(): Promise<string | undefined> {
117+
/**
118+
* The stored key together with the endpoint it was given for, or `undefined`
119+
* when nothing is stored.
120+
*/
121+
private async readStoredKey(): Promise<IMaiSpeechCredentials | undefined> {
118122
try {
119-
return await this.secretStorageService.get(MAI_SPEECH_KEY_SECRET) || undefined;
123+
const stored = await this.secretStorageService.get(MAI_SPEECH_KEY_SECRET);
124+
125+
return stored ? JSON.parse(stored) as IMaiSpeechCredentials : undefined;
120126
} catch (error) {
121-
// Secret storage is unavailable on some platforms; reading aloud then
122-
// falls back to the speech synthesizer of the platform.
127+
// Secret storage is unavailable on some platforms, and a value written
128+
// by an older version is not in this shape; reading aloud then falls
129+
// back to the speech synthesizer of the platform.
123130
this.logService.warn(`[speech] could not read the MAI speech key: ${error}`);
124131

125132
return undefined;
@@ -128,14 +135,18 @@ export class MaiSpeechCredentialsService extends Disposable implements IMaiSpeec
128135

129136
async resolve(): Promise<IMaiSpeechCredentials | undefined> {
130137
const endpoint = this.endpoint;
131-
const key = await this.readKey();
138+
const stored = await this.readStoredKey();
132139

133-
return endpoint && key ? { endpoint, key } : undefined;
140+
// A key is only ever sent to the endpoint it was given for. Pointing the
141+
// setting at another host therefore stops reading aloud until a key for
142+
// that host is entered, instead of handing this one to it.
143+
return endpoint && stored?.key && stored.endpoint === endpoint ? { endpoint, key: stored.key } : undefined;
134144
}
135145

136146
async setKey(key: string | undefined): Promise<void> {
137-
if (key?.trim()) {
138-
await this.secretStorageService.set(MAI_SPEECH_KEY_SECRET, key.trim());
147+
const endpoint = this.endpoint;
148+
if (key?.trim() && endpoint) {
149+
await this.secretStorageService.set(MAI_SPEECH_KEY_SECRET, JSON.stringify({ endpoint, key: key.trim() } satisfies IMaiSpeechCredentials));
139150
} else {
140151
await this.secretStorageService.delete(MAI_SPEECH_KEY_SECRET);
141152
}

‎src/vs/workbench/contrib/speech/common/speechText.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* `\uFE0F`.
1919
*/
2020
const TEXTUAL_SYMBOL = /[\u00A9\u00AE\u2122\u203C\u2049]/;
21-
const EMOJI = new RegExp(`(?!${TEXTUAL_SYMBOL.source}(?!\\uFE0F))(?:\\p{Extended_Pictographic}\\uFE0F?(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u20E3)?(?:\\u200D\\p{Extended_Pictographic}\\uFE0F?(?:[\\u{1F3FB}-\\u{1F3FF}])?)*|\\p{RI}\\p{RI}|[0-9#*]\\uFE0F\\u20E3)`, 'gu');
21+
const EMOJI = new RegExp(`(?!${TEXTUAL_SYMBOL.source}(?!\\uFE0F))(?:\\p{Extended_Pictographic}\\uFE0F?(?:[\\u{1F3FB}-\\u{1F3FF}]|\\u20E3)?(?:\\u200D\\p{Extended_Pictographic}\\uFE0F?(?:[\\u{1F3FB}-\\u{1F3FF}])?)*|\\p{RI}\\p{RI}|[0-9#*]\\uFE0F?\\u20E3)`, 'gu');
2222

2323
/**
2424
* Removes emoji from `text` so they are not read aloud. Emoji are decoration in

‎src/vs/workbench/contrib/speech/test/common/speechText.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,15 @@ suite('stripEmoji', () => {
3737
skinTone: stripEmoji('Nice 👍🏽 work'),
3838
flag: stripEmoji('Ships to 🇺🇸 only'),
3939
keycap: stripEmoji('Step 1️⃣ first'),
40+
// The variation selector is optional in the Unicode keycap grammar.
41+
keycapWithoutVariationSelector: stripEmoji('Step 1\u20E3 first'),
4042
variationSelector: stripEmoji('Warning ⚠️ ahead')
4143
}, {
4244
zwjSequence: 'Ask the team',
4345
skinTone: 'Nice work',
4446
flag: 'Ships to only',
4547
keycap: 'Step first',
48+
keycapWithoutVariationSelector: 'Step first',
4649
variationSelector: 'Warning ahead'
4750
});
4851
});

0 commit comments

Comments
 (0)