Волна и временная шкала
Звук можно прослушать, но для выбора участка полезно видеть его расположение во времени. В этом уроке построим амплитудную волну на Canvas и покажем текущую позицию отдельным HTML-индикатором. К концу урока станет ясно, как представить десятки тысяч кадров несколькими сотнями экранных колонок, не выбирая случайный семпл на каждую из них.
Рисование находится в wave.mjs, связь с формой и проигрывателем — в main.mjs архива серии. Пример пока не запускался. Мы объясняем алгоритм и ожидаемые ограничения, не приводим измерений частоты кадров или времени рисования.
Что показывает волна
Горизонтальная ось соответствует времени от начала до конца буфера. Вертикальная показывает значения семплов. Нулевая линия проходит посередине, положительные значения направлены вверх, отрицательные — вниз. Это обзор амплитуд, а не анализ частот. По нему нельзя надёжно определить высоту каждого звука или субъективную громкость.
Для короткой записи длиной 48 000 кадров и полотна шириной 600 пикселей на одну колонку приходится примерно 80 кадров. Если рисовать только первый из них, можно пропустить короткий пик между соседними выбранными значениями. Поэтому берём минимальное и максимальное значения всего диапазона, относящегося к колонке, и соединяем их вертикальным отрезком.
В стереобуфере наш обзор использует минимум и максимум по обоим каналам. Это сохраняет заметные пики каждого канала, но не показывает их раздельно и не описывает фазовую связь. Для профессионального редактора понадобятся отдельные дорожки, масштабирование и дополнительные представления. Первая версия стремится дать понятный ориентир короткого фрагмента.
Диапазон кадров для колонки
Для горизонтальной координаты x вычисляем начало как floor(x * length / width), а конец — по следующей колонке. Конец ограничиваем длиной буфера. Дополнительно гарантируем хотя бы один кадр, когда экранная ширина превышает число кадров. В таком случае несколько колонок могут показывать один семпл, что допустимо для обзорного рисунка.
const begin = Math.floor(x * buffer.length / width);
const end = Math.min(
buffer.length,
Math.max(begin + 1, Math.floor((x + 1) * buffer.length / width))
);
Это фрагмент внутреннего цикла. Для обычной длинной записи диапазоны покрывают последовательность по времени. Рисование не создаёт усреднённый звук и не изменяет буфер. Мы вычисляем только две характеристики участка для изображения; экспорт позднее использует исходные значения всех кадров.
Затем обходим канальные массивы и ищем экстремумы. Значение 1 превращается в верх полотна, 0 — в середину, -1 — в низ. Для изображения высотой 180 используется выражение (1 - value) * height / 2. Если значения выходят за обычный амплитудный диапазон, линия может уйти за видимую область. В этой версии отдельный масштаб амплитуды не предусмотрен.
Размер CSS и размер буфера Canvas
Полотно имеет ширину контейнера в CSS-пикселях и высоту 180. Размер внутреннего буфера умножаем на devicePixelRatio, ограниченный двумя. Затем масштабируем контекст, чтобы весь алгоритм продолжал рисовать в координатах CSS. Это помогает избежать слишком размытого представления на плотном экране и сдерживает рост пиксельного буфера.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(width * dpr);
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
Каждый вызов устанавливает размеры Canvas заново, что сбрасывает состояние контекста. Поэтому масштабирование выполняется после установки и не накапливается от предыдущего рисования. Если вызвать scale() многократно без сброса, изображение начнёт увеличиваться при каждом изменении формы.
Ограничение dpr относится к отображению, а не к частоте звука. Изменение плотности экрана не должно менять индексы фрагмента или качество WAV. Это ещё одна пара независимых систем: пиксели визуализации и кадры аудиобуфера. Для экспорта звука Canvas вообще не используется.
Выделенный участок
Перед волной рисуем цветной прямоугольник от startFrame / length * width до endFrame / length * width. Визуальный участок получается из той же проверенной модели, которую использует проигрыватель. Если диапазон занимает середину буфера от четверти до трёх четвертей, подложка должна занимать среднюю половину полотна.
Это только представление диапазона. В первой версии границы задаются числовыми полями, а не растягиваются мышью на самой волне. Такое решение позволяет сразу дать точный и доступный ввод без отдельного сложного жеста. При добавлении визуальных маркеров нужно будет сохранить общие проверки и преобразование координаты в целый кадр.
Индикатор позиции живёт отдельно
Текущую позицию показываем HTML-линией поверх Canvas. В requestAnimationFrame читаем player.position и меняем процентный left. Большая амплитудная волна не пересчитывается на каждом визуальном кадре. Она обновляется после открытия файла, изменения диапазона и размера окна.
const position = player?.position;
head.hidden = position == null || !buffer;
if (position != null && buffer) {
head.style.left = Math.min(100, position / buffer.duration * 100) + '%';
}
Это поясняющая запись тела визуального цикла. Часы воспроизведения находятся в AudioContext, а requestAnimationFrame только предоставляет момент обновления интерфейса. При фоновой вкладке визуальные кадры могут задерживаться. После возвращения линия должна снова соответствовать текущим часам, а не продолжать накопленный собственный счётчик.
Цена вычислений
Алгоритм обзора читает практически все значения буфера при каждом полном рисовании. Для шестидесятисекундной стереозаписи при 96 кГц это миллионы значений. Мы ограничили задачу, но не измеряли скорость. Частые изменения размера окна могут повторять такую работу; для следующей версии полезно кэшировать экстремумы и объединять запросы перерисовки.
Отдельный индикатор уменьшает число повторных проходов, однако не делает весь интерфейс автоматически быстрым. Если добавлять масштаб по времени, понадобится договор многомасштабного обзора и выбор нужного уровня детализации. Начните с измерений, а не с заявления о поддержке длинных записей по одной формуле.
Пустое состояние рисуется тем же модулем: фон остаётся, но канальные данные не читаются. После отказа декодирования старый рисунок не должен создавать впечатление, что прежний файл по-прежнему открыт. Поэтому очистка визуализации связана с обнулением буфера. Это небольшая часть управления состоянием, которую легко пропустить при разработке только удачного сценария.
Как проверить представление
При самостоятельном запуске рассмотрите учебный тон, тишину и короткую запись с заметным пиком. Ожидается центральная линия для тишины и сохранение пика даже между экранными колонками. Измените ширину окна: область фрагмента должна сохранять ту же долю времени. В стерео сравните рисунок с экстремумами обоих каналов, помня, что он их объединяет.
Эти проверки пока не выполнены. Мы получили обзор времени и отдельный живой указатель, не вмешивающиеся в звук. Следующий урок уточнит работу с границами: как округлять секунды, сохранять полуоткрытый диапазон и получать участок одинаковой длины в проигрывателе и экспорте.