Эта статья — перевод оригинальной статьи Tania Rascia "Building a Musical Instrument with the Web Audio API"

Также я веду телеграм канал “Frontend по-флотски”, где рассказываю про интересные вещи из мира разработки интерфейсов.

Вступление

Недавно я поехал в Техас и купил трехрядный диатонический аккордеон. Диатонические аккордеоны популярны для множества различных типов народной музыки, которую обычно учат на слух. Это хорошо для меня, потому что я все равно не умею читать ноты.

Аккордеон имеет 34 кнопки со стороны высоких частот и 12 кнопок со стороны басов. В отличие от фортепианного аккордеона, который имеет такое же логическое хроматическое расположение, как и фортепиано, у диатонического аккордеона просто набор кнопок, и я действительно не знал, с чего начать. Кроме того, каждая нота отличается независимо от того, вытягиваете ли вы меха или сжимаете их, поэтому на самом деле на стороне высоких частот 68 нот (хотя некоторые из них повторяются). Кроме того, как я думаю, вы знаете, аккордеоны громкие. Очень громкие. Чтобы сильно не злить соседей, и узнать, как работает компоновка этой коробки, я решил сделать маленькое веб-приложение.

Это веб-приложение KeyboardAccordion.com, которое, как и все остальное, что я создаю в свободное время, имеет открытый исходный код. Я заметил, что на клавиатуре компьютера ровно столько клавиш, чтобы соответствовать раскладке аккордеона, и они расположены по похожей схеме. Благодаря этому я могу отслеживать ноты, гаммы и аккорды и начинать выяснять, как соединить все это вместе.

Вот как выглядит один из аккордеонов:

Я решил сделать это приложение в Svelte, потому что я профессионально использовал React и Vue, но не имел никакого опыта работы со Svelte и хотел узнать, что всем в нем нравится.

Web Audio API

KeyboardAccordion.com имеет только одну зависимость, и это Svelte. Все остальное делается с помощью чистого JavaScript и встроенного в браузер Web Audio API. Я никогда раньше не использовал Web Audio API, поэтому я выяснял, что мне нужно, чтобы всё заработало.

Первое, что я сделал, это создал AudioContext и прикрепил GainNode, который управляет громкостью.

const audio = new (window.AudioContext || window.webkitAudioContext)()
const gainNode = audio.createGain()
gainNode.gain.value = 0.1
gainNode.connect(audio.destination)

Пока я во всем разбирался, я экспериментировал с созданием нового AudioContext для каждой ноты, потому что пытался заглушить звук, но потом я продолжал понимать, что после 50 нот приложение перестанет работать. Пятьдесят, по-видимому, предел для браузера, поэтому лучше просто сделать один AudioContext для всего приложения.

Я использую волны с Audio API и не использую какие-либо аудиосэмплы, и я использовал OscillatorNode для создания каждой ноты. Вы можете использовать различные типы волн — квадратные, треугольные, синусоидальные или пилообразные, и все они имеют разный тип звука. Я выбрал пилообразные для этого приложения, потому что они работали лучше всего. Square издает чрезвычайно громкий звук, похожий на чиптюн, как у NES, что по-своему приятно. Синус и треугольник были немного более приглушенными, но если вы не затухнете звук должным образом, получится очень неприятный режущий звук из-за того, как ваше ухо реагирует, когда волна обрезается.

Виды волн

Итак, для каждой ноты я делал осциллятор, устанавливал тип волны, устанавливал частоту и запустил ее. Вот пример с использованием 440, что является стандартной настройкой для «ля».

const oscillator = audio.createOscillator()
oscillator.type = 'sawtooth'
oscillator.connect(gainNode)
oscillator.frequency.value = 440
oscillator.start()

Если вы это сделаете, нота будет воспроизводиться до бесконечности, поэтому вам нужно убедиться, что вы остановили осциллятор, когда хотите, чтобы нота закончилась.

oscillator.stop()

Для меня это означало, что прослушиватели событий в DOM будут прослушивать событие keypress, чтобы увидеть, была ли нажата какая-либо кнопка, и событие keyup, чтобы определить, когда какая-либо кнопка больше не нажата. В Svelte это решается размещением прослушивателей событий на svelte:body.

<svelte:body
  on:keypress="{handleKeyPressNote}"
  on:keyup="{handleKeyUpNote}"
  on:mouseup="{handleClearAllNotes}"
/>

Так что это действительно все, что есть в самом Web Audio API, когда дело доходит до настройки приложения — создание AudioContext, добавление усиления и запуск/остановка осциллятора для каждой ноты.

Вы можете вставить это в консоль, и нота воспроизведется. Вам нужно либо обновить, либо набрать oscillator.stop(), чтобы она остановился.

