Skip to content

Commit a0b39a0

Browse files
committed
feat(stage-tamagotchi,stage-web): sync transcription implementation, improve config drawer
1 parent ddf9627 commit a0b39a0

File tree

8 files changed

+258
-188
lines changed

8 files changed

+258
-188
lines changed

apps/stage-tamagotchi/src/renderer/components/HearingPermissionStatus.vue

Lines changed: 0 additions & 78 deletions
This file was deleted.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script setup lang="ts">
2+
import { HearingConfigDialog } from '@proj-airi/stage-ui/components'
3+
import { useAudioAnalyzer, useAudioContextFromStream } from '@proj-airi/stage-ui/composables'
4+
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
5+
import { useAsyncState } from '@vueuse/core'
6+
import { storeToRefs } from 'pinia'
7+
import { onMounted, onUnmounted, watch } from 'vue'
8+
9+
import { electron } from '../../../../shared/electron'
10+
import { useElectronEventaInvoke } from '../../../composables/electron-vueuse/use-electron-eventa-context'
11+
12+
const show = defineModel('show', { type: Boolean, default: false })
13+
14+
const settingsAudioDeviceStore = useSettingsAudioDevice()
15+
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDeviceStore)
16+
const { startStream, stopStream } = settingsAudioDeviceStore
17+
18+
const getMediaAccessStatus = useElectronEventaInvoke(electron.systemPreferences.getMediaAccessStatus)
19+
const { state: mediaAccessStatus, execute: refreshMediaAccessStatus } = useAsyncState(() => getMediaAccessStatus(['microphone']), 'not-determined')
20+
21+
const { audioContext, initialize, dispose, pause } = useAudioContextFromStream(stream)
22+
const { volumeLevel, startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
23+
24+
watch(enabled, (val) => {
25+
if (val) {
26+
startStream()
27+
initialize().then(() => startAnalyzer(audioContext.value!))
28+
}
29+
else {
30+
stopStream()
31+
pause()
32+
}
33+
}, { immediate: true })
34+
35+
onMounted(async () => {
36+
await refreshMediaAccessStatus()
37+
if (audioContext.value) {
38+
await startAnalyzer(audioContext.value)
39+
}
40+
})
41+
42+
onUnmounted(async () => {
43+
await stopAnalyzer()
44+
await dispose()
45+
})
46+
</script>
47+
48+
<template>
49+
<HearingConfigDialog
50+
v-model:show="show"
51+
v-model:enabled="enabled"
52+
v-model:selected-audio-input="selectedAudioInput"
53+
:granted="mediaAccessStatus !== 'denied' && mediaAccessStatus !== 'restricted'"
54+
:audio-input-options="audioInputs"
55+
:volume-level="volumeLevel"
56+
>
57+
<slot />
58+
<template #extra>
59+
<slot name="extra" />
60+
</template>
61+
</HearingConfigDialog>
62+
</template>

apps/stage-tamagotchi/src/renderer/components/Widgets/ControlsIsland/index.vue

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
<script setup lang="ts">
2-
import { HearingConfigDialog } from '@proj-airi/stage-ui/components'
32
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
43
import { defineInvoke } from '@unbird/eventa'
54
import { useDark, useToggle } from '@vueuse/core'
65
import { storeToRefs } from 'pinia'
76
import { ref } from 'vue'
87
9-
import HearingPermissionStatus from '../../../components/HearingPermissionStatus.vue'
108
import ControlButton from './ControlButton.vue'
119
import ControlButtonTooltip from './ControlButtonTooltip.vue'
10+
import ControlsIslandHearingConfig from './ControlsIslandHearingConfig.vue'
1211
import IndicatorMicVolume from './IndicatorMicVolume.vue'
1312
1413
import { electronOpenChat, electronOpenSettings, electronStartDraggingWindow } from '../../../../shared/eventa'
@@ -61,7 +60,7 @@ defineExpose({ hearingDialogOpen })
6160
</ControlButtonTooltip>
6261

6362
<ControlButtonTooltip>
64-
<HearingConfigDialog v-model:show="hearingDialogOpen">
63+
<ControlsIslandHearingConfig v-model:show="hearingDialogOpen">
6564
<div class="relative">
6665
<ControlButton>
6766
<Transition name="fade" mode="out-in">
@@ -70,10 +69,7 @@ defineExpose({ hearingDialogOpen })
7069
</Transition>
7170
</ControlButton>
7271
</div>
73-
<template #extra>
74-
<HearingPermissionStatus />
75-
</template>
76-
</HearingConfigDialog>
72+
</ControlsIslandHearingConfig>
7773

7874
<template #tooltip>
7975
Open hearing controls

apps/stage-web/src/components/Layouts/InteractiveArea.vue

Lines changed: 45 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
<script setup lang="ts">
22
import type { ChatProvider } from '@xsai-ext/shared-providers'
33
4-
import WhisperWorker from '@proj-airi/stage-ui/libs/workers/worker?worker&url'
5-
6-
import { toWAVBase64 } from '@proj-airi/audio'
74
import { HearingConfigDialog } from '@proj-airi/stage-ui/components'
8-
import { useMicVAD, useWhisper } from '@proj-airi/stage-ui/composables'
5+
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
96
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
107
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
118
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
@@ -21,7 +18,6 @@ import ChatHistory from '../Widgets/ChatHistory.vue'
2118
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
2219
2320
const messageInput = ref('')
24-
const listening = ref(false)
2521
const hearingDialogOpen = ref(false)
2622
const isComposing = ref(false)
2723
@@ -30,29 +26,15 @@ const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
3026
const { themeColorsHueDynamic } = storeToRefs(useSettings())
3127
3228
const { askPermission } = useSettingsAudioDevice()
33-
const { enabled, selectedAudioInput } = storeToRefs(useSettingsAudioDevice())
29+
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
3430
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = useChatStore()
3531
const { messages } = storeToRefs(useChatStore())
3632
const { audioContext } = useAudioContext()
3733
const { t } = useI18n()
3834
3935
const isDark = useDark({ disableTransition: false })
4036
41-
const { transcribe: generate, terminate } = useWhisper(WhisperWorker, {
42-
onComplete: async (res) => {
43-
if (!res || !res.trim()) {
44-
return
45-
}
46-
47-
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
48-
49-
await send(res, {
50-
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
51-
model: activeModel.value,
52-
providerConfig,
53-
})
54-
},
55-
})
37+
// Legacy whisper pipeline removed; audio pipeline handled at page level
5638
5739
async function handleSend() {
5840
if (!messageInput.value.trim() || isComposing.value) {
@@ -77,46 +59,7 @@ async function handleSend() {
7759
}
7860
}
7961
80-
const { destroy, start } = useMicVAD(selectedAudioInput, {
81-
onSpeechStart: () => {
82-
// TODO: interrupt the playback
83-
// TODO: interrupt any of the ongoing TTS
84-
// TODO: interrupt any of the ongoing LLM requests
85-
// TODO: interrupt any of the ongoing animation of Live2D or VRM
86-
// TODO: once interrupted, we should somehow switch to listen or thinking
87-
// emotion / expression?
88-
listening.value = true
89-
},
90-
// VAD misfire means while speech end is detected but
91-
// the frames of the segment of the audio buffer
92-
// is not enough to be considered as a speech segment
93-
// which controlled by the `minSpeechFrames` parameter
94-
onVADMisfire: () => {
95-
// TODO: do audio buffer send to whisper
96-
listening.value = false
97-
},
98-
onSpeechEnd: (buffer) => {
99-
// TODO: do audio buffer send to whisper
100-
listening.value = false
101-
handleTranscription(buffer.buffer)
102-
},
103-
auto: false,
104-
})
105-
106-
async function handleTranscription(buffer: ArrayBufferLike) {
107-
await audioContext.resume()
108-
109-
// Convert Float32Array to WAV format
110-
const audioBase64 = await toWAVBase64(buffer, audioContext.sampleRate)
111-
generate({ type: 'generate', data: { audio: audioBase64, language: 'en' } })
112-
}
113-
114-
watch(enabled, async (value) => {
115-
if (value === false) {
116-
destroy()
117-
terminate()
118-
}
119-
})
62+
// No inline VAD/whisper here; see pages/index.vue pipeline
12063
12164
watch(hearingDialogOpen, async (value) => {
12265
if (value) {
@@ -130,14 +73,38 @@ watch([activeProvider, activeModel], async () => {
13073
}
13174
})
13275
133-
onMounted(() => {
134-
// loadWhisper()
135-
start()
136-
})
76+
onMounted(() => {})
13777
13878
onAfterMessageComposed(async () => {
13979
messageInput.value = ''
14080
})
81+
82+
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
83+
let analyzerSource: MediaStreamAudioSourceNode | undefined
84+
85+
function teardownAnalyzer() {
86+
try { analyzerSource?.disconnect() }
87+
catch {}
88+
analyzerSource = undefined
89+
stopAnalyzer()
90+
}
91+
92+
async function setupAnalyzer() {
93+
teardownAnalyzer()
94+
if (!hearingDialogOpen.value || !enabled.value || !stream.value)
95+
return
96+
if (audioContext.state === 'suspended')
97+
await audioContext.resume()
98+
const analyser = startAnalyzer(audioContext)
99+
if (!analyser)
100+
return
101+
analyzerSource = audioContext.createMediaStreamSource(stream.value)
102+
analyzerSource.connect(analyser)
103+
}
104+
105+
watch([hearingDialogOpen, enabled, stream], () => {
106+
setupAnalyzer()
107+
}, { immediate: true })
141108
</script>
142109

143110
<template>
@@ -167,12 +134,23 @@ onAfterMessageComposed(async () => {
167134
@compositionend="isComposing = false"
168135
/>
169136

170-
<HearingConfigDialog v-model:show="hearingDialogOpen" :overlay-dim="true" :overlay-blur="true">
137+
<HearingConfigDialog
138+
v-model:show="hearingDialogOpen"
139+
:overlay-dim="true"
140+
:overlay-blur="true"
141+
:enabled="enabled"
142+
:audio-input-options="audioInputs"
143+
:selected-audio-input="selectedAudioInput"
144+
:has-devices="(audioInputs || []).length > 0"
145+
:volume-level="volumeLevel"
146+
@toggle-enabled="enabled = !enabled"
147+
@update:selected-audio-input="val => selectedAudioInput = val as string"
148+
>
171149
<button
172150
class="max-h-[10lh] min-h-[1lh]"
173151
bg="neutral-100 dark:neutral-800"
174152
text="lg neutral-500 dark:neutral-400"
175-
:class="{ 'ring-2 ring-primary-400/60 ring-offset-2 dark:ring-offset-neutral-900': listening }"
153+
:class="{ 'ring-2 ring-primary-400/60 ring-offset-2 dark:ring-offset-neutral-900': enabled }"
176154
flex items-center justify-center rounded-md p-2 outline-none
177155
transition="colors duration-200, transform duration-100" active:scale-95
178156
:title="t('settings.hearing.title')"

packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config-dialog.vue

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@ import HearingConfig from './hearing-config.vue'
99
const props = defineProps<{
1010
overlayDim?: boolean
1111
overlayBlur?: boolean
12+
granted?: boolean
13+
audioInputOptions?: MediaDeviceInfo[]
14+
volumeLevel?: number
1215
}>()
16+
1317
const showDialog = defineModel('show', { type: Boolean, default: false, required: false })
18+
const selectedAudioInput = defineModel<string>('selectedAudioInput')
19+
const enabled = defineModel<boolean>('enabled', { default: false })
1420
1521
const isDesktop = useMediaQuery('(min-width: 768px)')
1622
const screenSafeArea = useScreenSafeArea()
@@ -36,7 +42,13 @@ onMounted(() => screenSafeArea.update())
3642
<VisuallyHidden>
3743
<DialogTitle>Hearing Input</DialogTitle>
3844
</VisuallyHidden>
39-
<HearingConfig @close="showDialog = false" />
45+
<HearingConfig
46+
v-model:enabled="enabled"
47+
v-model:selected-audio-input="selectedAudioInput"
48+
:audio-input-options="props.audioInputOptions"
49+
:granted="props.granted"
50+
:volume-level="props.volumeLevel"
51+
/>
4052
<slot name="extra" />
4153
</DialogContent>
4254
</DialogPortal>
@@ -47,9 +59,15 @@ onMounted(() => screenSafeArea.update())
4759
</DrawerTrigger>
4860
<DrawerPortal>
4961
<DrawerOverlay class="fixed inset-0" />
50-
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[50%] flex flex-col rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none backdrop-blur-md dark:bg-neutral-900/95" :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }">
51-
<DrawerHandle />
52-
<HearingConfig @close="showDialog = false" />
62+
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[45%] flex flex-col rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none backdrop-blur-md dark:bg-neutral-900/95" :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }">
63+
<DrawerHandle my-2 />
64+
<HearingConfig
65+
v-model:enabled="enabled"
66+
v-model:selected-audio-input="selectedAudioInput"
67+
:audio-input-options="props.audioInputOptions"
68+
:granted="props.granted"
69+
:volume-level="props.volumeLevel"
70+
/>
5371
<slot name="extra" />
5472
</DrawerContent>
5573
</DrawerPortal>

0 commit comments

Comments
 (0)