diff --git a/apps/stage-pocket/vite.config.ts b/apps/stage-pocket/vite.config.ts index 5b2baa86a..ba161c690 100644 --- a/apps/stage-pocket/vite.config.ts +++ b/apps/stage-pocket/vite.config.ts @@ -140,6 +140,7 @@ export default defineConfig({ exclude: base => [ ...base, '**/settings/connection/index.vue', + '**/settings/modules/beat-sync.vue', ], }, ], diff --git a/packages/stage-shared/src/beat-sync/detector.test.ts b/packages/stage-shared/src/beat-sync/detector.test.ts new file mode 100644 index 000000000..21ca05749 --- /dev/null +++ b/packages/stage-shared/src/beat-sync/detector.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { + getBeatSyncInputByteFrequencyData, + getBeatSyncState, + inputAnalyserFFTSize, + isBeatSyncSupported, + listenBeatSyncBeatSignal, + listenBeatSyncStateChange, + toggleBeatSync, + updateBeatSyncParameters, +} from './detector' + +describe('capacitor Beat Sync runtime', () => { + beforeAll(() => { + vi.stubEnv('RUNTIME_ENVIRONMENT', 'capacitor') + }) + + afterAll(() => { + vi.unstubAllEnvs() + }) + + it('registers and removes inactive listeners without throwing', () => { + const removeStateListener = listenBeatSyncStateChange(vi.fn()) + const removeBeatListener = listenBeatSyncBeatSignal(vi.fn()) + + expect(removeStateListener).toBeTypeOf('function') + expect(removeBeatListener).toBeTypeOf('function') + expect(() => removeStateListener()).not.toThrow() + expect(() => removeBeatListener()).not.toThrow() + }) + + it('returns a deterministic inactive state', async () => { + expect(isBeatSyncSupported()).toBe(false) + await expect(getBeatSyncState()).resolves.toEqual({ isActive: false }) + }) + + it('allows inactive configuration calls to settle', async () => { + await expect(toggleBeatSync(false)).resolves.toBeUndefined() + await expect(updateBeatSyncParameters({})).resolves.toBeUndefined() + }) + + it('rejects attempts to enable unsupported screen capture', async () => { + await expect(toggleBeatSync(true)).rejects.toThrow('Beat Sync is not available in Stage Pocket') + }) + + it('returns silent frequency data', async () => { + const data = await getBeatSyncInputByteFrequencyData() + + expect(data).toBeInstanceOf(Uint8Array) + expect(data).toHaveLength(inputAnalyserFFTSize / 2) + expect(data.every(value => value === 0)).toBe(true) + }) +}) diff --git a/packages/stage-shared/src/beat-sync/detector.ts b/packages/stage-shared/src/beat-sync/detector.ts index fee66a83e..368f5074a 100644 --- a/packages/stage-shared/src/beat-sync/detector.ts +++ b/packages/stage-shared/src/beat-sync/detector.ts @@ -1,4 +1,3 @@ -import type { EventContext } from '@moeru/eventa' import type { Analyser, AnalyserBeatEvent, AnalyserWorkletParameters } from '@nekopaw/tempora' import type { SerializableDesktopCapturerSource } from '@proj-airi/electron-screen-capture' @@ -10,7 +9,7 @@ import { defineInvoke, defineInvokeHandler } from '@moeru/eventa' import { startAnalyser as startTemporaAnalyser } from '@nekopaw/tempora' import { setupElectronScreenCapture } from '@proj-airi/electron-screen-capture/renderer' -import { isStageTamagotchi, isStageWeb, StageEnvironment } from '../environment' +import { isStageCapacitor, isStageTamagotchi, isStageWeb, StageEnvironment } from '../environment' import { isElectronWindow } from '../window' import { beatSyncBeatSignaledInvokeEventa, @@ -249,14 +248,38 @@ function getDetector() { return detector } -let context: EventContext | undefined +let context: ReturnType | undefined + +function installCapacitorHandlers(context: ReturnType) { + defineInvokeHandler(context, beatSyncToggleInvokeEventa, async (enabled) => { + if (enabled) + throw new Error('Beat Sync is not available in Stage Pocket') + }) + defineInvokeHandler(context, beatSyncGetStateInvokeEventa, async () => ({ isActive: false })) + defineInvokeHandler(context, beatSyncUpdateParametersInvokeEventa, async () => {}) + defineInvokeHandler(context, beatSyncGetInputByteFrequencyDataInvokeEventa, async () => { + return new Uint8Array(inputAnalyserFFTSize / 2) + }) +} + function getContext() { - if (!context) + if (!context) { context = createContext() + // Capacitor cannot capture system audio. Register every request handler so + // callers receive deterministic inactive results instead of pending forever. + if (isStageCapacitor()) + installCapacitorHandlers(context) + } + return context } +/** Returns whether the current Stage runtime can capture audio for Beat Sync. */ +export function isBeatSyncSupported() { + return isStageWeb() || isStageTamagotchi() +} + export function toggleBeatSync(enabled: boolean) { if (isStageWeb()) { if (enabled) { @@ -267,7 +290,7 @@ export function toggleBeatSync(enabled: boolean) { } } - if (isStageTamagotchi()) { + if (isStageTamagotchi() || isStageCapacitor()) { const toggleFn = defineInvoke(getContext(), beatSyncToggleInvokeEventa) return toggleFn(enabled) } @@ -280,7 +303,7 @@ export async function getBeatSyncState() { return getDetector().state } - if (isStageTamagotchi()) { + if (isStageTamagotchi() || isStageCapacitor()) { return defineInvoke(getContext(), beatSyncGetStateInvokeEventa)() } @@ -292,7 +315,7 @@ export function updateBeatSyncParameters(params: Partial voi return getDetector().on('beat', listener) } - if (isStageTamagotchi()) { + if (isStageTamagotchi() || isStageCapacitor()) { return defineInvokeHandler(getContext(), beatSyncBeatSignaledInvokeEventa, listener) } @@ -328,7 +351,7 @@ export async function getBeatSyncInputByteFrequencyData() { return getDetector().getInputByteFrequencyData() } - if (isStageTamagotchi()) { + if (isStageTamagotchi() || isStageCapacitor()) { return defineInvoke(getContext(), beatSyncGetInputByteFrequencyDataInvokeEventa)() } diff --git a/packages/stage-ui/src/composables/use-modules-list.ts b/packages/stage-ui/src/composables/use-modules-list.ts index 69d24196a..59641667f 100644 --- a/packages/stage-ui/src/composables/use-modules-list.ts +++ b/packages/stage-ui/src/composables/use-modules-list.ts @@ -1,6 +1,6 @@ import type { BeatSyncDetectorState } from '@proj-airi/stage-shared/beat-sync' -import { getBeatSyncState, listenBeatSyncStateChange } from '@proj-airi/stage-shared/beat-sync' +import { getBeatSyncState, isBeatSyncSupported, listenBeatSyncStateChange } from '@proj-airi/stage-shared/beat-sync' import { computed, onMounted, onUnmounted, ref } from 'vue' import { useI18n } from 'vue-i18n' @@ -44,6 +44,7 @@ export function useModulesList() { const factorioStore = useFactorioStore() const artistryStore = useArtistryStore() const beatSyncState = ref() + const beatSyncSupported = isBeatSyncSupported() minecraftStore.initialize() @@ -165,15 +166,17 @@ export function useModulesList() { configured: false, category: 'essential', }, - { - id: 'beat-sync', - name: t('settings.pages.modules.beat_sync.title'), - description: t('settings.pages.modules.beat_sync.description'), - icon: 'i-solar:music-notes-bold-duotone', - to: '/settings/modules/beat-sync', - configured: beatSyncState.value?.isActive ?? false, - category: 'essential', - }, + ...(beatSyncSupported + ? [{ + id: 'beat-sync', + name: t('settings.pages.modules.beat_sync.title'), + description: t('settings.pages.modules.beat_sync.description'), + icon: 'i-solar:music-notes-bold-duotone', + to: '/settings/modules/beat-sync', + configured: beatSyncState.value?.isActive ?? false, + category: 'essential', + }] + : []), ]) const categorizedModules = computed(() => { @@ -196,6 +199,9 @@ export function useModulesList() { // TODO(Makito): We can make this a reactive value from a synthetic store. onMounted(() => { + if (!beatSyncSupported) + return + getBeatSyncState().then(initialState => beatSyncState.value = initialState) const removeListener = listenBeatSyncStateChange(newState => beatSyncState.value = { ...newState }) onUnmounted(() => removeListener())