diff --git a/apps/stage-tamagotchi/src/renderer/components/settings/push-to-talk-shortcut.vue b/apps/stage-tamagotchi/src/renderer/components/settings/push-to-talk-shortcut.vue index 0d2c0ebb23..506e668820 100644 --- a/apps/stage-tamagotchi/src/renderer/components/settings/push-to-talk-shortcut.vue +++ b/apps/stage-tamagotchi/src/renderer/components/settings/push-to-talk-shortcut.vue @@ -24,7 +24,7 @@ const devices = useSettingsAudioDevice() const { accelerator, registrationError } = usePushToTalkShortcutSettings() const listShortcuts = useElectronEventaInvoke(electronShortcutList) const recording = shallowRef(false) -const active = computed(() => devices.enabled && hearing.inputMode === 'push-to-talk') +const active = computed(() => devices.listening && hearing.inputMode === 'push-to-talk') const shortcutLabel = computed(() => recording.value ? tt('actions.recording') diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-microphone.browser.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-microphone.browser.test.ts index 8dbde97cd0..6208eb399f 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-microphone.browser.test.ts +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-microphone.browser.test.ts @@ -80,7 +80,8 @@ afterEach(() => { }) describe('controls Island microphone', () => { - it('turns the microphone on after permission and off again without opening Hearing controls', async () => { + // The Island button must close the microphone for every feature, not only stop listening. + it('starts listening after permission, then turns the microphone off without opening Hearing controls', async () => { const { screen, device, askPermission } = mountControlsIsland() askPermission.mockResolvedValue(true) const microphone = screen.getByTestId('controls-island-microphone') @@ -91,16 +92,33 @@ describe('controls Island microphone', () => { await expect.element(microphone).toHaveAttribute('aria-pressed', 'true') await expect.element(microphone).toHaveAccessibleName('Turn off microphone') expect(device.enabled).toBe(true) + expect(device.off).toBe(false) expect(askPermission).toHaveBeenCalledOnce() expect(screen.getByTestId('hearing-config-dialog').query()).toBeNull() await microphone.click() await expect.element(microphone).toHaveAttribute('aria-pressed', 'false') - expect(device.enabled).toBe(false) + expect(device.off).toBe(true) + expect(device.enabled).toBe(true) expect(askPermission).toHaveBeenCalledOnce() }) + it('turns the microphone on again and listens without a second permission request', async () => { + localStorage.setItem('settings/audio/input/enabled', 'true') + localStorage.setItem('settings/audio/input/off', 'true') + const { screen, device, askPermission } = mountControlsIsland() + const microphone = screen.getByTestId('controls-island-microphone') + await expect.element(microphone).toHaveAttribute('aria-pressed', 'false') + + await microphone.click() + + await expect.element(microphone).toHaveAttribute('aria-pressed', 'true') + expect(device.off).toBe(false) + expect(device.listening).toBe(true) + expect(askPermission).not.toHaveBeenCalled() + }) + it('ignores presses while the permission prompt is pending', async () => { const { screen, device, askPermission } = mountControlsIsland() const permission = Promise.withResolvers() diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue index 59ca49cfc3..367e0ef450 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue @@ -73,7 +73,7 @@ const { dock, isLeft, isTop, motionPhase } = placement const settingsAudioDeviceStore = useSettingsAudioDevice() const settingsStore = useSettings() const context = useElectronEventaContext() -const { enabled } = storeToRefs(settingsAudioDeviceStore) +const { enabled, listening, off: microphoneOff } = storeToRefs(settingsAudioDeviceStore) const { alwaysOnTop, controlsIslandIconSize } = storeToRefs(settingsStore) const openSettings = useElectronEventaInvoke(electronOpenSettings) const isLinux = useElectronEventaInvoke(electron.app.isLinux) @@ -141,22 +141,30 @@ function setOverlay(key: string, active: boolean) { /** True while the microphone button waits for the permission prompt. The button is disabled, so a second press cannot start another request. */ const microphonePending = ref(false) -const microphoneLabel = computed(() => enabled.value +const microphoneLabel = computed(() => listening.value ? t('tamagotchi.stage.controls-island.disable-microphone') : t('tamagotchi.stage.controls-island.enable-microphone')) /** - * Turns the microphone preference on or off. The Hearing input mode still decides when the microphone captures. - * Only this press asks for permission. A refusal opens Hearing controls, which show the reason from the device store. + * Turns listening on, or turns the microphone off. The button is on while AIRI listens. + * + * Off is the microphone switch, so no feature can record until the microphone is on again. The listening preference + * stays, so the next press listens again without a permission request. + * On turns the microphone on. Only a first listening press asks for permission. A refusal opens Hearing controls, + * which show the reason from the device store. The Hearing input mode still decides when the microphone captures. */ async function toggleMicrophone() { if (microphonePending.value) return - if (enabled.value) { - enabled.value = false + if (listening.value) { + microphoneOff.value = true return } + microphoneOff.value = false + if (enabled.value) + return + microphonePending.value = true try { if (await settingsAudioDeviceStore.askPermission()) { @@ -642,13 +650,13 @@ function resetMainWindowPosition() { data-testid="controls-island-microphone" :button-style="adjustStyleClasses.button" :aria-label="microphoneLabel" - :aria-pressed="enabled" + :aria-pressed="listening" :aria-busy="microphonePending" :disabled="microphonePending" @click="toggleMicrophone" > - +
diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/indicator-mic-volume.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/indicator-mic-volume.vue index a36264133d..552baade5b 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/indicator-mic-volume.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/indicator-mic-volume.vue @@ -7,7 +7,7 @@ import { computed, onMounted, onUnmounted, watch } from 'vue' const props = withDefaults(defineProps<{ colorClass?: string }>(), { colorClass: 'text-primary-500 dark:text-primary-200' }) const settingsAudio = useSettingsAudioDevice() -const { stream, enabled } = storeToRefs(settingsAudio) +const { stream, listening } = storeToRefs(settingsAudio) const { audioContext } = storeToRefs(useAudioContext()) const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer() @@ -30,7 +30,7 @@ function teardown() { async function setup() { teardown() - if (!enabled.value || !stream.value) + if (!listening.value || !stream.value) return const ctx = audioContext.value @@ -45,7 +45,7 @@ async function setup() { } onMounted(() => { - watch([enabled, stream], () => setup(), { immediate: true }) + watch([listening, stream], () => setup(), { immediate: true }) }) onUnmounted(() => teardown()) diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index eb96a73d7c..5c6bc0f350 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -940,9 +940,13 @@ pages: description: Select the suitable speech recognition provider microphone: title: Microphone - label: Microphone - description: Turn on the microphone for Hearing. The input mode controls when AIRI listens. error: The microphone is not available + microphone-switch: + label: Use microphone + description: When this is off, no feature in AIRI can use the microphone. + listen: + label: Listen + description: AIRI listens in the input mode above. This needs the microphone. input-device: label: Input device description: Select the microphone that AIRI uses. diff --git a/packages/i18n/src/locales/en/stage.yaml b/packages/i18n/src/locales/en/stage.yaml index 2158699e0d..c571651f83 100644 --- a/packages/i18n/src/locales/en/stage.yaml +++ b/packages/i18n/src/locales/en/stage.yaml @@ -60,6 +60,10 @@ chat: push-to-talk-unavailable: This device has no Push to Talk key, so AIRI does not listen. Change the input mode in Hearing settings. wake-word: Listen for wake words hearing-settings: More Hearing settings… + use-microphone: Use microphone + microphone-off: The microphone is off + microphone-off-description: Turn on the microphone to record. + turn-on-microphone: Turn on options-hint: Hover or right-click for options recording: Recording stop: Stop recording diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 56b53d81d3..5ef2a67498 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -891,9 +891,13 @@ pages: description: 选择合适的语音转文本的服务来源 microphone: title: 麦克风 - label: 麦克风 - description: 为听觉打开麦克风。输入模式决定 AIRI 何时聆听。 error: 麦克风不可用 + microphone-switch: + label: 启用麦克风 + description: 关闭后,AIRI 的所有功能都不能使用麦克风。 + listen: + label: 聆听 + description: AIRI 按上面的输入模式聆听。需要启用麦克风。 input-device: label: 输入设备 description: 选择 AIRI 使用的麦克风。 diff --git a/packages/i18n/src/locales/zh-Hans/stage.yaml b/packages/i18n/src/locales/zh-Hans/stage.yaml index 0153c680d3..ce2adf2e93 100644 --- a/packages/i18n/src/locales/zh-Hans/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/stage.yaml @@ -60,6 +60,10 @@ chat: push-to-talk-unavailable: 当前设备没有「按住说话」按键,所以 AIRI 不会聆听。请在听觉设置中更改输入模式。 wake-word: 监听唤醒词 hearing-settings: 更多听觉设置… + use-microphone: 启用麦克风 + microphone-off: 麦克风已关闭 + microphone-off-description: 打开麦克风后才能录音。 + turn-on-microphone: 打开 options-hint: 悬停或右键查看选项 recording: 正在录音 stop: 停止录音 diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index 68758298f1..eadf5d0d54 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -166,12 +166,7 @@ const viewControlsEnabled = computed(() => { return false }) const settingsAudioDevice = useSettingsAudioDevice() -const { enabled, stream } = storeToRefs(settingsAudioDevice) - -watch(hearingOpen, async (open) => { - if (open) - await settingsAudioDevice.askPermission() -}) +const { listening, stream } = storeToRefs(settingsAudioDevice) function restoreComposerFocus(event: Event) { event.preventDefault() @@ -282,7 +277,7 @@ function teardownAnalyzer() { async function setupAnalyzer() { teardownAnalyzer() - if (!enabled.value || !stream.value) + if (!listening.value || !stream.value) return if (audioContext.state === 'suspended') await audioContext.resume() @@ -293,7 +288,7 @@ async function setupAnalyzer() { analyzerSource.connect(analyser) } -watch([enabled, stream], () => { +watch([listening, stream], () => { setupAnalyzer() }, { immediate: true }) @@ -449,7 +444,7 @@ onUnmounted(() => { >