barkey/packages/frontend/src/scripts/sound.ts
syuilo d30ddd4c2e
Refine preferences (#15597)
* wip

* wip

* wip

* test

* wip rollup pluginでsearchIndexの情報生成

* wip

* SPDX

* wip: markerIdを自動付与

* rollupでビルド時・devモード時に毎回uuidを生成するように

* 開発サーバーでだけ必要な挙動は開発サーバーのみで

* 条件が逆

* wip: childrenの生成

* update comment

* update comment

* rename auto generated file

* hashをパスと行数から決定

* Update privacy.vue

* Update privacy.vue

* wip

* Update general.vue

* Update general.vue

* wip

* wip

* Update SearchMarker.vue

* wip

* Update profile.vue

* Update mute-block.vue

* Update mute-block.vue

* Update general.vue

* Update general.vue

* childrenがduplicate key errorを吐く問題をいったん解決

* マーカーの形を成形

* loggerを置きかえ

* とりあえず省略記法に対応

* Refactor and Format codes

* wip

* Update settings-search-index.ts

* wip

* wip

* とりあえず不確定要因の仮置きidを削除

* hashの生成を正規化(絶対パスになっていたのを緩和)

* pathの入力を省略可能に

* adminでもパス生成できるように

* Update settings-search-index.ts

* Update privacy.vue

* wip

* build searchIndex

* wip

* build

* Update general.vue

* build

* Update sounds.vue

* build

* build

* Update sounds.vue

* 🎨

* 🎨

* Update privacy.vue

* Update privacy.vue

* Update security.vue

* create-search-indexを多少改善

* build

* Update 2fa.vue

* wip

* 必ずtransformCodeCacheを利用するように, キャッシュの明確な受け渡しを定義

* キャッシュはdevServerでなくても更新

* Revert "wip"

This reverts commit 41bffd3a13f55618bf939dc1c9acb2a77ead4054.

* inlining

* wip

* Update theme.vue

* 🎨

* wip normalize

* Update theme.vue

* キャッシュのパス変換

* build

* wip

* wip

* Update SearchMarker.vue

* i18n.ts['key'] の形式が取り出せない問題のFix

* build

* 仮でpath入れ

* 必ず絶対パスが使われるように

* wip

* 🎨

* storybookビルド時はcreateSearchIndexをしない

* inliningの構造化

* format code

* Update index.vue

* wip

* wip

* 🎨

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* clean up

* wip

* wip

* wip

* Update rollup-plugin-unwind-css-module-class-name.test.ts

* Update navbar.vue

* clean up

* wip

* wip

* wip

* wip

* wip

* Update preferences-backups.vue

* Update common.ts

* Update preferences.ts

* wip

* wip

* wip

* wip

* Update MkPreferenceContainer.vue

* Update MkPreferenceContainer.vue

* Update MkPreferenceContainer.vue

* enhance: 検索で上下矢印を使用することで検索結果を移動できるように

* Update main-boot.ts

* refactor

* wip

* Update sounds.vue

* fix(frontend): PageWindowでSearchMarkerが動作するように

* enhance(frontend): SearchMarkerの点滅を一定時間で止める

* wip

* lint fix

* fix: 子要素監視が抜けていたのを修正

* アニメーションの回数はCSSで制御するように

* refactor

* enhance(frontend): 検索インデックス作成時のログを削減

* revert

* fix

* fix

* Update preferences.ts

* Update preferences.ts

* wip

* Update preferences.ts

* wip

* 🎨

* wip

* Update MkPreferenceContainer.vue

* wip

* Update preferences.ts

* wip

* Update preferences.ts

* Update preferences.ts

* wip

* wip

* Update preferences.ts

* wip

* wip

* Update preferences.ts

* Update CHANGELOG.md

* Update preferences.ts

* Update deck-store.ts

* deckStoreをdefaultStoreに統合

* wip

* defaultStore -> store

* Update profile.ts

* wip

* refactor

* wip: plugin

* plugin

* plugin

* plugin

* Update plugin.ts

* wip

* Update plugin.vue

* Update preferences.ts

* Update main-boot.ts

* wip

* fix test

* Update plugin.vue

* Update plugin.vue

* Update utility.ts

* wip

* wip

* Update utility.ts

* wip

* wip

* clean up

* Update utility.ts

---------

Co-authored-by: tai-cha <dev@taichan.site>
Co-authored-by: taichan <40626578+tai-cha@users.noreply.github.com>
Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-09 12:34:08 +09:00

258 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { SoundStore } from '@/preferences/def.js';
import { prefer } from '@/preferences.js';
import { PREF_DEF } from '@/preferences/def.js';
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',
'syuilo/n-aec-8va',
'syuilo/n-aec-8vb',
'syuilo/n-cea',
'syuilo/n-cea-4va',
'syuilo/n-cea-4vb',
'syuilo/n-cea-8va',
'syuilo/n-cea-8vb',
'syuilo/n-eca',
'syuilo/n-eca-4va',
'syuilo/n-eca-4vb',
'syuilo/n-eca-8va',
'syuilo/n-eca-8vb',
'syuilo/n-ea',
'syuilo/n-ea-4va',
'syuilo/n-ea-4vb',
'syuilo/n-ea-8va',
'syuilo/n-ea-8vb',
'syuilo/n-ea-harmony',
'syuilo/up',
'syuilo/down',
'syuilo/pope1',
'syuilo/pope2',
'syuilo/waon',
'syuilo/popo',
'syuilo/triple',
'syuilo/bubble1',
'syuilo/bubble2',
'syuilo/poi1',
'syuilo/poi2',
'syuilo/pirori',
'syuilo/pirori-wet',
'syuilo/pirori-square-wet',
'syuilo/square-pico',
'syuilo/reverved',
'syuilo/ryukyu',
'syuilo/kick',
'syuilo/snare',
'syuilo/queue-jammed',
'aisha/1',
'aisha/2',
'aisha/3',
'noizenecio/kick_gaba1',
'noizenecio/kick_gaba2',
'noizenecio/kick_gaba3',
'noizenecio/kick_gaba4',
'noizenecio/kick_gaba5',
'noizenecio/kick_gaba6',
'noizenecio/kick_gaba7',
] as const;
export const operationTypes = [
'noteMy',
'note',
'notification',
'reaction',
] as const;
/** サウンドの種類 */
export type SoundType = typeof soundsTypes[number];
/** スプライトの種類 */
export type OperationType = typeof operationTypes[number];
/**
* 音声を読み込む
* @param url url
* @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする
*/
export async function loadAudio(url: string, options?: { useCache?: boolean; }) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (ctx == null) {
ctx = new AudioContext();
window.addEventListener('beforeunload', () => {
ctx.close();
});
}
if (options?.useCache ?? true) {
if (cache.has(url)) {
return cache.get(url) as AudioBuffer;
}
}
let response: Response;
try {
response = await fetch(url);
} catch (err) {
return;
}
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
if (options?.useCache ?? true) {
cache.set(url, audioBuffer);
}
return audioBuffer;
}
/**
* 既定のスプライトを再生する
* @param type スプライトの種類を指定
*/
export function playMisskeySfx(operationType: OperationType) {
const sound = prefer.s[`sound.on.${operationType}`];
playMisskeySfxFile(sound).then((succeed) => {
if (!succeed && sound.type === '_driveFile_') {
// ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>;
if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
playMisskeySfxFileInternal({
type: soundName,
volume: sound.volume,
});
}
});
}
/**
* サウンド設定形式で指定された音声を再生する
* @param soundStore サウンド設定
*/
export async function playMisskeySfxFile(soundStore: SoundStore): Promise<boolean> {
// 連続して再生しない
if (!canPlay) return false;
// ユーザーアクティベーションが必要な場合はそれがない場合は再生しない
if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
// サウンドがない場合は再生しない
if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
canPlay = false;
return await playMisskeySfxFileInternal(soundStore).finally(() => {
// ごく短時間に音が重複しないように
setTimeout(() => {
canPlay = true;
}, 25);
});
}
async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise<boolean> {
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
return false;
}
const masterVolume = prefer.s['sound.masterVolume'];
if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
return true; // ミュート時は成功として扱う
}
const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
const buffer = await loadAudio(url).catch(() => {
return undefined;
});
if (!buffer) return false;
const volume = soundStore.volume * masterVolume;
createSourceNode(buffer, { volume }).soundSource.start();
return true;
}
export async function playUrl(url: string, opts: {
volume?: number;
pan?: number;
playbackRate?: number;
}) {
if (opts.volume === 0) {
return;
}
const buffer = await loadAudio(url);
if (!buffer) return;
createSourceNode(buffer, opts).soundSource.start();
}
export function createSourceNode(buffer: AudioBuffer, opts: {
volume?: number;
pan?: number;
playbackRate?: number;
}): {
soundSource: AudioBufferSourceNode;
panNode: StereoPannerNode;
gainNode: GainNode;
} {
const panNode = ctx.createStereoPanner();
panNode.pan.value = opts.pan ?? 0;
const gainNode = ctx.createGain();
gainNode.gain.value = opts.volume ?? 1;
const soundSource = ctx.createBufferSource();
soundSource.buffer = buffer;
soundSource.playbackRate.value = opts.playbackRate ?? 1;
soundSource
.connect(panNode)
.connect(gainNode)
.connect(ctx.destination);
return { soundSource, panNode, gainNode };
}
/**
* 音声の長さをミリ秒で取得する
* @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 (prefer.s['sound.notUseSound']) {
// サウンドを出力しない
return true;
}
// noinspection RedundantIfStatementJS
if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') {
// ブラウザがアクティブな時のみサウンドを出力する
return true;
}
return false;
}