Как синхронизировать эффект печатающей машинки с песней (караоке-стиль)

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

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Эффект печатающей машинки</title>
</head>
<body>
<script>
    var main = async function () {
        const delay = (msec) => {
            return new Promise(resolve => setTimeout(resolve, msec));
        };

        <audio controls>
            <source src="C:/Users/user1/Desktop/код/audio.mp3" type="audio/mpeg">
        <p>
            Ваш браузер не поддерживает встроенное аудио. Попробуйте
            <a href="C:/Users/user1/Desktop/код/audio.mp3" download>скачать</a> файл.
        </p>
        </audio>


        const str2 = 'тебе не дают, окей, в ушах code10\nона знает этот swag, малыш2010\n';

        const str4 = 'они себя продают, окей, скажи мне ценник\nони знают этот swag, прошел свой тест на легит\n'

        const str5 = 'они знают, нам по кайфу влюбиться в наркоманку\n'

        const str7 = 'сливать с ней всю зарплату\nфонарь, подъезд и в банку\n'

        const str8 = 'знают, нам по кайфу влюбиться в наркоманку\n'

        const str10 = 'сливать с ней всю зарплату\nфонарь, подъезд и в банку'



        document.body.innerHTML = ''; //разбить строчки на константы и каждую константу засунуть в цикл

        for (let i = 0; i < str2.length; ++i) {      //малыш2010
            let c = str2[i];
            if (c === '\n') c = '<br/>';
            document.body.innerHTML += c;
            await delay(105);
        }

        for (let i = 0; i < str4.length; ++i) {      //наркоманку
            let c = str4[i];
            if (c === '\n') c = '<br/>';
            document.body.innerHTML += c;
            await delay(80);
        }

        for (let i = 0; i < str5.length; ++i) {      //в банку
            let c = str5[i];
            if (c === '\n') c = '<br/>';
            document.body.innerHTML += c;
            await delay(75);
        }

        for (let i = 0; i < str7.length; ++i) {      //наркоманку
            let c = str7[i];
            if (c === '\n') c = '<br/>';
            document.body.innerHTML += c;
            await delay(75);
        }

        for (let i = 0; i < str8.length; ++i) {      //в банку
            let c = str8[i];
            if (c === '\n') c = '<br/>';
            document.body.innerHTML += c;
            await delay(75);
        }

        for (let i = 0; i < str10.length; ++i) {      //тест на легит
            let c = str10[i];
            if (c === '\n') c = '<br/>';
            document.body.innerHTML += c;
            await delay(75);
        }
    }
    main();
</script>
</body>
</html>

Ответы (2 шт):

Автор решения: Dev18

Данный ответ не учавствует в конкурсе, но я всё же оставлю здесь этот вариант караоке на JavaScript.

Ознакомившись с разными подходами (1, 2) и немного их изменив, вот рабочее решение, в котором слова песни подсвечиваются синхронно с музыкой, создавая эффект «караоке».

  • Берём текст с таймкодами (формат LRC).
  • Разбиваем каждую строку по буквам в отдельные <span>.
  • Когда аудио играет — по currentTime узнаём активную строку и сколько символов уже прошло.
  • По мере времени включаем класс .on для первых N букв → получается подсветка «караоке?эффект».

