Help us understand the problem. What is going on with this article?

React とセルオートマトンと WebAudio

More than 3 years have passed since last update.

セルオートマトン(コンウェイのライフゲーム)の状態を React (+ Redux) で描画しつつ音楽のシーケンスとして利用し WebAudio で作ったシンセを鳴らすというタイトル通りのものを作ってみました。

出来たものはこちら(注: 音が出ます)(注: ちゃんと動作するの Chrome くらいかと思いますすみません)。
音楽と呼べるか分からない感じに音が鳴り響いていると思いますがやりたいことは何か伝わってると幸いです。

遊び方

  • 'start'ボタンでセルの状態の変化とシーケンスが始まり音が流れ始めます
  • 'stop'ボタンで上記を停止します
  • 'clear'ボタンで全セルを死滅させます
  • 'random'ボタンで全セルの生死をランダムに更新します
  • 'bpm'スライダーで速さを変えます
  • グリッド上のセルをクリックするとセルの生死をトグル出来ます
  • ほっておくとセルが全部死んだりするのでその際は'random'ボタンを押すといいでしょう

ソースはこちら。色々とかなり雑ですがとりあえず動いています。

以下解説。

React で必要なコンポーネント作成

今回は table でセル群を表示してます。HTML のルートになる部分と table の部分を以下に抜粋。
また、Flux の部分は Redux を使っています。Redux は全く触ったことがなかったですが折角なので導入してみました。

src/containers/App.jsx
import React, { PropTypes } from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'

import Board from '../components/board.jsx'
import Controller from '../components/controller.jsx'
import * as Actions from '../actions'

const rowLabels = [
  'Synth C',
  'Synth B',
  'Synth A',
  'Synth G',
  'Synth F',
  'Synth E',
  'Synth D',
  'Synth C',
  'Open Hihat',
  'Open Hihat',
  'Close Hihat',
  'Close Hihat',
  'Snare',
  'Snare',
  'Kick',
  'Kick'
]

const rootStyle = {
  margin: '8px'
}

const titleStyle = {
  fontSize: '24px'
}

const App = ({cells, sequencer, actions}) => (
  <div style={rootStyle}>
    <h1 style={titleStyle}>React/Redux Cellular Automaton Sequencer</h1>
    <Controller actions={actions} bpm={sequencer.bpm} />
    <Board cells={cells} sequencer={sequencer} actions={actions} rowLabels={rowLabels}/>
  </div>
)

App.propTypes = {
  cells: PropTypes.array.isRequired,
  actions: PropTypes.object.isRequired
}

const mapStateToProps = state => ({ cells: state.cells, sequencer: state.sequencer })
const mapDispatchToProps = dispatch => ({ actions: bindActionCreators(Actions, dispatch)
})

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(App)
src/components/board.jsx
import React, { Component, PropTypes } from 'react'
import Row from './row.jsx'

class Board extends Component {
  static propTypes = {
    cells: PropTypes.array.isRequired,
    sequencer: PropTypes.object.isRequired,
    actions: PropTypes.object.isRequired,
    rowLabels: PropTypes.array
  }

  handleCellClick(x, y) {
    this.props.actions.updateCell(x, y)
  }

  render() {
    const style = {
      backgroundColor: "#afeeee",
      borderStyle: "none",
      borderWidth: "1px",
      padding: "0px",
      margin: "0px"
    }
    const rows = this.props.cells.map((row, i) => {
      return (
        <Row
            key={i}
            row={row}
            y={i}
            stepX={this.props.sequencer.step}
            label={this.props.rowLabels[i]}
            trigger={this.props.sequencer.triggers[i]}
            onCellClick={(x, y) => this.handleCellClick(x, y)}/>)
    })
    return (
      <table style={ style }>
        <tbody>
          {rows}
        </tbody>
      </table>
    )
  }
}

export default Board

Redux でセルオートマトンの状態遷移を管理

まず、コンウェイのライフゲームのルールは簡単に以下のようになります。

  1. 対象のセルの周囲 8 つのセルがそのセルの次の状態を決める
  2. 対象のセルが死んでいる時、周囲のセルが 3 つ生きていれば次は生きかえる
  3. 対象のセルが生きている時、周囲のセルが 2 つ、もしくは 3 つ生きていれば次も生きている

参考: https://ja.wikipedia.org/wiki/%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B2%E3%83%BC%E3%83%A0

これを Redux の reducer に作っていきます。
reducer で 状態更新の action を受けたらセルの全体の状態を上記のルールに従って判定し新しい状態を作成、その変更ををトリガーに View が再描画される、という流れです。

