feat(stage-tamagotchi,stage-pages,stage-shared,stage-ui): complete beat-sync integration

This commit is contained in:
Makito
2025-12-01 05:14:42 +09:00
parent 4d3c97cdf6
commit bbb437a77e
17 changed files with 456 additions and 240 deletions
+1
View File
@@ -134,6 +134,7 @@
"@iconify-json/vscode-icons": "^1.2.34",
"@iconify/utils": "^3.0.2",
"@intlify/unplugin-vue-i18n": "^11.0.1",
"@nekopaw/tempora": "0.3.1-alpha.1",
"@proj-airi/lobe-icons": "^1.0.14",
"@proj-airi/stage-shared": "workspace:^",
"@proj-airi/ui-transitions": "workspace:^",
@@ -3,9 +3,22 @@ import { fileURLToPath } from 'node:url'
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { beatSyncRequestSignalBeat, beatSyncSignalBeat } from '@proj-airi/stage-shared/beat-sync/eventa'
import {
beatSyncBeatSignaledInvokeEventa,
beatSyncGetStateInvokeEventa,
beatSyncStateChangedInvokeEventa,
beatSyncToggleInvokeEventa,
beatSyncUpdateParametersInvokeEventa,
} from '@proj-airi/stage-shared/beat-sync'
import { BrowserWindow, ipcMain } from 'electron'
import {
beatSyncElectronChangeState,
beatSyncElectronGetState,
beatSyncElectronSignalBeat,
beatSyncElectronToggle,
beatSyncElectronUpdateParameters,
} from '../../../shared/eventa'
import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location'
export async function setupBeatSync() {
@@ -16,16 +29,35 @@ export async function setupBeatSync() {
sandbox: false,
},
})
const context = createContext(ipcMain, window).context
// TODO(Makito): Revisit and improve
// [main] -> [renderer] beat-sync
const toggle = defineInvoke(context, beatSyncElectronToggle) as (enabled: boolean) => Promise<void> // TODO: Better type
const getState = defineInvoke(context, beatSyncElectronGetState)
const updateParameters = defineInvoke(context, beatSyncElectronUpdateParameters)
await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'), 'beat-sync.html'))
return {
window,
dispatchTo: (window: BrowserWindow) => {
const context = createContext(ipcMain, window).context
const signalBeat = defineInvoke(context, beatSyncSignalBeat)
const removeHandler = defineInvokeHandler(context, beatSyncRequestSignalBeat, async e => signalBeat(e))
window.on('closed', () => removeHandler())
return removeHandler
const stateChanged = defineInvoke(context, beatSyncStateChangedInvokeEventa)
const beatSignaled = defineInvoke(context, beatSyncBeatSignaledInvokeEventa)
const removeHandlerFns = [
// [renderer] beat-sync -> [main] -> [renderer] index (Events)
defineInvokeHandler(context, beatSyncElectronChangeState, async e => stateChanged(e)),
defineInvokeHandler(context, beatSyncElectronSignalBeat, async e => beatSignaled(e)),
// [renderer] index -> [main] -> [renderer] beat-sync (Functions)
defineInvokeHandler(context, beatSyncToggleInvokeEventa, async enabled => toggle(enabled)),
defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => getState()),
defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async params => updateParameters(params)),
]
const removeHandlers = () => removeHandlerFns.forEach(fn => fn())
window.on('closed', () => removeHandlers())
return removeHandlers
},
}
}
@@ -1,12 +1,23 @@
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { createBeatSyncDetector, StageEnvironment } from '@proj-airi/stage-shared'
import { beatSyncRequestSignalBeat, beatSyncToggle } from '@proj-airi/stage-shared/beat-sync/eventa'
import { StageEnvironment } from '@proj-airi/stage-shared'
import { createBeatSyncDetector } from '@proj-airi/stage-shared/beat-sync/browser'
import {
beatSyncElectronChangeState,
beatSyncElectronGetState,
beatSyncElectronSignalBeat,
beatSyncElectronToggle,
beatSyncElectronUpdateParameters,
} from '../shared/eventa'
const { ipcRenderer } = window.electron
const context = createContext(ipcRenderer).context
const requestSignalBeat = defineInvoke(context, beatSyncRequestSignalBeat)
// [renderer] beat-sync -> [main] -> [renderer] index
const changeState = defineInvoke(context, beatSyncElectronChangeState)
const signalBeat = defineInvoke(context, beatSyncElectronSignalBeat)
function enableLoopbackAudio() {
// electron-audio-loopback currently registers this handler internally
@@ -24,9 +35,16 @@ const detector = createBeatSyncDetector({
disableLoopbackAudio,
})
detector.on('beat', e => requestSignalBeat(e))
detector.on('stateChange', state => changeState(state))
detector.on('beat', (e) => {
// eslint-disable-next-line no-console
console.debug('[beat]', e) // This could be noisy.
signalBeat(e)
})
defineInvokeHandler(context, beatSyncToggle, async (enabled) => {
defineInvokeHandler(context, beatSyncElectronToggle, async (enabled) => {
// eslint-disable-next-line no-console
console.log('[toggle]', enabled)
if (enabled) {
detector.startScreenCapture()
}
@@ -34,3 +52,9 @@ defineInvokeHandler(context, beatSyncToggle, async (enabled) => {
detector.stop()
}
})
defineInvokeHandler(context, beatSyncElectronGetState, async () => detector.state)
defineInvokeHandler(context, beatSyncElectronUpdateParameters, async (params) => {
// eslint-disable-next-line no-console
console.log('[update-params]', params)
detector.updateParameters(params)
})
@@ -1,3 +1,6 @@
import type { AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync'
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
export const electronStartTrackMousePosition = defineInvokeEventa('eventa:invoke:electron:start-tracking-mouse-position')
@@ -71,4 +74,10 @@ export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:ele
export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:widgets:clear')
export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Record<string, any> }>('eventa:event:electron:windows:widgets:update')
export const beatSyncElectronSignalBeat = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:electron:signal-beat')
export const beatSyncElectronChangeState = defineInvokeEventa<void, BeatSyncDetectorState>('eventa:event:electron:beat-sync:electron:change-state')
export const beatSyncElectronGetState = defineInvokeEventa<BeatSyncDetectorState>('eventa:event:electron:beat-sync:electron:get-state')
export const beatSyncElectronToggle = defineInvokeEventa<void, boolean>('eventa:event:electron:beat-sync:electron:toggle')
export const beatSyncElectronUpdateParameters = defineInvokeEventa<void, Partial<AnalyserWorkletParameters>>('eventa:event:electron:beat-sync:electron:update-parameters')
export { electron } from './electron'
+1
View File
@@ -45,6 +45,7 @@
"vue-router": "^4.6.3"
},
"devDependencies": {
"@proj-airi/stage-shared": "workspace:^",
"@types/three": "^0.181.0",
"vue-tsc": "^3.1.4"
}
@@ -1,16 +1,24 @@
<script setup lang="ts">
import type { AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
import type { AnalyserWorkletParameters } from '@nekopaw/tempora'
import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync'
import { DEFAULT_ANALYSER_WORKLET_PARAMS } from '@nekopaw/tempora'
import {
getBeatSyncState,
listenBeatSyncBeatSignal,
listenBeatSyncStateChange,
toggleBeatSync,
updateBeatSyncParameters,
} from '@proj-airi/stage-shared/beat-sync/browser'
import { Button } from '@proj-airi/stage-ui/components'
import { useBeatSyncStore } from '@proj-airi/stage-ui/stores/beat-sync'
import { FieldCheckbox, FieldRange } from '@proj-airi/ui'
import { createTimeline } from 'animejs'
import { nanoid } from 'nanoid'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const beatSyncStore = useBeatSyncStore()
const state = ref<BeatSyncDetectorState>()
const { t } = useI18n()
const beatsHistory = ref<Array<{
@@ -21,9 +29,7 @@ const beatsHistory = ref<Array<{
const parameters = ref<AnalyserWorkletParameters>({ ...DEFAULT_ANALYSER_WORKLET_PARAMS })
watchEffect(() => {
beatSyncStore.updateParameters(parameters.value)
})
watch<AnalyserWorkletParameters>(parameters, newParameters => updateBeatSyncParameters(toRaw(newParameters)), { deep: true })
function normalizeEnergy(energy: number) {
const base = 2
@@ -32,19 +38,23 @@ function normalizeEnergy(energy: number) {
}
onMounted(() => {
const onBeat = ({ energy }: AnalyserBeatEvent) => {
beatsHistory.value.unshift({
id: nanoid(),
energy,
normalizedEnergy: normalizeEnergy(energy),
})
}
getBeatSyncState().then(initialState => state.value = initialState)
beatSyncStore.on('beat', onBeat)
const removeHandlerFns = [
listenBeatSyncStateChange((newState) => {
state.value = { ...newState }
}),
listenBeatSyncBeatSignal(({ energy }) => {
beatsHistory.value.unshift({
id: nanoid(),
energy,
normalizedEnergy: normalizeEnergy(energy),
})
}),
]
onUnmounted(() => {
beatSyncStore.off('beat', onBeat)
})
const removeHandlers = () => removeHandlerFns.forEach(fn => fn())
onUnmounted(() => removeHandlers())
})
function onRippleEnter(el: Element, done: () => void) {
@@ -93,14 +103,14 @@ function resetDefaultParameters() {
</div>
<div max-w-full flex="~ row gap-4 wrap">
<template v-if="beatSyncStore.isActive">
<Button @click="beatSyncStore.stop">
<template v-if="state?.isActive">
<Button @click="toggleBeatSync(false)">
{{ t('settings.pages.modules.beat_sync.sections.audio_source.actions.stop') }}
</Button>
</template>
<template v-else>
<Button @click="beatSyncStore.startFromScreenCapture">
<Button @click="toggleBeatSync(true)">
{{ t('settings.pages.modules.beat_sync.sections.audio_source.actions.start_screen_capture') }}
</Button>
</template>
+1 -1
View File
@@ -17,7 +17,7 @@
"exports": {
".": "./src/index.ts",
"./beat-sync": "./src/beat-sync/index.ts",
"./beat-sync/eventa": "./src/beat-sync/eventa.ts",
"./beat-sync/browser": "./src/beat-sync/browser/index.ts",
"./electron-renderer": "./src/electron-renderer.d.ts"
},
"scripts": {
@@ -0,0 +1,288 @@
import type { EventContext } from '@moeru/eventa'
import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
import type { BeatSyncDetectorEventMap, BeatSyncDetectorState } from '../types'
import analyserWorklet from '@nekopaw/tempora/worklet?url'
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora'
import { isStageTamagotchi, isStageWeb, StageEnvironment } from '../../environment'
import {
beatSyncBeatSignaledInvokeEventa,
beatSyncGetStateInvokeEventa,
beatSyncStateChangedInvokeEventa,
beatSyncToggleInvokeEventa,
beatSyncUpdateParametersInvokeEventa,
} from '../eventa'
import {
createContext,
} from './eventa'
export interface BeatSyncDetector {
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
startScreenCapture: () => Promise<void>
stop: () => void
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => () => void
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
readonly state: BeatSyncDetectorState
readonly context: AudioContext | undefined
readonly analyser: Analyser | undefined
readonly source: AudioNode | undefined
}
export type CreateBeatSyncDetectorOptions = |
{
env: StageEnvironment.Tamagotchi
enableLoopbackAudio: () => Promise<any>
disableLoopbackAudio: () => Promise<any>
}
| {
env: StageEnvironment.Web
}
export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): BeatSyncDetector {
let context: AudioContext | undefined
let analyser: Analyser | undefined
let source: AudioNode | undefined
const state = {
isActive: false,
}
let stopSource: (() => void) | undefined
const listeners: { [K in keyof BeatSyncDetectorEventMap]: Array<(...args: any) => void> } = {
stateChange: [],
beat: [],
}
const emit = <E extends keyof BeatSyncDetectorEventMap>(event: E, ...args: Parameters<BeatSyncDetectorEventMap[E]>) => {
listeners[event].forEach(listener => listener(...args))
}
const stop = () => {
if (!state.isActive)
return
state.isActive = false
emit('stateChange', state)
stopSource?.()
stopSource = undefined
source?.disconnect()
source = undefined
analyser?.stop()
analyser = undefined
context?.close()
context = undefined
}
const start = async (createSource: (context: AudioContext) => Promise<AudioNode>) => {
stop()
context = new AudioContext()
analyser = await startTemporaAnalyser({
context,
worklet: analyserWorklet,
listeners: {
onBeat: e => emit('beat', e),
},
})
const node = await createSource(context)
node.connect(analyser.workletNode)
source = node
state.isActive = true
emit('stateChange', state)
}
const updateParameters = (params: Partial<AnalyserWorkletParameters>) => {
analyser?.updateParameters(params)
}
const startScreenCapture = async () => start(async (ctx) => {
switch (options.env) {
case StageEnvironment.Web: {
const stream = await navigator.mediaDevices.getDisplayMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
video: true,
})
if (stream.getAudioTracks().length === 0) {
throw new Error('No audio track available in the stream')
}
stream.getAudioTracks().forEach((track) => {
let stopCalled = false
track.addEventListener('ended', () => {
if (stopCalled)
return
stopCalled = true
stop()
})
})
const node = ctx.createMediaStreamSource(stream)
stopSource = () => {
stream.getTracks().forEach(track => track.stop())
}
return node
}
case StageEnvironment.Tamagotchi: {
await options.enableLoopbackAudio()
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
})
const videoTracks = stream.getVideoTracks()
videoTracks.forEach((track) => {
track.stop()
stream.removeTrack(track)
})
const node = ctx.createMediaStreamSource(stream)
stopSource = () => {
stream.getTracks().forEach(track => track.stop())
options.disableLoopbackAudio()
}
await options.disableLoopbackAudio()
return node
}
default:
throw new Error('Failed to start screen capture: Unsupported environment')
}
})
const off = <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
const listenerFns = listeners[event]
if (!listenerFns) {
throw new Error(`Unknown event: ${event}`)
}
const index = listenerFns.indexOf(listener)
if (index !== -1)
listenerFns.splice(index, 1)
}
const on = <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
const listenerFns = listeners[event]
if (!listenerFns) {
throw new Error(`Unknown event: ${event}`)
}
listenerFns.push(listener)
return () => off(event, listener)
}
return {
start,
updateParameters,
startScreenCapture,
stop,
on,
off,
get state() { return state },
get context() { return context },
get analyser() { return analyser },
get source() { return source },
}
}
let detector: BeatSyncDetector | undefined
function getDetector() {
if (!isStageWeb())
throw new Error('getDetector() is only available in Stage Web environment')
if (!detector)
detector = createBeatSyncDetector({ env: StageEnvironment.Web })
return detector
}
let context: EventContext<any, any> | undefined
function getContext() {
if (!context)
context = createContext()
return context
}
export function toggleBeatSync(enabled: boolean) {
if (isStageWeb()) {
if (enabled) {
return getDetector().startScreenCapture()
}
else {
return getDetector().stop()
}
}
if (isStageTamagotchi()) {
const toggleFn = defineInvoke(getContext(), beatSyncToggleInvokeEventa) as (enabled: boolean) => Promise<void> // TODO: Better type
return toggleFn(enabled)
}
throw new Error('Unknown environment for beatSyncToggle()')
}
export async function getBeatSyncState() {
if (isStageWeb()) {
return getDetector().state
}
if (isStageTamagotchi()) {
return defineInvoke(getContext(), beatSyncGetStateInvokeEventa)()
}
throw new Error('Unknown environment for getBeatSyncState()')
}
export function updateBeatSyncParameters(params: Partial<AnalyserWorkletParameters>) {
if (isStageWeb()) {
return getDetector().updateParameters(params)
}
if (isStageTamagotchi()) {
return defineInvoke(getContext(), beatSyncUpdateParametersInvokeEventa)(params)
}
throw new Error('Unknown environment for updateBeatSyncParameters()')
}
export function listenBeatSyncStateChange(listener: (state: BeatSyncDetectorState) => void) {
if (isStageWeb()) {
return getDetector().on('stateChange', listener)
}
if (isStageTamagotchi()) {
return defineInvokeHandler(getContext(), beatSyncStateChangedInvokeEventa, listener)
}
throw new Error('Unknown environment for listenBeatSyncStateChange()')
}
export function listenBeatSyncBeatSignal(listener: (e: AnalyserBeatEvent) => void) {
if (isStageWeb()) {
return getDetector().on('beat', listener)
}
if (isStageTamagotchi()) {
return defineInvokeHandler(getContext(), beatSyncBeatSignaledInvokeEventa, listener)
}
throw new Error('Unknown environment for listenBeatSyncBeatSignal()')
}
@@ -0,0 +1,12 @@
import { createContext as createWebContext } from '@moeru/eventa'
import { createContext as createElectronRendererContext } from '@moeru/eventa/adapters/electron/renderer'
import { isElectronWindow } from '@proj-airi/stage-shared'
export function createContext() {
if (isElectronWindow(window)) {
return createElectronRendererContext(window.electron.ipcRenderer).context
}
else {
return createWebContext()
}
}
@@ -0,0 +1,2 @@
export * from './detector'
export * from './eventa'
+11 -4
View File
@@ -1,7 +1,14 @@
import type { AnalyserBeatEvent } from '@nekopaw/tempora'
import type { AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
import type { BeatSyncDetectorState } from './types'
import { defineInvokeEventa } from '@moeru/eventa'
export const beatSyncToggle = defineInvokeEventa<void, boolean>('eventa:event:electron:beat-sync:toggle')
export const beatSyncRequestSignalBeat = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:request-signal-beat')
export const beatSyncSignalBeat = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:signal-beat')
// Functions
export const beatSyncToggleInvokeEventa = defineInvokeEventa<void, boolean>('eventa:invoke:electron:beat-sync:toggle')
export const beatSyncGetStateInvokeEventa = defineInvokeEventa<BeatSyncDetectorState>('eventa:invoke:electron:beat-sync:get-state')
export const beatSyncUpdateParametersInvokeEventa = defineInvokeEventa<void, Partial<AnalyserWorkletParameters>>('eventa:event:electron:beat-sync:update-parameters')
// Events
export const beatSyncStateChangedInvokeEventa = defineInvokeEventa<void, BeatSyncDetectorState>('eventa:event:electron:beat-sync:state-changed')
export const beatSyncBeatSignaledInvokeEventa = defineInvokeEventa<void, AnalyserBeatEvent>('eventa:event:electron:beat-sync:beat-signaled')
+2 -191
View File
@@ -1,191 +1,2 @@
import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora'
import analyserWorklet from '@nekopaw/tempora/worklet?url'
import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora'
import { StageEnvironment } from '../environment'
export interface BeatSyncDetectorEventMap {
stateChange: (isActive: boolean) => void
beat: (e: AnalyserBeatEvent) => void
}
export interface BeatSyncDetector {
start: (createSource: (context: AudioContext) => Promise<AudioNode>) => Promise<void>
updateParameters: (params: Partial<AnalyserWorkletParameters>) => void
startScreenCapture: () => Promise<void>
stop: () => void
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => void
readonly isActive: boolean
readonly context: AudioContext | undefined
readonly analyser: Analyser | undefined
readonly source: AudioNode | undefined
}
export type CreateBeatSyncDetectorOptions = |
{
env: StageEnvironment.Tamagotchi
enableLoopbackAudio: () => Promise<any>
disableLoopbackAudio: () => Promise<any>
}
| {
env: StageEnvironment.Web
}
export function createBeatSyncDetector(options: CreateBeatSyncDetectorOptions): BeatSyncDetector {
let context: AudioContext | undefined
let analyser: Analyser | undefined
let source: AudioNode | undefined
let isActive = false
let stopSource: (() => void) | undefined
const listeners: { [K in keyof BeatSyncDetectorEventMap]: Array<(...args: any) => void> } = {
stateChange: [],
beat: [],
}
const emit = <E extends keyof BeatSyncDetectorEventMap>(event: E, ...args: Parameters<BeatSyncDetectorEventMap[E]>) => {
listeners[event].forEach(listener => listener(...args))
}
const stop = () => {
if (!isActive)
return
isActive = false
emit('stateChange', isActive)
stopSource?.()
stopSource = undefined
source?.disconnect()
source = undefined
analyser?.stop()
analyser = undefined
context?.close()
context = undefined
}
const start = async (createSource: (context: AudioContext) => Promise<AudioNode>) => {
stop()
context = new AudioContext()
analyser = await startTemporaAnalyser({
context,
worklet: analyserWorklet,
listeners: {
onBeat: e => emit('beat', e),
},
})
const node = await createSource(context)
node.connect(analyser.workletNode)
source = node
isActive = true
emit('stateChange', isActive)
}
const updateParameters = (params: Partial<AnalyserWorkletParameters>) => {
analyser?.updateParameters(params)
}
const startScreenCapture = async () => start(async (ctx) => {
switch (options.env) {
case StageEnvironment.Web: {
const stream = await navigator.mediaDevices.getDisplayMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
video: true,
})
if (stream.getAudioTracks().length === 0) {
throw new Error('No audio track available in the stream')
}
stream.getAudioTracks().forEach((track) => {
let stopCalled = false
track.addEventListener('ended', () => {
if (stopCalled)
return
stopCalled = true
stop()
})
})
const node = ctx.createMediaStreamSource(stream)
stopSource = () => {
stream.getTracks().forEach(track => track.stop())
}
return node
}
case StageEnvironment.Tamagotchi: {
await options.enableLoopbackAudio()
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
})
const videoTracks = stream.getVideoTracks()
videoTracks.forEach((track) => {
track.stop()
stream.removeTrack(track)
})
const node = ctx.createMediaStreamSource(stream)
stopSource = () => {
stream.getTracks().forEach(track => track.stop())
options.disableLoopbackAudio()
}
await options.disableLoopbackAudio()
return node
}
default:
throw new Error('Failed to start screen capture: Unsupported environment')
}
})
return {
start,
updateParameters,
startScreenCapture,
stop,
on: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
switch (event) {
case 'beat':
listeners.beat.push(listener)
break
default:
throw new Error(`Unknown event: ${event}`)
}
},
off: <E extends keyof BeatSyncDetectorEventMap>(event: E, listener: BeatSyncDetectorEventMap[E]) => {
switch (event) {
case 'beat': {
const index = listeners.beat.indexOf(listener)
if (index !== -1)
listeners.beat.splice(index, 1)
break
}
default:
throw new Error(`Unknown event: ${event}`)
}
},
get isActive() { return isActive },
get context() { return context },
get analyser() { return analyser },
get source() { return source },
}
}
export * from './eventa'
export * from './types'
@@ -0,0 +1,10 @@
import type { AnalyserBeatEvent } from '@nekopaw/tempora'
export interface BeatSyncDetectorState {
isActive: boolean
}
export interface BeatSyncDetectorEventMap {
stateChange: (state: BeatSyncDetectorState) => void
beat: (e: AnalyserBeatEvent) => void
}
-1
View File
@@ -1,4 +1,3 @@
export * from './beat-sync'
export * from './environment'
export * from './url'
export * from './window'
@@ -2,6 +2,7 @@
import type { Application } from '@pixi/app'
import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cubism4'
import { listenBeatSyncBeatSignal } from '@proj-airi/stage-shared/beat-sync/browser'
import { useTheme } from '@proj-airi/ui'
import { breakpointsTailwind, until, useBreakpoints, useDebounceFn } from '@vueuse/core'
import { formatHex } from 'culori'
@@ -12,7 +13,6 @@ import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from
import { useLive2DIdleEyeFocus } from '../../../composables/live2d'
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
import { useBeatSyncStore } from '../../../stores/beat-sync'
import { useLive2d } from '../../../stores/live2d'
import { useSettings } from '../../../stores/settings'
@@ -660,19 +660,14 @@ watch(focusAt, (value) => {
model.value.focus(value.x, value.y)
})
const beatSyncStore = useBeatSyncStore()
onMounted(() => {
const onBeat = () => {
beatSyncTargetY.value = Math.max(-5, Math.min(5, (beatSyncTargetY.value < 0 ? 10 : -10) * (0.5 + Math.random() * 0.3)))
beatSyncTargetZ.value = Math.max(-5, Math.min(5, (beatSyncTargetZ.value < 0 ? 10 : -10) * (0.5 + Math.random() * 0.3)))
}
beatSyncStore.on('beat', onBeat)
onUnmounted(() => {
beatSyncStore.off('beat', onBeat)
})
const removeListener = listenBeatSyncBeatSignal(() => onBeat())
onUnmounted(() => removeListener())
})
onMounted(async () => {
@@ -1,7 +1,9 @@
import { computed } from 'vue'
import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync'
import { getBeatSyncState, listenBeatSyncStateChange } from '@proj-airi/stage-shared/beat-sync/browser'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBeatSyncStore } from '../stores/beat-sync'
import { useConsciousnessStore } from '../stores/modules/consciousness'
import { useDiscordStore } from '../stores/modules/discord'
import { useFactorioStore } from '../stores/modules/gaming-factorio'
@@ -31,7 +33,7 @@ export function useModulesList() {
const twitterStore = useTwitterStore()
const minecraftStore = useMinecraftStore()
const factorioStore = useFactorioStore()
const beatSyncStore = useBeatSyncStore()
const beatSyncState = ref<BeatSyncDetectorState>()
const modulesList = computed<Module[]>(() => [
{
@@ -138,7 +140,7 @@ export function useModulesList() {
description: t('settings.pages.modules.beat_sync.description'),
icon: 'i-solar:music-notes-bold-duotone',
to: '/settings/modules/beat-sync',
configured: beatSyncStore.isActive,
configured: beatSyncState.value?.isActive ?? false,
category: 'essential',
},
])
@@ -161,6 +163,13 @@ export function useModulesList() {
gaming: t('settings.pages.modules.categories.gaming'),
}))
// TODO(Makito): We can make this a reactive value from a synthetic store.
onMounted(() => {
getBeatSyncState().then(initialState => beatSyncState.value = initialState)
const removeListener = listenBeatSyncStateChange(newState => beatSyncState.value = { ...newState })
onUnmounted(() => removeListener())
})
return {
modulesList,
categorizedModules,
+6
View File
@@ -567,6 +567,9 @@ importers:
'@intlify/unplugin-vue-i18n':
specifier: ^11.0.1
version: 11.0.1(@vue/compiler-dom@3.5.25)(eslint@9.39.1(jiti@2.6.1))(rollup@4.53.2)(typescript@5.9.3)(vue-i18n@11.1.12(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
'@nekopaw/tempora':
specifier: 0.3.1-alpha.1
version: 0.3.1-alpha.1
'@proj-airi/lobe-icons':
specifier: ^1.0.14
version: 1.0.14
@@ -1328,6 +1331,9 @@ importers:
specifier: ^4.6.3
version: 4.6.3(vue@3.5.25(typescript@5.9.3))
devDependencies:
'@proj-airi/stage-shared':
specifier: workspace:^
version: link:../stage-shared
'@types/three':
specifier: ^0.181.0
version: 0.181.0