(function() {
  const audio = document.getElementById('audio');
  const playBtn = document.getElementById('playBtn');
  const ul = document.getElementById('lyrics');
  const progress = document.getElementById('progress');
  const loaderBox = document.getElementById('loaderBox');

  // ВСТРОЕННЫЙ LRC (можно заменить на свой)
  const LRC_TEXT = `
[00:13.04]Калинка, калинка, калинка моя!
[00:21.86]В саду ягода малинка, малинка моя!
[00:27.23]Калинка, калинка, калинка моя!
[00:31.28]В саду ягода малинка, малинка моя!
[00:39.06]
[00:40.40]Под сосною, под зелёною,
[00:42.99]Спать положите вы меня!
[00:46.92]Ай-люли, люли, ай-люли, люли,
[00:52.60]Спать положите вы меня!
[01:13.71]
[01:15.16]Калинка, калинка, калинка моя!
[01:22.02]В саду ягода малинка, малинка моя!
`;

  // --- LRC parser ---
  function parseLRC(text) {
    const lines = [];
    text.split(/\r?\n/).forEach(raw => {
      const s = raw.trim();
      if (!s) return;
      const tags = [...s.matchAll(/\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g)];
      if (!tags.length) return;
      const txt = s.replace(/\[(?:\d{1,2}):\d{2}(?:\.\d{1,3})?\]+/g, '').trim();
      for (const t of tags) {
        const min = parseInt(t[1], 10) || 0;
        const sec = parseInt(t[2], 10) || 0;
        const frac = t[3] || '';
        const val = min * 60 + sec + (parseInt(frac || '0', 10) / Math.pow(10, frac.length || 1));
        lines.push({
          start: val,
          text: txt
        });
      }
    });
    lines.sort((a, b) => a.start - b.start);
    for (let i = 0; i < lines.length; i++) {
      const cur = lines[i],
        nxt = lines[i + 1];
      cur.end = nxt ? (nxt.start - 0.04) : (cur.start + 3);
    }
    return lines;
  }

  let LINES = parseLRC(LRC_TEXT);

  // --- Build DOM by chars ---
  const lineNodes = []; // { li, chars[] }
  function buildDOM() {
    ul.innerHTML = '';
    lineNodes.length = 0;
    LINES.forEach(({
      text
    }) => {
      const li = document.createElement('li');
      li.className = 'line';
      const wrap = document.createElement('span');
      wrap.className = 'chars';
      for (const ch of text) {
        const span = document.createElement('span');
        span.className = 'char';
        if (ch === ' ') span.innerHTML = '&nbsp;';
        else span.textContent = ch;
        wrap.appendChild(span);
      }
      li.appendChild(wrap);
      ul.appendChild(li);
      lineNodes.push({
        li,
        chars: Array.from(wrap.children)
      });
    });
  }
  buildDOM();

  // --- Sync with real audio ---
  let activeIndex = -1,
    scrolledTo = 0;

  function setActive(i) {
    if (i === activeIndex) return;
    if (activeIndex >= 0) lineNodes[activeIndex].li.classList.remove('active');
    lineNodes[i].li.classList.add('active');
    activeIndex = i;
    if (i >= 3 && i > scrolledTo) {
      lineNodes[i].li.scrollIntoView({
        behavior: 'smooth',
        block: 'center'
      });
      scrolledTo = i;
    }
  }

  function update(t) {
    let i = -1;
    for (let k = 0; k < LINES.length; k++) {
      const L = LINES[k];
      if (t >= L.start && t <= L.end) {
        i = k;
        break;
      }
    }
    if (i === -1) return;

    setActive(i);

    const L = LINES[i];
    const chars = lineNodes[i].chars;

    const eps = 0.06;
    if (t >= L.end - eps) {
      for (let k = 0; k < chars.length; k++) chars[k].classList.add('on');
      return;
    }

    const dur = Math.max(0.001, L.end - L.start);
    const ratio = Math.min(1, Math.max(0, (t - L.start) / dur));
    let count = Math.ceil(ratio * chars.length);
    if (count > chars.length) count = chars.length;

    for (let k = 0; k < chars.length; k++) {
      chars[k].classList.toggle('on', k < count);
    }
  }

  // кольцо до первой строки
  function tickProgress(t) {
    const r = 54,
      circ = 2 * Math.PI * r;
    const first = LINES[0]?.start ?? 0.001;
    const ratio = Math.max(0, Math.min(1, t / first));
    progress.style.strokeDashoffset = String(circ * (1 - ratio));
    if (t >= first) loaderBox.style.display = 'none';
  }

  audio.addEventListener('timeupdate', () => {
    const t = audio.currentTime || 0;
    update(t);
    tickProgress(t);
  });
  audio.addEventListener('seeked', () => update(audio.currentTime || 0));
  audio.addEventListener('loadedmetadata', () => tickProgress(0));

  // надёжный запуск (обход блокировки автоплея)
  async function safePlay() {
    playBtn.disabled = true;
    try {
      await audio.play();
    } catch (e) {
      console.log('play():', e.name, e.message);
    }
  }
  playBtn.addEventListener('click', safePlay);
  window.addEventListener('touchend', safePlay, {
    once: true,
    passive: true
  });
  window.addEventListener('pointerup', () => {
    if (audio.paused) safePlay();
  }, {
    once: true,
    passive: true
  });

  audio.addEventListener('play', () => playBtn.disabled = true);
  audio.addEventListener('pause', () => playBtn.disabled = false);
})();
:root {
  --bg: #1c1c1c;
  --muted: #9aa0a6;
  --on: #ffffff;
  --off: #6f7781;
  --accent: #f77a52;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--bg);
  color: #fff;
  font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Arial;
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  gap: 10px;
  align-items: center;
  justify-content: center;
  padding: 10px 16px;
  background: rgba(28, 28, 28, .9);
  backdrop-filter: blur(4px);
  box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
  flex-wrap: wrap;
}

.audio {
  width: min(900px, 95vw);
}

.btn {
  appearance: none;
  border: 0;
  border-radius: 10px;
  padding: 10px 14px;
  background: #fff;
  color: #000;
  font-weight: 600;
  cursor: pointer;
}

.btn:disabled {
  opacity: .5;
  cursor: not-allowed;
}

.wrap {
  max-width: 1000px;
  margin: 0 auto;
  padding: 16px;
}

.hint {
  color: #c7cbd1;
  text-align: center;
  margin: 8px 0 16px;
}

.lyrics {
  list-style: none;
  margin: 16px auto 96px;
  padding: 0 12px;
  display: block;
}

.line {
  margin: 16px 0;
  font-size: 28px;
  color: var(--muted);
  padding: 12px 16px;
  border-radius: 12px;
  transition: background-color .25s, transform .25s;
}

.line.active {
  background: #000;
  box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
  transform: scale(1.01);
}

.chars {
  display: inline-block;
}