:src/reducers/cells.js
import { process, updateCell, createRandomCells, clearAll } from './ca/conway'
import { PROCESS, STEP, UPDATE_CELL, RANDOM_ALL, CLEAR_ALL } from '../constants/ActionTypes'

const initialState = createRandomCells(16)

const cells = (state = initialState, action) => {
  switch (action.type) {
    case PROCESS:
      return process(state)
    case STEP:
      return process(state)
    case UPDATE_CELL:
      return updateCell(state, action.x, action.y)
    case RANDOM_ALL:
      return createRandomCells(16)
    case CLEAR_ALL:
      return clearAll(state)
    default:
      return state
  }
}

export default cells
src/reducers/ca/conway.js
import _ from 'lodash'

export const process = (cells) => {
  return cells.map((row, y) => {
    return row.map((cell, x) => {
      return willChange(cells, x, y) ? toggleValue(cell) : cell
    })
  })
}

export const updateCell = (cells, x, y) => {
  cells[y][x] = toggleValue(cells[y][x])
  return Object.assign([], cells)
}

export const clearAll = (cells) => {
  return cells.map((r) => { return r.map((_) => { return 0 }) })
}

export const createRandomCells = (size) => {
  return _.times(size, () => {
    return _.times(size, () => {
      return Math.floor(Math.random()*2)
    })
  })
}

const willChange = (cells, x, y) => {
  const v = cells[y][x],
        c = neighborCount(cells, x, y)
  if (isAlived(v)) {
    return !(c === 2 || c === 3)
  } else {
    return c === 3
  }
}

const isAlived = (v) => {
  return v !== 0
}

const neighborCount = (cells, x, y) => {
  return neighbors(cells, x, y).filter((val) => { return isAlived(val) }).length
}

const neighbors = (cells, x, y) => {
  return [
    [x, y - 1],
    [x, y + 1],
    [x - 1, y],
    [x + 1, y],
    [x - 1, y - 1],
    [x - 1, y + 1],
    [x + 1, y - 1],
    [x + 1, y + 1]
  ].map(([x, y]) => { return cellValue(cells, x, y) })
}

const cellValue = (cells, x, y) => {
  const h = cells.length,
        y_ = (y + h) % h,
        w = cells[y_].length,
        x_ = (x + w) % w
  return cells[y_][x_]
}

const toggleValue = (val) => { return val ^ 1 }

セルオートマトンをどうやって音楽に使うか?

今回は縦に楽器を並べ、横を順に進んでいくシーケンサとして使ってみます。
縦は上から以下の様に楽器を並べてみました

  • シンセ音の C
  • シンセ音の B
  • シンセ音の A
  • シンセ音の G
  • シンセ音の F
  • シンセ音の E
  • シンセ音の D
  • シンセ音の C
  • オープンハイハット
  • オープンハイハット
  • クローズハイハット
  • クローズハイハット
  • スネア
  • スネア
  • キック
  • キック

横方向にステップし、フォーカスした列でその時生きているセルに該当する行の楽器の音が鳴るようになっています。
ビートを入れていたりシンセ音がこんな感じなのは単に趣味です。

WebAudio で各楽器作成

WebAudio ではオーディオファイルをサンプルとして利用も出来ますが、今回は各楽器を自前で作ります。WebAudio はシンセが自前で簡単に作れて便利ですね。

キックとシンセ音のコードを載せておきます。

src/synth/kick.js
export default class Kick {
  constructor(ctx) {
    const t = ctx.currentTime
    this.ctx = ctx
    this.decay = 0.2
    this.hi = 200
    this.lo = 40
    this.gain = this.ctx.createGain()
    this.gain.gain.value = 0
  }

  connect(node) {
    this.gain.connect(node)
  }

  play() {
    const t = this.ctx.currentTime,
          osc = this.ctx.createOscillator()
    osc.type = 'sine'
    osc.start(t)
    osc.stop(t + this.decay)
    osc.connect(this.gain)

    osc.frequency.setValueAtTime(this.hi, t)
    osc.frequency.exponentialRampToValueAtTime(this.lo, t + this.decay * 0.4)

    this.gain.gain.cancelScheduledValues(0)
    this.gain.gain.setValueAtTime(0, t)
    this.gain.gain.linearRampToValueAtTime(0.5, t)
    this.gain.gain.exponentialRampToValueAtTime(0.0001, t + this.decay)
  }
}
src/synth/acid.js
import { m2f } from './util'

