<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>节奏音乐生成器</title>
</head>
<body>
<div class="container">
<!-- 标题栏 -->
<div class="header">
<h1>🎵 节奏音乐生成器</h1>
<div class="header-buttons">
<button class="random-btn" id="randomBtn">🎲 随机</button>
<button class="play-btn" id="playBtn">▶ 播放</button>
<button class="stop-btn" id="stopBtn" disabled>⏹ 停止</button>
</div>
</div>
<!-- 主内容 -->
<div class="main-content">
<!-- 左侧面板 -->
<div class="left-panel">
<!-- 参数设置 -->
<div class="card">
<div class="card-title">🎛️ 参数设置</div>
<div class="controls-grid">
<div class="control-group">
<label>音阶</label>
<select id="scale">
<option value="major">大调</option>
<option value="minor">小调</option>
<option value="pentatonic">五声</option>
<option value="blues">布鲁斯</option>
<option value="dorian">多利亚</option>
</select>
</div>
<div class="control-group">
<label>根音</label>
<select id="rootNote">
<option value="C">C</option>
<option value="D">D</option>
<option value="E">E</option>
<option value="F">F</option>
<option value="G">G</option>
<option value="A">A</option>
<option value="B">B</option>
</select>
</div>
<div class="control-group">
<label>风格</label>
<select id="style">
<option value="electronic">电子</option>
<option value="ambient">氛围</option>
<option value="funk">放克</option>
<option value="jazz">爵士</option>
<option value="rock">摇滚</option>
</select>
</div>
</div>
</div>
<!-- 滑块控制 -->
<div class="card">
<div class="sliders-grid">
<div class="slider-group">
<div class="slider-header">
<span>⏱️ 速度</span>
<span id="bpmValue">120</span>
</div>
<input type="range" id="bpm" min="60" max="180" value="120">
</div>
<div class="slider-group">
<div class="slider-header">
<span>🔊 音量</span>
<span id="volumeValue">40%</span>
</div>
<input type="range" id="volume" min="0" max="100" value="40">
</div>
<div class="slider-group">
<div class="slider-header">
<span>🎲 随机度</span>
<span id="randomnessValue">50%</span>
</div>
<input type="range" id="randomness" min="0" max="100" value="50">
</div>
</div>
</div>
<!-- 音轨开关 -->
<div class="card">
<div class="card-title">🎚️ 音轨</div>
<div class="tracks-grid">
<div class="track active" data-track="melody">
<div class="track-icon">🎵</div>
<div class="track-name">旋律</div>
</div>
<div class="track active" data-track="chord">
<div class="track-icon">🎹</div>
<div class="track-name">和弦</div>
</div>
<div class="track active" data-track="bass">
<div class="track-icon">🎸</div>
<div class="track-name">低音</div>
</div>
<div class="track active" data-track="drums">
<div class="track-icon">🥁</div>
<div class="track-name">鼓点</div>
</div>
<div class="track active" data-track="arp">
<div class="track-icon">✨</div>
<div class="track-name">琶音</div>
</div>
</div>
</div>
<!-- 节拍显示 -->
<div class="card">
<div class="beat-section">
<div class="beat-indicator">
<div class="beat downbeat" data-beat="0"></div>
<div class="beat" data-beat="1"></div>
<div class="beat" data-beat="2"></div>
<div class="beat" data-beat="3"></div>
<div class="beat downbeat" data-beat="4"></div>
<div class="beat" data-beat="5"></div>
<div class="beat" data-beat="6"></div>
<div class="beat" data-beat="7"></div>
<div class="beat downbeat" data-beat="8"></div>
<div class="beat" data-beat="9"></div>
<div class="beat" data-beat="10"></div>
<div class="beat" data-beat="11"></div>
<div class="beat downbeat" data-beat="12"></div>
<div class="beat" data-beat="13"></div>
<div class="beat" data-beat="14"></div>
<div class="beat" data-beat="15"></div>
</div>
<div class="chord-display" id="chordDisplay">--</div>
</div>
</div>
<!-- 钢琴键盘 -->
<div class="card piano-container">
<div class="piano" id="piano"></div>
</div>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<!-- 频谱可视化 -->
<div class="card visualizer-section">
<div class="card-title">📊 频谱</div>
<canvas id="visualizer"></canvas>
</div>
<!-- 多波形显示 -->
<div class="card waveform-section">
<div class="card-title">🌊 波形</div>
<div class="waveform-row">
<div class="waveform-item">
<span class="waveform-label">低频</span>
<canvas class="waveform-canvas" id="waveformLow"></canvas>
</div>
<div class="waveform-item">
<span class="waveform-label">中频</span>
<canvas class="waveform-canvas" id="waveformMid"></canvas>
</div>
<div class="waveform-item">
<span class="waveform-label">高频</span>
<canvas class="waveform-canvas" id="waveformHigh"></canvas>
</div>
</div>
</div>
<!-- 信息显示 -->
<div class="card">
<div class="info-grid">
<div class="info-item">
<div class="info-value" id="infoBar">0</div>
<div class="info-label">小节</div>
</div>
<div class="info-item">
<div class="info-value" id="infoBeat">0</div>
<div class="info-label">拍子</div>
</div>
<div class="info-item">
<div class="info-value" id="infoKey">C</div>
<div class="info-label">调性</div>
</div>
<div class="info-item">
<div class="info-value" id="infoNotes">0</div>
<div class="info-label">音符数</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100vh;
overflow: hidden;
}
body {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
font-family: 'Segoe UI', sans-serif;
color: white;
padding: 10px;
display: flex;
flex-direction: column;
}
.container {
flex: 1;
display: flex;
flex-direction: column;
max-width: 1200px;
margin: 0 auto;
width: 100%;
gap: 8px;
}
/* 标题栏 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 15px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
}
h1 {
font-size: 1.3rem;
background: linear-gradient(45deg, #e94560, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-buttons {
display: flex;
gap: 8px;
}
.header-buttons button {
padding: 8px 20px;
font-size: 0.85rem;
border: none;
border-radius: 20px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.play-btn {
background: linear-gradient(45deg, #e94560, #ff6b6b);
color: white;
}
.stop-btn {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid #e94560 !important;
}
.random-btn {
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
}
button:hover:not(:disabled) {
transform: scale(1.05);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 主内容区 */
.main-content {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
min-height: 0;
}
/* 左侧面板 */
.left-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 右侧面板 */
.right-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 卡片样式 */
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 12px;
}
.card-title {
font-size: 0.75rem;
color: #888;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
}
/* 控件网格 */
.controls-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.control-group label {
font-size: 0.7rem;
color: #aaa;
}
select {
padding: 6px 8px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 0.8rem;
cursor: pointer;
}
select option {
background: #1a1a2e;
}
/* 滑块控件 */
.sliders-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.slider-header {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
}
.slider-header span:first-child {
color: #aaa;
}
.slider-header span:last-child {
color: #e94560;
font-weight: bold;
}
input[type="range"] {
width: 100%;
height: 6px;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #e94560;
}
/* 音轨开关 */
.tracks-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.track {
background: rgba(255, 255, 255, 0.05);
padding: 10px 5px;
border-radius: 10px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.track.active {
border-color: #e94560;
background: rgba(233, 69, 96, 0.2);
}
.track-icon {
font-size: 1.3rem;
}
.track-name {
font-size: 0.65rem;
margin-top: 3px;
color: #aaa;
}
/* 节拍指示器 */
.beat-section {
display: flex;
align-items: center;
gap: 15px;
}
.beat-indicator {
display: flex;
gap: 6px;
flex: 1;
}
.beat {
flex: 1;
height: 20px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
transition: all 0.1s;
}
.beat.active {
background: #e94560;
box-shadow: 0 0 15px #e94560;
}
.beat.downbeat {
background: rgba(255, 255, 255, 0.2);
}
.chord-display {
font-size: 1.2rem;
font-weight: bold;
color: #e94560;
min-width: 60px;
text-align: center;
}
/* 可视化区域 */
.visualizer-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
canvas {
width: 100%;
flex: 1;
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
}
/* 钢琴键盘 */
.piano-container {
display: flex;
justify-content: center;
align-items: flex-end;
height: 50px;
padding: 5px 0;
}
.piano {
display: flex;
position: relative;
height: 45px;
}
.key {
width: 24px;
height: 40px;
background: linear-gradient(to bottom, #eee, #fff);
border: 1px solid #999;
border-radius: 0 0 4px 4px;
transition: all 0.1s;
position: relative;
}
.key.black {
width: 16px;
height: 26px;
background: linear-gradient(to bottom, #333, #000);
margin-left: -8px;
margin-right: -8px;
z-index: 1;
border-radius: 0 0 3px 3px;
}
.key.playing {
background: linear-gradient(to bottom, #e94560, #ff6b6b) !important;
transform: scaleY(0.98);
}
/* 信息显示 */
.info-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.info-item {
background: rgba(0, 0, 0, 0.2);
padding: 8px;
border-radius: 8px;
text-align: center;
}
.info-value {
font-size: 1.1rem;
font-weight: bold;
color: #e94560;
}
.info-label {
font-size: 0.65rem;
color: #666;
margin-top: 2px;
}
/* 波形显示区 */
.waveform-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 5px;
min-height: 0;
}
.waveform-row {
flex: 1;
display: flex;
gap: 5px;
min-height: 0;
}
.waveform-item {
flex: 1;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
position: relative;
overflow: hidden;
}
.waveform-label {
position: absolute;
top: 3px;
left: 6px;
font-size: 0.6rem;
color: #666;
}
.waveform-canvas {
width: 100%;
height: 100%;
}
/* 响应式 */
@media (max-width: 900px) {
.main-content {
grid-template-columns: 1fr;
}
.controls-grid,
.sliders-grid {
grid-template-columns: repeat(2, 1fr);
}
.tracks-grid {
grid-template-columns: repeat(5, 1fr);
}
}
class RhythmicMusicGenerator {
constructor() {
this.audioContext = null;
this.masterGain = null;
this.analyser = null;
this.isPlaying = false;
this.currentStep = 0;
this.totalNotes = 0;
this.currentBar = 0;
this.scales = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
pentatonic: [0, 2, 4, 7, 9],
blues: [0, 3, 5, 6, 7, 10],
dorian: [0, 2, 3, 5, 7, 9, 10]
};
this.chordProgressions = {
major: [[0, 4, 7], [5, 9, 12], [7, 11, 14], [0, 4, 7]],
minor: [[0, 3, 7], [5, 8, 12], [7, 10, 14], [0, 3, 7]]
};
this.rhythmPatterns = {
electronic: {
kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
hihat: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
melody: [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0]
},
ambient: {
kick: [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
snare: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
hihat: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0],
melody: [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0]
},
funk: {
kick: [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0],
hihat: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
melody: [1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0]
},
jazz: {
kick: [1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0],
snare: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0],
hihat: [1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0],
melody: [1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0]
},
rock: {
kick: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
hihat: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],
melody: [1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0]
}
};
this.noteToFreq = {
'C': 261.63, 'D': 293.66, 'E': 329.63, 'F': 349.23,
'G': 392.00, 'A': 440.00, 'B': 493.88
};
this.tracks = { melody: true, chord: true, bass: true, drums: true, arp: true };
this.settings = { bpm: 120, volume: 0.4, randomness: 0.5, scale: 'major', rootNote: 'C', style: 'electronic' };
this.currentChord = 0;
this.melodyHistory = [];
this.activeOscillators = [];
this.initUI();
this.initPiano();
}
init() {
if (this.audioContext) return;
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioContext.createGain();
this.masterGain.gain.value = this.settings.volume;
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
this.compressor = this.audioContext.createDynamicsCompressor();
this.masterGain.connect(this.compressor);
this.compressor.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.startVisualization();
}
getFrequency(semitones, octave = 4) {
const rootFreq = this.noteToFreq[this.settings.rootNote];
return rootFreq * Math.pow(2, octave - 4) * Math.pow(2, semitones / 12);
}
getScaleNote(degree, octave = 4) {
const scale = this.scales[this.settings.scale];
const octaveOffset = Math.floor(degree / scale.length);
const noteIndex = ((degree % scale.length) + scale.length) % scale.length;
return this.getFrequency(scale[noteIndex], octave + octaveOffset);
}
createSynth(type, freq, startTime, duration, volume = 0.3) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
const filter = this.audioContext.createBiquadFilter();
osc.type = type;
osc.frequency.value = freq;
filter.type = 'lowpass';
filter.frequency.value = 2000;
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(volume, startTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
osc.start(startTime);
osc.stop(startTime + duration);
this.totalNotes++;
}
createPad(freq, startTime, duration, volume = 0.12) {
['sine', 'triangle'].forEach((type, i) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = type;
osc.frequency.value = freq * (1 + i * 0.003);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(volume, startTime + 0.2);
gain.gain.setValueAtTime(volume, startTime + duration - 0.2);
gain.gain.linearRampToValueAtTime(0.001, startTime + duration);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(startTime);
osc.stop(startTime + duration);
});
}
createBass(freq, startTime, duration, volume = 0.35) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
const filter = this.audioContext.createBiquadFilter();
osc.type = 'sawtooth';
osc.frequency.value = freq;
filter.type = 'lowpass';
filter.frequency.value = 400;
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(volume, startTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
osc.start(startTime);
osc.stop(startTime + duration);
}
createKick(startTime, volume = 0.5) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.frequency.setValueAtTime(150, startTime);
osc.frequency.exponentialRampToValueAtTime(40, startTime + 0.1);
gain.gain.setValueAtTime(volume, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.3);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(startTime);
osc.stop(startTime + 0.3);
}
createSnare(startTime, volume = 0.25) {
const bufferSize = this.audioContext.sampleRate * 0.15;
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = this.audioContext.createBufferSource();
noise.buffer = buffer;
const filter = this.audioContext.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 1000;
const gain = this.audioContext.createGain();
gain.gain.setValueAtTime(volume, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.15);
noise.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
noise.start(startTime);
noise.stop(startTime + 0.15);
}
createHihat(startTime, volume = 0.12) {
const bufferSize = this.audioContext.sampleRate * 0.04;
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = this.audioContext.createBufferSource();
noise.buffer = buffer;
const filter = this.audioContext.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 7000;
const gain = this.audioContext.createGain();
gain.gain.setValueAtTime(volume, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.04);
noise.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
noise.start(startTime);
noise.stop(startTime + 0.04);
}
scheduleBar(barStartTime) {
const patterns = this.rhythmPatterns[this.settings.style];
const sixteenthNote = (60 / this.settings.bpm) / 4;
const randomness = this.settings.randomness;
if (this.currentStep % 16 === 0) {
this.currentChord = Math.floor(Math.random() * 4);
this.currentBar++;
}
for (let i = 0; i < 16; i++) {
const time = barStartTime + i * sixteenthNote;
setTimeout(() => {
this.updateBeatIndicator(i);
document.getElementById('infoBeat').textContent = (i % 4) + 1;
document.getElementById('infoBar').textContent = this.currentBar;
document.getElementById('infoNotes').textContent = this.totalNotes;
}, (time - this.audioContext.currentTime) * 1000);
if (this.tracks.drums) {
if (patterns.kick[i] && Math.random() > randomness * 0.3) this.createKick(time);
if (patterns.snare[i] && Math.random() > randomness * 0.2) this.createSnare(time);
if (patterns.hihat[i] && Math.random() > randomness * 0.4) this.createHihat(time);
}
if (this.tracks.bass && i % 4 === 0) {
const bassNote = this.getScaleNote(this.currentChord * 2, 2);
this.createBass(bassNote, time, sixteenthNote * 3);
}
if (this.tracks.chord && i % 8 === 0) {
const progression = this.chordProgressions[this.settings.scale === 'minor' ? 'minor' : 'major'];
progression[this.currentChord % 4].forEach(semitone => {
this.createPad(this.getFrequency(semitone, 3), time, sixteenthNote * 8);
});
setTimeout(() => this.updateChordDisplay(this.currentChord),
(time - this.audioContext.currentTime) * 1000);
}
if (this.tracks.melody && patterns.melody[i] && Math.random() > randomness * 0.5) {
const melodyNote = this.generateMelodyNote();
this.createSynth('sawtooth', this.getScaleNote(melodyNote, 5), time, sixteenthNote * 2, 0.18);
setTimeout(() => this.highlightPianoKey(melodyNote),
(time - this.audioContext.currentTime) * 1000);
}
if (this.tracks.arp && i % 2 === 0 && Math.random() > 0.4) {
const arpNote = (i / 2) % 4;
this.createSynth('triangle', this.getScaleNote(arpNote, 4), time, sixteenthNote, 0.12);
}
}
this.currentStep += 16;
}
generateMelodyNote() {
const scale = this.scales[this.settings.scale];
let note;
if (this.melodyHistory.length === 0 || Math.random() < this.settings.randomness) {
note = Math.floor(Math.random() * scale.length);
} else {
const lastNote = this.melodyHistory[this.melodyHistory.length - 1];
const step = Math.floor(Math.random() * 3) - 1;
note = Math.max(0, Math.min(scale.length - 1, lastNote + step));
}
this.melodyHistory.push(note);
if (this.melodyHistory.length > 4) this.melodyHistory.shift();
return note;
}
start() {
this.init();
if (this.isPlaying) return;
this.isPlaying = true;
this.currentStep = 0;
this.currentBar = 0;
this.totalNotes = 0;
this.melodyHistory = [];
const barDuration = (60 / this.settings.bpm) * 4;
let nextBarTime = this.audioContext.currentTime + 0.1;
const scheduler = () => {
if (!this.isPlaying) return;
while (nextBarTime < this.audioContext.currentTime + 0.5) {
this.scheduleBar(nextBarTime);
nextBarTime += barDuration;
}
this.schedulerId = setTimeout(scheduler, 100);
};
scheduler();
document.getElementById('playBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
}
stop() {
this.isPlaying = false;
if (this.schedulerId) clearTimeout(this.schedulerId);
document.querySelectorAll('.beat').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.key').forEach(k => k.classList.remove('playing'));
document.getElementById('chordDisplay').textContent = '--';
document.getElementById('playBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
}
updateBeatIndicator(beat) {
document.querySelectorAll('.beat').forEach((b, i) => {
b.classList.toggle('active', i === beat);
});
}
updateChordDisplay(chordIndex) {
const names = this.settings.scale === 'minor' ? ['i', 'iv', 'v', 'i'] : ['I', 'IV', 'V', 'I'];
document.getElementById('chordDisplay').textContent = `${this.settings.rootNote}${names[chordIndex % 4]}`;
document.getElementById('infoKey').textContent = this.settings.rootNote;
}
highlightPianoKey(noteIndex) {
const keys = document.querySelectorAll('.key:not(.black)');
keys.forEach(function (k) {
k.classList.remove('playing');
});
const keyIndex = noteIndex % keys.length;
const key = keys[keyIndex];
if (key) {
key.classList.add('playing');
setTimeout(function () {
if (key) {
key.classList.remove('playing');
}
}, 150);
}
}
startVisualization() {
const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');
const resize = () => {
canvas.width = canvas.offsetWidth * 2;
canvas.height = canvas.offsetHeight * 2;
};
resize();
window.addEventListener('resize', resize);
const bufferLength = this.analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
// 波形画布
const waveCanvases = ['waveformLow', 'waveformMid', 'waveformHigh'].map(id => {
const c = document.getElementById(id);
c.width = c.offsetWidth * 2;
c.height = c.offsetHeight * 2;
return { canvas: c, ctx: c.getContext('2d') };
});
const draw = () => {
requestAnimationFrame(draw);
this.analyser.getByteFrequencyData(dataArray);
// 主频谱
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / bufferLength * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height * 0.9;
const hue = (i / bufferLength) * 60 + 340;
ctx.fillStyle = `hsla(${hue}, 80%, 50%, 0.8)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
// 波形显示
const ranges = [[0, 20], [20, 60], [60, bufferLength]];
const colors = ['#e94560', '#667eea', '#4ade80'];
waveCanvases.forEach((wc, idx) => {
const [start, end] = ranges[idx];
wc.ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
wc.ctx.fillRect(0, 0, wc.canvas.width, wc.canvas.height);
wc.ctx.beginPath();
wc.ctx.strokeStyle = colors[idx];
wc.ctx.lineWidth = 2;
const sliceWidth = wc.canvas.width / (end - start);
let wx = 0;
for (let i = start; i < end; i++) {
const v = dataArray[i] / 255;
const y = (1 - v) * wc.canvas.height * 0.9 + wc.canvas.height * 0.05;
if (i === start) wc.ctx.moveTo(wx, y);
else wc.ctx.lineTo(wx, y);
wx += sliceWidth;
}
wc.ctx.stroke();
});
};
draw();
}
initPiano() {
const piano = document.getElementById('piano');
const pattern = [0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0];
for (let i = 0; i < 14; i++) {
const key = document.createElement('div');
key.className = `key ${pattern[i] ? 'black' : ''}`;
piano.appendChild(key);
}
}
initUI() {
document.getElementById('bpm').addEventListener('input', e => {
this.settings.bpm = parseInt(e.target.value);
document.getElementById('bpmValue').textContent = this.settings.bpm;
});
document.getElementById('volume').addEventListener('input', e => {
this.settings.volume = e.target.value / 100;
document.getElementById('volumeValue').textContent = `${e.target.value}%`;
if (this.masterGain) this.masterGain.gain.value = this.settings.volume;
});
document.getElementById('randomness').addEventListener('input', e => {
this.settings.randomness = e.target.value / 100;
document.getElementById('randomnessValue').textContent = `${e.target.value}%`;
});
['scale', 'rootNote', 'style'].forEach(id => {
document.getElementById(id).addEventListener('change', e => {
this.settings[id] = e.target.value;
});
});
document.querySelectorAll('.track').forEach(track => {
track.addEventListener('click', () => {
const name = track.dataset.track;
this.tracks[name] = !this.tracks[name];
track.classList.toggle('active', this.tracks[name]);
});
});
document.getElementById('playBtn').addEventListener('click', () => this.start());
document.getElementById('stopBtn').addEventListener('click', () => this.stop());
document.getElementById('randomBtn').addEventListener('click', () => {
const scales = Object.keys(this.scales);
const styles = Object.keys(this.rhythmPatterns);
const notes = Object.keys(this.noteToFreq);
document.getElementById('scale').value = scales[Math.floor(Math.random() * scales.length)];
document.getElementById('style').value = styles[Math.floor(Math.random() * styles.length)];
document.getElementById('rootNote').value = notes[Math.floor(Math.random() * notes.length)];
document.getElementById('bpm').value = 80 + Math.floor(Math.random() * 80);
document.getElementById('randomness').value = 30 + Math.floor(Math.random() * 50);
['scale', 'style', 'rootNote', 'bpm', 'randomness'].forEach(id => {
document.getElementById(id).dispatchEvent(new Event('input'));
document.getElementById(id).dispatchEvent(new Event('change'));
});
document.querySelectorAll('.track').forEach(track => {
const active = Math.random() > 0.25;
this.tracks[track.dataset.track] = active;
track.classList.toggle('active', active);
});
});
}
}
const generator = new RhythmicMusicGenerator();