diff options
Diffstat (limited to 'packages/frontend/src/scripts/sound.ts')
| -rw-r--r-- | packages/frontend/src/scripts/sound.ts | 168 |
1 files changed, 147 insertions, 21 deletions
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 4b0cd0bb39..2f7545ef0d 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -3,13 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { SoundStore } from '@/store.js'; import { defaultStore } from '@/store.js'; +import * as os from '@/os.js'; -const ctx = new AudioContext(); +let ctx: AudioContext; const cache = new Map<string, AudioBuffer>(); +let canPlay = true; export const soundsTypes = [ + // 音声なし null, + + // ドライブの音声 + '_driveFile_', + + // プリインストール 'syuilo/n-aec', 'syuilo/n-aec-4va', 'syuilo/n-aec-4vb', @@ -38,6 +47,8 @@ export const soundsTypes = [ 'syuilo/waon', 'syuilo/popo', 'syuilo/triple', + 'syuilo/bubble1', + 'syuilo/bubble2', 'syuilo/poi1', 'syuilo/poi2', 'syuilo/pirori', @@ -61,46 +72,161 @@ export const soundsTypes = [ 'noizenecio/kick_gaba7', ] as const; -export async function getAudio(file: string, useCache = true) { - if (useCache && cache.has(file)) { - return cache.get(file)!; +export const operationTypes = [ + 'noteMy', + 'note', + 'antenna', + 'channel', + 'notification', + 'reaction', +] as const; + +/** サウンドの種類 */ +export type SoundType = typeof soundsTypes[number]; + +/** スプライトの種類 */ +export type OperationType = typeof operationTypes[number]; + +/** + * 音声を読み込む + * @param soundStore サウンド設定 + * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする + */ +export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) { + if (_DEV_) console.log('loading audio. opts:', options); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (ctx == null) { + ctx = new AudioContext(); + } + if (options?.useCache ?? true) { + if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) { + if (_DEV_) console.log('use cache'); + return cache.get(soundStore.fileId) as AudioBuffer; + } else if (cache.has(soundStore.type)) { + if (_DEV_) console.log('use cache'); + return cache.get(soundStore.type) as AudioBuffer; + } + } + + let response: Response; + + if (soundStore.type === '_driveFile_') { + try { + response = await fetch(soundStore.fileUrl); + } catch (err) { + try { + // URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック + const apiRes = await os.api('drive/files/show', { + fileId: soundStore.fileId, + }); + response = await fetch(apiRes.url); + } catch (fbErr) { + // それでも無理なら諦める + return; + } + } + } else { + try { + response = await fetch(`/client-assets/sounds/${soundStore.type}.mp3`); + } catch (err) { + return; + } } - const response = await fetch(`/client-assets/sounds/${file}.mp3`); const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrayBuffer); - if (useCache) { - cache.set(file, audioBuffer); + if (options?.useCache ?? true) { + if (soundStore.type === '_driveFile_') { + cache.set(soundStore.fileId, audioBuffer); + } else { + cache.set(soundStore.type, audioBuffer); + } } return audioBuffer; } -export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement { - const masterVolume = defaultStore.state.sound_masterVolume; - audio.volume = masterVolume - ((1 - volume) * masterVolume); - return audio; +/** + * 既定のスプライトを再生する + * @param type スプライトの種類を指定 + */ +export function play(operationType: OperationType) { + const sound = defaultStore.state[`sound_${operationType}`]; + if (_DEV_) console.log('play', operationType, sound); + if (sound.type == null || !canPlay) return; + + canPlay = false; + playFile(sound).finally(() => { + // ごく短時間に音が重複しないように + setTimeout(() => { + canPlay = true; + }, 25); + }); } -export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') { - const sound = defaultStore.state[`sound_${type}`]; - if (_DEV_) console.log('play', type, sound); - if (sound.type == null) return; - playFile(sound.type, sound.volume); +/** + * サウンド設定形式で指定された音声を再生する + * @param soundStore サウンド設定 + */ +export async function playFile(soundStore: SoundStore) { + const buffer = await loadAudio(soundStore); + if (!buffer) return; + createSourceNode(buffer, soundStore.volume)?.start(); } -export async function playFile(file: string, volume: number) { +export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null { const masterVolume = defaultStore.state.sound_masterVolume; - if (masterVolume === 0 || volume === 0) { - return; + if (isMute() || masterVolume === 0 || volume === 0) { + return null; } const gainNode = ctx.createGain(); gainNode.gain.value = masterVolume * volume; const soundSource = ctx.createBufferSource(); - soundSource.buffer = await getAudio(file); + soundSource.buffer = buffer; soundSource.connect(gainNode).connect(ctx.destination); - soundSource.start(); + + return soundSource; +} + +/** + * 音声の長さをミリ秒で取得する + * @param file ファイルのURL(ドライブIDではない) + */ +export async function getSoundDuration(file: string): Promise<number> { + const audioEl = document.createElement('audio'); + audioEl.src = file; + return new Promise((resolve) => { + const si = setInterval(() => { + if (audioEl.readyState > 0) { + resolve(audioEl.duration * 1000); + clearInterval(si); + audioEl.remove(); + } + }, 100); + }); +} + +/** + * ミュートすべきかどうかを判断する + */ +export function isMute(): boolean { + if (defaultStore.state.sound_notUseSound) { + // サウンドを出力しない + return true; + } + + // noinspection RedundantIfStatementJS + if (defaultStore.state.sound_useSoundOnlyWhenActive && document.visibilityState === 'hidden') { + // ブラウザがアクティブな時のみサウンドを出力する + return true; + } + + return false; } |