class Acid {
  constructor(ctx) {
    this.ctx = ctx
    this.decay = 0.4
    this.filter = this.ctx.createBiquadFilter()
    this.filter.type = 'lowpass'
    this.filter.frequency.value = 1000
    this.filter.Q.value = 10

    this.gain = this.ctx.createGain()
    this.gain.gain.value = 0
    this.filter.connect(this.gain)
  }

  connect(node) {
    this.gain.connect(node)
  }

  play(note = 24) {
    const t = this.ctx.currentTime,
          freq = m2f(note),
          osc = this.ctx.createOscillator()
    osc.type = 'sawtooth'
    osc.connect(this.filter)
    osc.frequency.setValueAtTime(freq, t)
    osc.start(t)
    osc.stop(t + this.decay)

    this.filter.frequency.cancelScheduledValues(0)
    this.filter.frequency.setValueAtTime(0, t)
    this.filter.frequency.linearRampToValueAtTime(freq * 5, t)
    this.filter.frequency.exponentialRampToValueAtTime(freq * 1.5, t + this.decay)

    this.gain.gain.cancelScheduledValues(0)
    this.gain.gain.setValueAtTime(0, t)
    this.gain.gain.linearRampToValueAtTime(0.1, t)
    this.gain.gain.exponentialRampToValueAtTime(0.0001, t + this.decay)
  }
}

export default Acid

キックはピッチエンベロープを使ってアタック感を出しています。常套手段ですね。
シンセ音の方もごく基本的なシンセの構成で、ノコギリ波をソースにフィルターにエンベロープを付けています。

Redux で時間情報の管理

セルの状態の更新は一定の時間間隔で行うようにします。タイマーを用意し、任意の一定のタイミングでセルの状態を更新しシーケンスを進める Action を発行します。

src/containers/Timer.js
import WebAudioScheduler from 'web-audio-scheduler'
import WorkerTimer from 'worker-timer'

class Timer {
  constructor(ctx, actions) {
    this.ctx = ctx
    this.actions = actions
    this.bpm = 0
    this.sched = new WebAudioScheduler({ context: ctx, timerAPI: WorkerTimer })
    this.tick = this.tick.bind(this)
  }

  setState(state) {
    this.bpm = state.sequencer.bpm
    if (state.sequencer.running) {
      this.start()
    } else {
      this.stop()
    }
  }

  start() {
    if (this.sched.state === 'suspended') {
      this.sched.start(this.tick)
    }
  }

  stop() {
    if (this.sched.state === 'running') {
      this.sched.stop()
    }
  }

  tick(e) {
    const t = e.playbackTime,
          bpm = this.bpm
    this.actions.step()
    this.sched.insert(t + (60 / bpm) / 4, this.tick);
  }
}

export default Timer

タイマーからの Action によりシーケンスの状態とセルの状態が変化します。
タイミングは WebAudio のスケジューリングを利用すると精度を高くすることが出来ます。
https://www.html5rocks.com/en/tutorials/audio/scheduling/

そのためにこちらのライブラリを利用させていただいています。大変便利です。
https://github.com/mohayonao/web-audio-scheduler
https://github.com/mohayonao/worker-timer

シーケンスは以下の reducer で、セルの状態は先述のセルオートマトンの reducer で状態を管理しています。

src/reducers/sequencer.js
import { STEP, STOP, START, BPM, TRIGGER_ALL, TRIGGER_END } from '../constants/ActionTypes'

const initialState = {
  step: 0,
  running: false,
  bpm: 160,
  length: 16,
  triggers: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
  triggering: false
}

const sequencer = (state = initialState, action) => {
  switch (action.type) {
    case STEP:
      return {
        step : (state.step  + 1) % state.length,
        running: state.running,
        bpm: state.bpm,
        length: state.length,
        triggers: state.triggers,
        triggering: false
      }
    case STOP:
      return {
        step : state.step,
        running: false,
        bpm: state.bpm,
        length: state.length,
        triggers: state.triggers,
        triggering: false
      }
    case START:
      return {
        step : state.step,
        running: true,
        bpm: state.bpm,
        length: state.length,
        triggers: state.triggers,
        triggering: false
      }
    case BPM:
      return {
        step : state.step,
        running: state.running,
        bpm: action.bpm,
        length: state.length,
        triggers: state.triggers,
        triggering: false
      }
    case TRIGGER_ALL:
      return {
        step : state.step,
        running: state.running,
        bpm: state.bpm, length:
        state.length,
        triggers: action.triggers,
        triggering: true
      }
    case TRIGGER_END:
      return {
        step : state.step,
        running: state.running,
        bpm: state.bpm, length:
        state.length,
        triggers: state.triggers,
        triggerring: false
      }
    default:
      return state
  }
}

