🔊

snd.devをReactで使ってみた

2022/03/02に公開約6,000字1件のコメント

(追記: SEについてのポエムを最後に書いてしまいました。)

電通の土屋さんとスターリーワークスさんが snd.dev というUI向けのサウンドライブラリを出したと聞いたので早速つくっているアプリで試してみた。

https://twitter.com/starryworks/status/1498877211633328128

npmとCDNとどちらもあるのでHTML/VanillaJSで手軽に使うこともReactなどのプロダクトにも導入しやすい。

ここではReact/Next.jsでの組み込んでみた内容をまとめてみる。

プロジェクト構成

  • TypeScript 4.5.5
  • React 17.0.2
  • Next 12.0.8
  • Chakra 1.8.1

※UIはChakraを採用していますが他のものでも応用できるように書いています。

インストール方法

コンソールなどで package.json に追加する。

npm i snd-lib

基本的な使い方

  1. Sndインスタンスを作る
  2. サウンドアセットをロードする
  3. Sndインスタンスのメソッドを呼ぶ
const snd = new Snd(/* options */)

snd.load(Snd.KITS.SND01)

document.getElementById("button").addEventListener("click", () => {
  snd.playButton()
})

初期化時に自動的にロードさせるオプションもある。

const snd = new Snd({
  preloadSoundKit: Snd.KITS.SND01 // or Snd.KITS.SND02
})

なお、iOSなどのモバイル端末では音を再生するときに制約があり、ユーザーのアクションをトリガーに再生しないと勝手に音を鳴らすことができないため、通例として何かしらのユーザー操作で最初の1回を無音で鳴らしておくというハックが必要ですが、それはSndコンストラクタ内でよしなにやってくれるのでモバイルでも問題なく使えます。
素敵。

Reactで使いやすくする

SndインスタンスをContext/Providerで流す

おなじSndインスタンスを使いまわして一度だけアセットをロードしたいので初期化処理をContext.Providerに集めて利用側はContextを通じてインスタンスを受け取って使うだけにします。

はじめに値の型がSndとなるContextをつくります。
ここでは初期値の型を雑にアサーションしてしまってますがプロジェクトの作りに応じてよしなに変えてください。

export const SoundContext = createContext<Snd>({} as Snd)

Sndの知識を隠蔽するなら必要なメソッドや操作のみ渡すのも良いと思います。

type ISoundContext = {
  play: (key:string) => void 
  stop: (key:string) => void 
  mute: () => void 
  unmute: () => void 
}

export const SoundContext = createContext<ISoundContext>({} as ISoundContext)

Providerはインスタンス生成時に渡すオプション(SndOptions)をそのまま受け取れるようにしました。

type IProps = {
  options?: SndOptions 
}

export const SoundContextProvider: VFC<PropsWithChildren<IProps>> = ({
  options,
  children,
}) => {
  const [snd, setSnd] = useState<Snd>()

  const load = useCallback(async () => {
    await snd?.load(Snd.KITS.SND01)
  }, [snd])

  useEffect(() => {
    setSnd(new Snd(options))
  }, [options])

  useEffect(() => {
    // preloadSoundKitがある場合はコンストラクタで自動的にロードされるのでloadメソッドを呼ばない
    if (options?.preloadSoundKit) return
    void load()
  }, [options?.preloadSoundKit, load])
 
  if (!snd) return <>{children}</>

  return <SoundContext.Provider value={snd}>{children}</SoundContext.Provider>
}

ちなみにSndクラス本体以外の型は、パッケージ内の定義ファイルから読めば使えます。

import { SndOptions, PlayOptions } from 'snd-lib/dist/snd'

Next.jsなので pages/_app.tsx で先程の初期化を担う SoundContextProvider をインポートしてページコンポーネントを囲みます。
※実プロジェクトでは他にもいろいろ なんとかProvider があるけど省略します。

const App: VFC<AppProps> = ({ Component, pageProps }) => (
  <SoundContextProvider options={{ preloadSoundKit: Snd.KITS.SND02 }}>
    <ChakraProvider>
      <Component {...pageProps} />
    </ChakraProvider>
  </SoundContextProvider>
)

export default App

Reactコンポーネントで使う

これで下層のコンポーネントでは Context 経由で Snd インスタンスを受け取って使えます。

const snd = useContext(SoundContext)

ただ、ボタンなどのたくさんある全ての要素で毎度インポートとuseContext()書くのが辛そうだったので、UIライブラリに依存しないHooksを作り、それをUIライブラリと紐付けた汎用的なUIコンポーネントを作ります。

useClickSound()を実装します。
HooksとしてはonClick()というマウスイベントハンドラを含むpropsを受け取って、onClick()にサウンドを鳴らす処理を挟み込んで他のpropsはそのまま戻す感じのI/Fにします。

import { useContext, useCallback, MouseEventHandler } from 'react'
import { SoundContext } from 'views/features/sounds/Context'

export const useClickSound = <T extends { onClick?: MouseEventHandler }>({
  onClick,
  ...props
}: T) => {
  const snd = useContext(SoundContext)

  const onClickHandler: MouseEventHandler = useCallback(
    (e) => {
      snd.playButton()

      onClick?.(e)
    },
    [onClick, snd],
  )

  return {
    ...props,
    onClick: onClickHandler,
  }
}

これをお使いのUIライブラリの汎用パーツとして組み込んで利用します。

Chakra の場合の実装は次のようになりました。

ボタンのpropsを受け取って useClickSound() を通した値を Button に渡します。 
このときrefを転送できるようにします。(Chakraの場合、ReactのではなくてChakraのforwardRef()を使います。)

import {
  Button,
  ButtonProps,
  forwardRef,
  IconButtonProps,
} from '@chakra-ui/react'

import useClickSound from 'views/features/sounds/useClickSound'

export const SoundButton = forwardRef((props: ButtonProps | IconButtonProps, ref) => (
  <Button {...useClickSound(props)} ref={ref} />
))

こうすることで、onClick()含め、もとのボタンpropsを透過的に渡すだけなので、もともとのButtonコンポーネントと使用感はまったく同じです。

const SomeComponent = () => {
  const clickHandler = useCallback(() => {
    // Do something.
  }, []) 
  
  return (
    <SoundButton size="lg" onClick={clickHandler}>ボタン</SoundButton>
  ) 
}

as プロパティで IconButton などに変更することもできます。
もうちょっと汎用的にできそうな気もしますが関係ないところで時間がかかるのでこの辺で妥協しました。

<SoundButton as={IconButton} aria-label="ボタン" icon={<SomeIcon />} />

追記

asプロパティでコンポーネントを変更したときにうまく他のプロパティが反映されないことがあるので、ButtonIconButtonの切り替え程度であればそれぞれ素直にラップしたものを用意したほうが良いかもです。
うまく汎用化できる方は上記のような形で設計すると良いと思います。

export const SoundButton = forwardRef((props: ButtonProps, ref) => (
  <Button {...useClickSound(props)} ref={ref} />
))

export const SoundIconButton = forwardRef((props: IconButtonProps, ref) => (
  <IconButton {...useClickSound(props)} ref={ref} />
))

これで既存のプロジェクトですでに組み込まれたボタンがあってもコンポーネントを置き換えていくだけで、ほぼ違和感なく組み込みができると思います。

よいサウンドライフを。

余談

短絡的にクールなSEを入れること自体がUXをもたらすという誤解がないようにしたいです。
「インタラクションデザイン」として触っている感触の一端を担うのがSEの役割であり、
UIのフィードバックが適切になされていなければ作業者の自己満足に終わってしまいます。

Webにおいてはアプリっぽいものと広告ぽいものと雑誌媒体ぽいものでいろんな文脈があります。
UIにおけるSEは、もそもそOSやネイティブのアプリのUIとの連続性において適度な一貫性を保つのに有用と思いますが、雑誌とか病院のサイトとかがガチャガチャSEがなるのは必要性に疑問もあります。

そういうUI的な文脈とかシンタックスを理解しているほうが良いし、
SEを効果的に入れるには、インターフェイスとの対話において視覚以外のフィードバックとして必然性や意図を持つことと、かつ適切な音質音量音色で視覚的なトンマナと一貫性を担保することが重要です。
(ユーザーを妨げないとかは視覚的な演出もふくめて大前提にしています。)

商用もしくは公共性の高いサイトでは、音を出せない環境で想定される場合への配慮(そもそも鳴らさない、事前にアテンションを入れる、サウンドのON/OFFを選択できるなど)をしたりSEを入れることで体験が向上することを目指すのがUX(におけるUIの立場)と思います。
音が聞こえないと理解できない用途も避けるべきです。

なくても成り立つんだけど、あることでグッとよくなる(といいな)という思いがこのライブラリの配布には込められていると思います。
snd.devを立ち上げた方たちのそういう哲学的なところもセットで広がるといいな、と思います。

Discussion

使ってみたかったので大変助かります!
Next,jsで試してみたのですが、挙動は成功せずでして、よろしければ上記コードを書いているfile名を記載いただけないでしょうか?
このようにどこに記載されたのが拝見し、再度試行してみたいです!

pages/index.tsx
export const xxx...
ログインするとコメントできます