.char {
  color: var(--off);
  transition: color .05s linear;
}

.char.on {
  color: var(--on);
}

.progressbox {
  position: fixed;
  inset: auto 0 24px 0;
  display: flex;
  justify-content: center;
  pointer-events: none;
}

.progressbar {
  background: #fff;
  border-radius: 16px;
  padding: 16px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
}

@media (max-width:480px) {
  .line {
    font-size: 22px
  }
}
<div class="topbar">
  <button id="playBtn" class="btn">▶️ Старт</button>
  <audio id="audio" class="audio" preload="auto" controls>
      <!-- MP3-транскод «Калинки» с Wikimedia Commons (public domain) -->
      <source src="https://upload.wikimedia.org/wikipedia/commons/transcoded/6/6a/Kalinka.ogg/Kalinka.ogg.mp3" type="audio/mpeg">
      Ваш браузер не поддерживает аудио.
    </audio>
</div>

<div class="wrap">
  <div class="hint">Запусти трек кнопкой «Старт». Подсветка идёт <b>по буквам</b>. В этом демо LRC встроен в код.</div>
  <ul id="lyrics" class="lyrics"></ul>
</div>

<!-- Кольцо до первой строки -->
<div class="progressbox" id="loaderBox" aria-hidden="true">
  <svg class="progressbar" height="120" viewBox="0 0 120 120" role="img" aria-label="Загрузка">
      <circle cx="60" cy="60" r="54" fill="none" stroke="#e6e6e6" stroke-width="12"></circle>
      <circle id="progress" cx="60" cy="60" r="54" fill="none" stroke="var(--accent)" stroke-width="12"
              stroke-dasharray="339.292" stroke-dashoffset="339.292"></circle>
    </svg>
</div>


результат сниппета у нас на ru.SO => Google Chrome Version 140.0.7339.208 (Build officiel) (64 bits)

введите сюда описание изображения

→ Ссылка
Автор решения: Опан

Вот еще, надеюсь, пригодится. Дело в том, что промисы обычно используют для общения с сервером, а для получения эффектов анимации лучше использовать setInterval():

const audio = document.getElementById("audio");
var index = 0;
var rty = true;
const alltext = [
     [27, "Суд над таксистом - легендарная песня"],
     [29, "Спрашивал таксиста колега по работе"],
     [32, "Почему тебя не было с прошлой субботы?"],
     [35, "Отвечал он - я вез одну подругу среди ночи,"],
     [38, "И к тому же, я тогда уставший был очень."],
     [41, "А она оказалась стерва суперэкстра"],
     [43, "Обвинила меня в домогательстве секса."],
     [46, "А сама-то на себя хоть в зеркало глядела"],
     [49, "Как коробка передач ее лицо и все тело."],
     [52, "Эх, баранка, жизнь моя жестянка,"],
     [54, "Лучше б из болота тащил я бегемота."],
     [57, "Луче б был священником, исповеди слушал,"],
     [71, "Или губернатором штата Массачусетс."],
     [74, "А потом состоялся суд арбитражный,"],
     [77, "И на заседание тоскали меня дважды."],
     [79, "Выписали штраф размером с зарплату."],
     [82, "Вот такая была за халатность расплата."],
     [85, "Эх, баранка, жизнь моя жестянка,"],
     [88, "Лучше б из болота тащил я бегемота."],
     [90, "Луче был бы батюшкой, исповеди слушал,"],
     [115, "Или губернатором штата Массачусетс."],
     [118, "Но на этом все не закончилось, конечно,"],
     [120, "Должен был последовать какой-то форсмажор."],
     [123, "А быть может форсминор, так будет лучше, наверно."],
     [126, "Короче говоря, когда прокурор"],
     [129, "Увидел потерпевшую своими глазами -"],
     [131, "Это превзошло все его ожиданья!"],
     [134, "Он добавил мне штраф еще две зарплаты"],
     [138, "За вождение такси в нетрезвом состояньи."],
     [141, "Эх, баранка, жизнь моя жестянка,"],
     [143, "Лучше б из болота тащил я бегемота."],
     [146, "Луче был бы батюшкой, исповеди слушал,"],
     [148, "Или губернатором штата Массачусетс."],
     [151, "Эх, баранка, жизнь моя жестянка,"],
     [154, "Лучше б из болота тащил я бегемота."],
     [157, "Луче был бы батюшкой, исповеди слушал,"],
     [audio.duration, "Или губернатором штата Массачусетс."]
]
  audio.ontimeupdate = function(){
     if(audio.currentTime >= alltext[index][0]){
        rty = false;
        var index2 = 0;
        var interval = setInterval(function(){
           if(rty == false) lirics.innerHTML += alltext[index][1][index2];
           index2 ++;
           if(index2 >= alltext[index][1].length){
              clearInterval(interval);
              lirics.innerHTML += "<br />";
              rty = true;
              index2 = 0;
           }
        }, 45);
        index ++;
     }
  }
<audio id=audio 
   src="http://mysynthesizer.github.io/index.hetemeel/track2.mp3"
   controls>
</audio>
<div id=lirics></div>

→ Ссылка