export default sequencer

セルの状態とシーケンスの状態の変化を以下のクラスが subscribe していて、その状態に合わせて現在のステップの状態を通知する Action を発行、その Action を契機に該当のトラックを発音し View にも発音の状態を反映させます。

src/containers/Sequencer.js
import _ from 'lodash'

class Sequencer {
  constructor(actions) {
    this.actions = actions
    this.step = -1
  }

  setState(state) {
    if (this.step != state.sequencer.step) {
      this.step = state.sequencer.step
      this.processStep(this.step, state.cells)
    }
  }

  processStep(step, cells) {
    const currents = _.flatten(cells.map((row) => {
      return row.filter((_, x) => { return x === step })
    }))

    this.actions.triggerAll(currents)
  }
}

export default Sequencer
src/containers/Track.js
import { Acid, Kick, Snare, Hihat }  from '../synth'

class Track {
  constructor(ctx, actions) {
    this.bass = new Acid(ctx)
    this.kick = new Kick(ctx)
    this.snare = new Snare(ctx)
    this.oh = new Hihat(ctx, 0.5)
    this.ch = new Hihat(ctx, 0.1)

    this.bass.connect(ctx.destination)
    this.kick.connect(ctx.destination)
    this.snare.connect(ctx.destination)
    this.oh.connect(ctx.destination)
    this.ch.connect(ctx.destination)

    this.baseNote = 60
    this.tracks = [
      { instrument: this.bass, args: [12 + this.baseNote] },
      { instrument: this.bass, args: [11 + this.baseNote] },
      { instrument: this.bass, args: [9 + this.baseNote] },
      { instrument: this.bass, args: [7 + this.baseNote] },
      { instrument: this.bass, args: [5 + this.baseNote] },
      { instrument: this.bass, args: [4 + this.baseNote] },
      { instrument: this.bass, args: [2 + this.baseNote] },
      { instrument: this.bass, args: [0 + this.baseNote] },
      { instrument: this.oh, args: [] },
      { instrument: this.oh, args: [] },
      { instrument: this.ch, args: [] },
      { instrument: this.ch, args: [] },
      { instrument: this.snare, args: [] },
      { instrument: this.snare, args: [] },
      { instrument: this.kick, args: [] },
      { instrument: this.kick, args: [] }
    ]
    this.actions = actions
  }

  setState(state) {
    if (state.sequencer.triggering) {
      this.playAll(state.sequencer.triggers)
    }
  }

  playAll(triggers) {
    triggers.map((v, i) => {
      const track = this.tracks[i]
      track.active = v === 1 ? true : false
      return track
    }).filter((track) => {
      return track.active
    }).forEach((track) => {
      track.instrument.play(...track.args)
    })
    this.actions.triggerEnd()
  }
}

export default Track

全体の流れ

だいたいこんな感じ。

    ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←
    ↓                                     ↑
[ View ] →→→ [ Action ] → [ Dispatch ] → [ Store(cells/sequencer) ]
               ↑  ↑  ↑                    ↓  ↓  ↓
[ Timer ] →→→→→→  ↑  ↑                    ↓  ↓  ↓
       ↑          ↑  ↑                    ↓  ↓  ↓
       ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←  ↓  ↓
                  ↑  ↑                       ↓  ↓
[ Sequencer ] →→→→→  ↑                       ↓  ↓
           ↑         ↑                       ↓  ↓
           ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←  ↓
                     ↑                          ↓
[ Track ] →→→→→→→→→→→→                          ↓
  ↓    ↑                                        ↓
  ↓    ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←
  ↓→→[ Kick ]
  ↓→→[ Snare ]
  ↓→→[ Hihat ]
  ↓→→[ Synth ]

まとめ

  • 久々にフロントのコード書いて楽しかった
  • とりあえず Redux 初めて触れたの良かった
  • React 以外のところを Redux に紐付ける方法がまだよく分かってない
  • セルオートマトンを React + Redux で作る意義があるかは分からないが楽しかった
  • シーケンスの方法とか楽器のマッピングが浅はかで無理矢理感もあるけどそれでもなかなか楽しめる
  • WebAudio でブラウザで音鳴らすの楽しいですね
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした