Как синхронизировать эффект печатающей машинки с песней (караоке-стиль)
Создаю локальный сайт, который проигрывает песню, параллельно выводя побуквенный вывод текста на экран.
<!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 = ' ';
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>
