<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>智能音乐节奏训练系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #FFB6C1 0%, #FF69B4 100%);
min-height: 100vh;
padding-bottom: 160px;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.action-container {
max-width: 1200px;
margin: 2rem auto;
padding: 20px;
}
.action-card {
background: rgba(255, 255, 255, 0.98);
border-radius: 15px;
padding: 25px;
box-shadow: 0 8px 15px rgba(0,0,0,0.1);
margin-bottom: 20px;
backdrop-filter: blur(10px);
}
.control-panel {
background: rgba(255, 255, 255, 0.98);
position: fixed;
bottom: 0;
width: 100%;
padding: 20px;
box-shadow: 0 -5px 15px rgba(0,0,0,0.1);
}
.beat-indicator {
width: 50px;
height: 50px;
border-radius: 50%;
background: #ff4757;
position: absolute;
left: 50%;
transform: translateX(-50%);
top: -25px;
transition: all 0.1s ease;
}
.config-item {
margin: 0 15px;
}
.beat-card {
animation: beat 0.3s ease-out;
transition: transform 0.2s;
}
@keyframes beat {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.ms-indicator::after {
content: "ms";
margin-left: 3px;
color: #666;
}
</style>
</head>
<body>
<div class="action-container">
<div class="action-card">
<h2 class="mb-4">🎵 选择音乐文件</h2>
<input type="file" id="musicUpload" accept="audio/*" class="form-control">
</div>
<div class="action-card">
<div class="row g-4">
<div class="col-md-3 config-item">
<label class="form-label">🎚 BPM速度</label>
<input type="number" id="bpmInput" class="form-control"
min="40" max="240" value="120">
</div>
<div class="col-md-3 config-item">
<label class="form-label">🎼 节拍类型</label>
<select id="timeSignature" class="form-select">
<option value="4/4">4/4 拍</option>
<option value="3/4">3/4 拍</option>
<option value="6/8">6/8 拍</option>
</select>
</div>
<div class="col-md-3 config-item">
<label class="form-label">⏱ 延迟补偿</label>
<div class="input-group">
<input type="number" id="delayInput" class="form-control"
min="0" max="10000" step="1" value="0">
<span class="input-group-text ms-indicator"></span>
</div>
</div>
<div class="col-md-3 config-item">
<label class="form-label">📊 当前节奏</label>
<div id="rhythmStatus" class="form-control">120 BPM | 4/4 拍</div>
</div>
</div>
</div>
<div class="action-card">
<h3 class="mb-4">🎮 当前动作组合</h3>
<div id="actionContainer" class="row g-3"></div>
</div>
</div>
<div class="control-panel">
<div class="beat-indicator"></div>
<div class="container d-flex align-items-center">
<div class="flex-grow-1">
<audio id="audioPlayer" class="w-100" controls></audio>
</div>
</div>
</div>
<script>
class RhythmScheduler {
constructor() {
this.audioContext = null;
this.audioSource = null;
this.schedulerId = null;
this.nextBeatTime = 0;
this.currentBeat = 0;
this.actionGroupIndex = 0;
this.currentBPM = 120;
this.currentTimeSig = '4/4';
this.globalDelay = 0;
this.isScheduling = false;
this.lastFrameTime = 0;
}
init(audioElement) {
this.audioElement = audioElement;
this.setupEventListeners();
}
setupEventListeners() {
document.getElementById('bpmInput').addEventListener('change', this.handleBPMChange.bind(this));
document.getElementById('timeSignature').addEventListener('change', this.handleTimeSignatureChange.bind(this));
document.getElementById('delayInput').addEventListener('input', this.handleDelayChange.bind(this));
document.getElementById('musicUpload').addEventListener('change', this.handleFileUpload.bind(this));
this.audioElement.addEventListener('play', this.handlePlay.bind(this));
this.audioElement.addEventListener('pause', this.handlePause.bind(this));
document.addEventListener('click', () => {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
});
}
handleBPMChange(e) {
this.currentBPM = Math.min(240, Math.max(40, e.target.value));
this.updateRhythmDisplay();
this.restartScheduler();
}
handleTimeSignatureChange(e) {
this.currentTimeSig = e.target.value;
this.updateRhythmDisplay();
this.restartScheduler();
}
handleDelayChange(e) {
this.globalDelay = parseInt(e.target.value) || 0;
if (!this.audioElement.paused) this.restartScheduler();
}
handleFileUpload(e) {
const file = e.target.files[0];
const url = URL.createObjectURL(file);
this.audioElement.src = url;
}
handlePlay() {
this.initAudioContext();
this.restartScheduler();
}
handlePause() {
this.stopScheduler();
}
initAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (this.audioSource) {
this.audioSource.disconnect();
}
this.audioSource = this.audioContext.createMediaElementSource(this.audioElement);
this.audioSource.connect(this.audioContext.destination);
this.audioElement._startTimestamp = performance.now();
this.audioElement.startTime = this.audioContext.currentTime;
}
scheduler = (timestamp) => {
if (!this.audioContext || this.audioElement.paused) {
this.isScheduling = false;
return;
}
const elapsed = timestamp - this.lastFrameTime;
const interval = this.calculateInterval();
const lookahead = Math.min(50, interval / 2);
const now = this.audioContext.currentTime;
const targetTime = now + lookahead / 1000;
while (this.nextBeatTime < targetTime) {
const delaySeconds = this.globalDelay / 1000;
const actualBeatTime = this.nextBeatTime + delaySeconds;
if (actualBeatTime >= this.audioElement.startTime) {
if (this.currentBeat % this.getBeatsPerMeasure() === 0) {
this.showActionGroup();
}
this.updateBeatIndicator(this.currentBeat);
}
this.nextBeatTime += interval / 1000;
this.currentBeat++;
}
if (elapsed > 16) {
this.lastFrameTime = timestamp;
requestAnimationFrame(this.scheduler);
} else {
setTimeout(() => {
requestAnimationFrame(this.scheduler);
}, 1);
}
}
showActionGroup() {
const container = document.getElementById('actionContainer');
container.innerHTML = '';
const actionGroup = this.actions.slice(this.actionGroupIndex * 4, (this.actionGroupIndex + 1) * 4);
this.actionGroupIndex = (this.actionGroupIndex + 1) % 2;
actionGroup.forEach(action => {
const col = document.createElement('div');
col.className = 'col-3';
col.innerHTML = `
<div class="p-3 text-center rounded beat-card"
style="background: ${action.color}; color: white">
<div style="font-size: 2.5rem">${action.emoji}</div>
<div class="mt-2">${action.text}</div>
</div>
`;
container.appendChild(col);
});
}
calculateInterval() {
const [upper, lower] = this.currentTimeSig.split('/').map(Number);
const quarterNote = (60 / this.currentBPM) * 1000;
return lower === 8 ? quarterNote * 0.5 : quarterNote;
}
getBeatsPerMeasure() {
return parseInt(this.currentTimeSig.split('/')[0]);
}
updateRhythmDisplay() {
document.getElementById('rhythmStatus').textContent =
`${this.currentBPM} BPM | ${this.currentTimeSig} 拍`;
}
updateBeatIndicator(beat) {
const indicator = document.querySelector('.beat-indicator');
const isStrongBeat = (beat % this.getBeatsPerMeasure()) === 0;
indicator.style.background = isStrongBeat ? '#ff4757' : '#ffa94d';
indicator.style.transform = `scale(${isStrongBeat ? 1.2 : 0.8}) translateX(-50%)`;
}
restartScheduler() {
if (this.isScheduling) return;
this.isScheduling = true;
this.lastFrameTime = performance.now();
this.nextBeatTime = this.audioContext ? this.audioContext.currentTime : 0;
this.currentBeat = 0;
requestAnimationFrame(this.scheduler);
}
stopScheduler() {
this.isScheduling = false;
if (this.schedulerId) {
cancelAnimationFrame(this.schedulerId);
}
}
}
// 系统初始化
document.addEventListener('DOMContentLoaded', () => {
const scheduler = new RhythmScheduler();
scheduler.actions = [
{ emoji: "👋", text: "拍手", color: "#FF7F50" },
{ emoji: "🦵", text: "踢腿", color: "#40E0D0" },
{ emoji: "👋", text: "拍手", color: "#FF69B4" },
{ emoji: "🦵", text: "踢脚", color: "#7B68EE" },
{ emoji: "👋", text: "拍手", color: "#32CD32" },
{ emoji: "🦵", text: "拍腿", color: "#FFD700" },
{ emoji: "👋", text: "拍手", color: "#FF4500" },
{ emoji: "🦵", text: "拍腿", color: "#6A5ACD" }
];
scheduler.init(document.getElementById('audioPlayer'));
});
</script>
</body>
</html>
index.html
index.html