const audio = new (window.AudioContext || window.webkitAudioContext)()
const gainNode = audio.createGain()
gainNode.gain.value = 0.1
gainNode.connect(audio.destination)

const oscillator = audio.createOscillator()
oscillator.type = 'sawtooth'
oscillator.connect(gainNode)
oscillator.frequency.value = 440
oscillator.start()

Структура данных

Мне нужно было понять, как я хочу разместить структуру данных для этого приложения. Прежде всего, если я собираюсь использовать Web Audio API с частотами напрямую, мне нужно было собрать их все.

Частоты

Вот хорошая схема частот нот со всеми 12 нотами и 8-9 октавами для каждой ноты, поэтому я могу использовать A[4], чтобы получить частоту 440.

export const tone = {
  C: [16.35, 32.7, 65.41, 130.81, 261.63, 523.25, 1046.5, 2093.0, 4186.01],
  Db: [17.32, 34.65, 69.3, 138.59, 277.18, 554.37, 1108.73, 2217.46, 4434.92],
  D: [18.35, 36.71, 73.42, 146.83, 293.66, 587.33, 1174.66, 2349.32, 4698.64],
  Eb: [19.45, 38.89, 77.78, 155.56, 311.13, 622.25, 1244.51, 2489.02, 4978.03],
  E: [20.6, 41.2, 82.41, 164.81, 329.63, 659.26, 1318.51, 2637.02],
  F: [21.83, 43.65, 87.31, 174.61, 349.23, 698.46, 1396.91, 2793.83],
  Gb: [23.12, 46.25, 92.5, 185.0, 369.99, 739.99, 1479.98, 2959.96],
  G: [24.5, 49.0, 98.0, 196.0, 392.0, 783.99, 1567.98, 3135.96],
  Ab: [25.96, 51.91, 103.83, 207.65, 415.3, 830.61, 1661.22, 3322.44],
  A: [27.5, 55.0, 110.0, 220.0, 440.0, 880.0, 1760.0, 3520.0],
  Bb: [29.14, 58.27, 116.54, 233.08, 466.16, 932.33, 1864.66, 3729.31],
  B: [30.87, 61.74, 123.47, 246.94, 493.88, 987.77, 1975.53, 3951.07],
}

Расположение кнопок

Выяснение того, как расположить все кнопки в структуре данных, заняло у меня пару попыток. Данные, которые нужно было зафиксировать, были:

  • Ряд на аккордеоне

  • Колонна на аккордеоне

  • Направление меха (сжимать или тянуть)

  • Название и частота ноты в этой строке, столбце и направлении

Это означает, что существуют разные комбинации для всех трех наборов этих вещей. Я решил создать идентификатор, соответствующий каждой возможной комбинации, например, 1-1-pull — это строка 1, столбец 1, направление "тянуть".

Таким образом, я мог бы создать массив, содержащий данные для любой воспроизводимой в данный момент ноты. Если вы нажмете кнопку, чтобы перевернуть меха, она возьмет все играемые в данный момент ноты и перевернет их, таким образом заменив 1-1-тянуть и 1-2-тянуть на 1-1-нажать и 1-2-нажать.

Итак, в конечном итоге у меня был объект, содержащий данные для всех трех тройных строк, например:

const layout = {
  one: [],
  two: [],
  three: [],
}

Мой аккордеон настроен на FB♭Eb, то есть первый ряд настроен на F, второй ряд настроен на B♭, а третий ряд настроен на E♭. Пример для первой строки выглядит так:

const layout = {
  one: [
    // Pull
    { id: '1-1-pull', name: 'D♭', frequency: tone.Db[4] },
    { id: '1-2-pull', name: 'G', frequency: tone.G[3] },
    { id: '1-3-pull', name: 'B♭', frequency: tone.Bb[3] },
    { id: '1-4-pull', name: 'D', frequency: tone.D[4] },
    { id: '1-5-pull', name: 'E', frequency: tone.E[4] },
    { id: '1-6-pull', name: 'G', frequency: tone.G[4] },
    { id: '1-7-pull', name: 'B♭', frequency: tone.Bb[4] },
    { id: '1-8-pull', name: 'D', frequency: tone.D[5] },
    { id: '1-9-pull', name: 'E', frequency: tone.E[5] },
    { id: '1-10-pull', name: 'G', frequency: tone.G[5] },
    // Push
    { id: '1-1-push', name: 'B', frequency: tone.B[3] },
    { id: '1-2-push', name: 'F', frequency: tone.F[3] },
    { id: '1-3-push', name: 'A', frequency: tone.A[3] },
    { id: '1-4-push', name: 'C', frequency: tone.C[4] },
    { id: '1-5-push', name: 'F', frequency: tone.F[4] },
    { id: '1-6-push', name: 'A', frequency: tone.A[4] },
    { id: '1-7-push', name: 'C', frequency: tone.C[5] },
    { id: '1-8-push', name: 'F', frequency: tone.F[5] },
    { id: '1-9-push', name: 'A', frequency: tone.A[5] },
    { id: '1-10-push', name: 'C', frequency: tone.C[6] },
  ],
  two: [
    // ...etc
  ],
}

В первой строке есть ноты с 1 по 10, и у каждой есть имя и частота, связанные с ней. Повторяя это для двойки и тройки, теперь у меня есть все 68 нот на высоких частотах.

Раскладка клавиатуры

Теперь мне нужно было сопоставить каждую клавишу на клавиатуре со строкой и столбцом аккордеона. Направление здесь не имеет значения, так как z будет соответствовать как 01-01-push, так и 01-01-pull.

export const keyMap = {
  z: { row: 1, column: 1 },
  x: { row: 1, column: 2 },
  c: { row: 1, column: 3 },
  v: { row: 1, column: 4 },
  b: { row: 1, column: 5 },
  n: { row: 1, column: 6 },
  m: { row: 1, column: 7 },
  ',': { row: 1, column: 8 },
  '.': { row: 1, column: 9 },
  '/': { row: 1, column: 10 },
  a: { row: 2, column: 1 },
  s: { row: 2, column: 2 },
  d: { row: 2, column: 3 },
  f: { row: 2, column: 4 },
  g: { row: 2, column: 5 },
  // ...etc
}

Теперь у меня есть все ключи от z до /, от a до ' и от w до [. Очень подозрительно, что клавиатура компьютера и клавиатура аккордеона так похожи.

Нажатие клавиш, воспроизведение нот

Как вы, возможно, помните, у меня есть прослушиватель событий на всей странице, прослушивающий событие нажатия клавиши. Любое событие нажатия клавиши будет проходить через эту функцию.

Во-первых, она должна проверять как строчные, так и прописные клавиши в случае нажатия Shift или Caps Lock, иначе клавиши вообще не будут работать. Затем, если вы нажимаете кнопку для переключения мехов (что я сделал q), она должна обрабатывать это отдельно. В противном случае она проверит keyMap и, если он существует, найдет соответствующий идентификатор, проверив текущее направление и получив строку и столбец из keymap.

let activeButtonIdMap = {}

function handleKeyPressNote(e) {
  const key = `${e.key}`.toLowerCase() || e.key // handle caps lock

  if (key === toggleBellows) {
    handleToggleBellows('push')
    return
  }

  const buttonMapData = keyMap[key]

  if (buttonMapData) {
    const { row, column } = buttonMapData
    const id = `${row}-${column}-${direction}`

    if (!activeButtonIdMap[id]) {
      const { oscillator } = playTone(id)

      activeButtonIdMap[id] = { oscillator, ...buttonIdMap[id] }
    }
  }
}

Я отслеживаю каждую воспроизводимую ноту, помещая их в объект activeButtonIdMap. В Svelte, чтобы обновить переменную, вы просто переназначаете ее, это заменяет нам useState, который используется в React:

const [activeButtonIdMap, setActiveButtonIdMap] = useState({})

const App = () => {
  function handleKeyPressNote() {
    setActiveButtonIdMap(newButtonIdMap)
  }
}

Вы должны объявить её как let и переназначить:

let activeButtonIdMap = {}

function handleKeyPressNote() {
  activeButtonIdMap = newButtonIdMap
}

Это было в основном проще, за исключением случаев, когда все, что я хотел сделать, это удалить ключ из объекта. Насколько я мог судить, Svelte перерисовывает только тогда, когда переменная переназначается, поэтому просто изменить какое-то значение внутри было недостаточно, и мне пришлось клонировать его, видоизменять и переназначать. Это то, что я сделал в функции handleKeyUpNote.

function handleKeyUpNote(e) {
  const key = `${e.key}`.toLowerCase() || e.key

  if (key === toggleBellows) {
    handleToggleBellows('pull')
    return
  }

  const buttonMapData = keyMap[key]

  if (buttonMapData) {
    const { row, column } = buttonMapData
    const id = `${row}-${column}-${direction}`

    if (activeButtonIdMap[id]) {
      const { oscillator } = activeButtonIdMap[id]
      oscillator.stop()
      // Must be reassigned in Svelte
      const newActiveButtonIdMap = { ...activeButtonIdMap }
      delete newActiveButtonIdMap[id]
      activeButtonIdMap = newActiveButtonIdMap
    }
  }
}

Может быть, кто-то знает лучший способ удалить элемент из объекта в Svelte, но это лучшее, что я смог придумать.

Я также сделал несколько функций, которые будут воспроизводить гаммы, начиная с F, B♭ и E♭, являющихся основными диатоническими клавишами аккордеона, но есть и другие варианты. Чтобы воспроизвести гаммы, я просто просмотрел все идентификаторы, соответствующие нотам в гамме, и использовал JavaScript команду «sleep» с интервалом 600мс между каждой нотой.

Рендеринг

Теперь, когда у меня настроены все структуры данных и JavaScript, мне просто нужно отобразить все кнопки. В Svelte есть блоки #each для циклической логики, поэтому я просто перебрал три ряда кнопок и отрисовал круг для каждой кнопки.

<div class="accordion-layout">
  {#each rows as row}
    <div class="row {row}">
      {#each layout[row].filter(({ id }) => id.includes(direction)) as button}
        <div
          class={`circle ${activeButtonIdMap[button.id] ? 'active' : ''} ${direction} `}
          id={button.id}
          on:mousedown={handleClickNote(button.id)}
        >
          {button.name}
        </div>
      {/each}
    </div>
  {/each}
</div>

У каждого круга есть свое собственное событие mousedown, поэтому вы можете щелкать по ним в дополнение к использованию клавиатуры, но я не помещал событие mouseup в сам круг. Это связано с тем, что если вы переместите мышь в другое место, прежде чем поднять ее, она не будет правильно определять положение мыши, и нота будет воспроизводиться вечно.

И, конечно же, я использовал простой CSS, потому что я не чувствую необходимости в чём-то большем.

.circle {
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  height: 60px;
  width: 60px;
  margin-bottom: 10px;
  background: linear-gradient(to bottom, white, #e7e7e7);
  box-shadow: 0px 6px rgba(255, 255, 255, 0.4);
  color: #222;
  font-weight: 600;
  cursor: pointer;
}

.circle:hover {
  background: white;
  box-shadow: 0px 6px rgba(255, 255, 255, 0.3);
  cursor: pointer;
}

.circle.pull:active,
.circle.pull.active {
  background: linear-gradient(to bottom, var(--green), #56ea7b);
  box-shadow: 0px 6px rgba(255, 255, 255, 0.2);
}

.circle.push:active,
.circle.push.active {
  background: linear-gradient(to bottom, var(--red), #f15050);
  box-shadow: 0px 6px rgba(255, 255, 255, 0.2);
  color: white;
}

Заключение

Надеюсь, вам понравилось моё приложение Keyboard Accordion! Разумеется, полный код доступен на GitHub.

Здесь и там есть несколько небольших ошибок, например, если вы используете сочетания клавиш, одновременно нажимая другие клавиши, он навсегда застревает на ноте. Я уверен, что если вы попытаетесь найти больше ошибок, вы сможете это сделать.

Это приложение было весело делать, я научился использовать как Svelte, так и Web Audio API. Возможно, это вдохновит вас на создание собственного маленького онлайн-инструмента или приложение для одного из ваших увлечений. Самое лучшее в программировании то, что вы можете сделать все, что захотите!

Комментарии (6)


  1. addewyd
    19.06.2022 17:16

    Странно, что женщина (пруф: www.taniarascia.com) говорит о себе в мужском роде.
    Хотя, им там, в америках, пофиг.

    Но вообще, интересно. После того, как сгорела моя мидиклава (натурально, синим пламенем), очень интересуюсь вариантами использования обычной клавиатуры в качестве инструмента.


    1. dmitrypyltsov
      19.06.2022 23:06

      Странно, что некоторые не видят в начале статьи упоминание о переводе, с ссылкой на оригинальную статью автора. Поэтому все вопросы к переводчику, а не к "америкам".


      1. addewyd
        20.06.2022 05:28
        +1

        Вопрос именно к переводчику. «Недавно я поехал в Техас», «Я заметил», «я решил».
        В русском род глагола в пр.вр. ещё не подвергся политкорректной отмене.


  1. vvs013
    19.06.2022 22:42
    +1

    Первый сообщение в комментариях должно было состоять из одного слова - "Баян". :)

    (шутка)


  1. pehat
    20.06.2022 02:48
    +1

    Это не баян, это бандонеон. Самая извращённая версия аккордеона, где на вдох и на выдох на одной кнопке две разные ноты, а все из-за экономии на кнопках. На нем невообразимо сложно сыграть без подготовки.


  1. Alexey2005
    20.06.2022 11:40

    Интересно, работает ли этот код на Safari? Там со всеми звуковыми API обычно дела не очень…