629 lines
21 KiB
JavaScript
629 lines
21 KiB
JavaScript
|
|
/* =========================================================
|
|||
|
|
CYBER BEATS — 16-Step Web Audio Davul Makinesi
|
|||
|
|
Tüm sesler SAF Web Audio API ile sentezlenir:
|
|||
|
|
OscillatorNode, GainNode, BiquadFilterNode, AudioBufferSourceNode
|
|||
|
|
(noise buffer "ses dosyası" değil; kod içinde Math.random ile
|
|||
|
|
üretilen örnek tampondur). Hiçbir mp3/wav dosyası kullanılmaz.
|
|||
|
|
|
|||
|
|
- KICK : frekansı hızla düşen sinüs (150Hz -> 30Hz pitch drop)
|
|||
|
|
- SNARE : beyaz gürültü + band-pass + kısa rezonans
|
|||
|
|
- HAT : yüksek frekanslı (high-pass) kesik metalik tiz ses
|
|||
|
|
- SYNTH : melodik kare/üçgen dalga tonu
|
|||
|
|
========================================================= */
|
|||
|
|
(function () {
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
/* ---------- Sabitler & State ---------- */
|
|||
|
|
var INSTRUMENTS = [
|
|||
|
|
{ id: 'kick', label: 'KICK', rowCls: 'kick' },
|
|||
|
|
{ id: 'snare', label: 'SNARE', rowCls: 'snare' },
|
|||
|
|
{ id: 'hat', label: 'HAT', rowCls: 'hat' },
|
|||
|
|
{ id: 'synth', label: 'SYNTH', rowCls: 'synth' }
|
|||
|
|
];
|
|||
|
|
var STEPS = 16;
|
|||
|
|
|
|||
|
|
var audioCtx = null;
|
|||
|
|
var masterGain = null;
|
|||
|
|
var noiseBuffer = null;
|
|||
|
|
|
|||
|
|
var isPlaying = false;
|
|||
|
|
var step = 0; // sıradaki çalınacak adım
|
|||
|
|
var scheduleTimer = null; // lookahead interval id
|
|||
|
|
var visualTimer = null; // UI playhead interval id
|
|||
|
|
var nextNoteTime = 0; // ctx.currentTime cinsinden bir sonraki nota vakti
|
|||
|
|
var currentStepVisual = -1; // görselde en son yanan sütun
|
|||
|
|
var lastScheduledStep = -1;
|
|||
|
|
|
|||
|
|
var bpm = 120;
|
|||
|
|
var BPM_MIN = 80;
|
|||
|
|
var BPM_MAX = 180;
|
|||
|
|
var LOOKAHEAD_MS = 25; // timer tarama aralığı
|
|||
|
|
// 16. adım süresi (sn) = 60 / BPM / 4
|
|||
|
|
function stepDur() { return 60 / bpm / 4; }
|
|||
|
|
|
|||
|
|
// 4 enstrüman satırı x 16 adım boolean matris
|
|||
|
|
var pattern = [];
|
|||
|
|
function initPattern() {
|
|||
|
|
for (var r = 0; r < 4; r++) {
|
|||
|
|
pattern[r] = new Array(STEPS).fill(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SYNTH pentatonik ölçüsünden frekans seçimi için tablo (Hz)
|
|||
|
|
var SYNTH_TABLE = [
|
|||
|
|
220.0, 261.63, 293.66, 329.63, 392.0, 440.0,
|
|||
|
|
523.25, 587.33, 659.25, 783.99, 880.0, 987.77
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/* ---------- Audio kurulum ---------- */
|
|||
|
|
// AudioContext'i oluşturur; master gain ve noise buffer hazırlar.
|
|||
|
|
function initAudio() {
|
|||
|
|
if (audioCtx) return audioCtx;
|
|||
|
|
var Ctor = window.AudioContext || window.webkitAudioContext;
|
|||
|
|
if (!Ctor) { return null; }
|
|||
|
|
audioCtx = new Ctor();
|
|||
|
|
masterGain = audioCtx.createGain();
|
|||
|
|
masterGain.gain.value = 0.85;
|
|||
|
|
masterGain.connect(audioCtx.destination);
|
|||
|
|
noiseBuffer = createNoiseBuffer();
|
|||
|
|
return audioCtx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Kullanıcı etkileşimiyle (klavye/tıklama) context'i aç.
|
|||
|
|
function unlock() {
|
|||
|
|
initAudio();
|
|||
|
|
if (audioCtx && audioCtx.state === 'suspended') {
|
|||
|
|
audioCtx.resume();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Beyaz gürültü tamponu — AudioBufferSourceNode ile çalınır.
|
|||
|
|
function createNoiseBuffer() {
|
|||
|
|
if (!audioCtx) { return null; }
|
|||
|
|
var dur = 1.5; // saniye
|
|||
|
|
var length = Math.floor(audioCtx.sampleRate * dur);
|
|||
|
|
var buffer = audioCtx.createBuffer(1, length, audioCtx.sampleRate);
|
|||
|
|
var data = buffer.getChannelData(0);
|
|||
|
|
for (var i = 0; i < length; i++) {
|
|||
|
|
data[i] = Math.random() * 2 - 1;
|
|||
|
|
}
|
|||
|
|
return buffer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Genel ADSR benzeri env için yardımcı: node'u master'a bağlar.
|
|||
|
|
function makeEnv(peak, attack, decay, time, isExponential) {
|
|||
|
|
var g = audioCtx.createGain();
|
|||
|
|
var z = isExponential ? 0.0001 : 0;
|
|||
|
|
g.gain.setValueAtTime(z, time);
|
|||
|
|
if (isExponential) {
|
|||
|
|
g.gain.exponentialRampToValueAtTime(peak, time + attack);
|
|||
|
|
g.gain.exponentialRampToValueAtTime(0.0001, time + attack + decay);
|
|||
|
|
} else {
|
|||
|
|
g.gain.linearRampToValueAtTime(peak, time + attack);
|
|||
|
|
g.gain.linearRampToValueAtTime(0, time + attack + decay);
|
|||
|
|
}
|
|||
|
|
g.connect(masterGain);
|
|||
|
|
return { node: g, start: time + attack, stopAt: time + attack + decay };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* =========================================================
|
|||
|
|
SES SENTEZ FONKSİYONLARI
|
|||
|
|
========================================================= */
|
|||
|
|
|
|||
|
|
// KICK — 150Hz -> 30Hz hızla düşen sinüs (pitch drop) + sub body
|
|||
|
|
function playKick(time) {
|
|||
|
|
if (!audioCtx) { return; }
|
|||
|
|
var ctx = audioCtx;
|
|||
|
|
var t = time;
|
|||
|
|
|
|||
|
|
// ana sinüs
|
|||
|
|
var osc = ctx.createOscillator();
|
|||
|
|
osc.type = 'sine';
|
|||
|
|
osc.frequency.setValueAtTime(150, t);
|
|||
|
|
osc.frequency.exponentialRampToValueAtTime(30, t + 0.42);
|
|||
|
|
|
|||
|
|
// düşük geçiren filtre ile sub-gövde yumuşat
|
|||
|
|
var lp = ctx.createBiquadFilter();
|
|||
|
|
lp.type = 'lowpass';
|
|||
|
|
lp.frequency.setValueAtTime(900, t);
|
|||
|
|
lp.frequency.exponentialRampToValueAtTime(120, t + 0.4);
|
|||
|
|
|
|||
|
|
var g = makeEnv(0.95, 0.003, 0.38, t, true);
|
|||
|
|
osc.connect(lp);
|
|||
|
|
lp.connect(g.node);
|
|||
|
|
osc.start(t);
|
|||
|
|
osc.stop(g.stopAt + 0.02);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SNARE — beyaz gürültü (noise source) + band-pass + gövde sinüs
|
|||
|
|
function playSnare(time) {
|
|||
|
|
if (!audioCtx || !noiseBuffer) { return; }
|
|||
|
|
var ctx = audioCtx;
|
|||
|
|
var t = time;
|
|||
|
|
|
|||
|
|
// gürültü kaynağı (AudioBufferSourceNode)
|
|||
|
|
var noise = ctx.createBufferSource();
|
|||
|
|
noise.buffer = noiseBuffer;
|
|||
|
|
noise.loop = true;
|
|||
|
|
|
|||
|
|
var bp = ctx.createBiquadFilter();
|
|||
|
|
bp.type = 'bandpass';
|
|||
|
|
bp.frequency.value = 1800;
|
|||
|
|
bp.Q.value = 0.8;
|
|||
|
|
|
|||
|
|
var ng = makeEnv(0.55, 0.002, 0.16, t, true);
|
|||
|
|
noise.connect(bp);
|
|||
|
|
bp.connect(ng.node);
|
|||
|
|
noise.start(t, Math.random() * 0.5);
|
|||
|
|
noise.stop(ng.stopAt + 0.01);
|
|||
|
|
|
|||
|
|
// gövde (body): kısa düşük sinüs ~180Hz
|
|||
|
|
var osc = ctx.createOscillator();
|
|||
|
|
osc.type = 'triangle';
|
|||
|
|
osc.frequency.setValueAtTime(190, t);
|
|||
|
|
osc.frequency.exponentialRampToValueAtTime(80, t + 0.1);
|
|||
|
|
var og = makeEnv(0.4, 0.001, 0.12, t, true);
|
|||
|
|
osc.connect(og.node);
|
|||
|
|
osc.start(t);
|
|||
|
|
osc.stop(og.stopAt + 0.01);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// HAT — yüksek frekanslı kesik, tiz metalik ses
|
|||
|
|
function playHiHat(time, open) {
|
|||
|
|
if (!audioCtx || !noiseBuffer) { return; }
|
|||
|
|
var ctx = audioCtx;
|
|||
|
|
var t = time;
|
|||
|
|
var isOpen = !!open;
|
|||
|
|
|
|||
|
|
var decay = isOpen ? 0.28 : 0.05; // kapalı kısa titrek, açık daha uzun
|
|||
|
|
|
|||
|
|
var noise = ctx.createBufferSource();
|
|||
|
|
noise.buffer = noiseBuffer;
|
|||
|
|
noise.loop = true;
|
|||
|
|
|
|||
|
|
// yüksek geçiren filtre -> keskin tiz
|
|||
|
|
var hp = ctx.createBiquadFilter();
|
|||
|
|
hp.type = 'highpass';
|
|||
|
|
hp.frequency.value = 7500;
|
|||
|
|
|
|||
|
|
var ng = makeEnv(isOpen ? 0.4 : 0.5, 0.001, decay, t, true);
|
|||
|
|
noise.connect(hp);
|
|||
|
|
hp.connect(ng.node);
|
|||
|
|
noise.start(t, Math.random() * 0.5);
|
|||
|
|
noise.stop(ng.stopAt + 0.01);
|
|||
|
|
|
|||
|
|
// metalik rezonans üst tonu
|
|||
|
|
var osc = ctx.createOscillator();
|
|||
|
|
osc.type = 'square';
|
|||
|
|
osc.frequency.value = 8200;
|
|||
|
|
var hg = ctx.createGain();
|
|||
|
|
hg.gain.setValueAtTime(0.12, t);
|
|||
|
|
hg.gain.exponentialRampToValueAtTime(0.0001, t + (isOpen ? 0.22 : 0.045));
|
|||
|
|
osc.connect(hg);
|
|||
|
|
hg.connect(masterGain);
|
|||
|
|
osc.start(t);
|
|||
|
|
osc.stop(t + (isOpen ? 0.24 : 0.05) + 0.01);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SYNTH — melodik ton (kare/üçgen dalga + env)
|
|||
|
|
function playSynth(time, noteIdx) {
|
|||
|
|
if (!audioCtx) { return; }
|
|||
|
|
var ctx = audioCtx;
|
|||
|
|
var t = time;
|
|||
|
|
|
|||
|
|
// notayı ölçüden seç; verilmezse adım indeksinden üret
|
|||
|
|
var idx = (noteIdx === undefined) ? Math.floor(Math.random() * SYNTH_TABLE.length) : noteIdx;
|
|||
|
|
var freq = SYNTH_TABLE[idx % SYNTH_TABLE.length];
|
|||
|
|
|
|||
|
|
var oscA = ctx.createOscillator();
|
|||
|
|
oscA.type = 'square';
|
|||
|
|
oscA.frequency.value = freq;
|
|||
|
|
|
|||
|
|
// ikinci tiz katman
|
|||
|
|
var oscB = ctx.createOscillator();
|
|||
|
|
oscB.type = 'triangle';
|
|||
|
|
oscB.frequency.value = freq * 1.5;
|
|||
|
|
|
|||
|
|
var g = makeEnv(0.22, 0.006, 0.18, t, true);
|
|||
|
|
var oscBgain = ctx.createGain();
|
|||
|
|
oscBgain.gain.value = 0.35;
|
|||
|
|
|
|||
|
|
oscA.connect(g.node);
|
|||
|
|
oscB.connect(oscBgain);
|
|||
|
|
oscBgain.connect(g.node);
|
|||
|
|
oscA.start(t);
|
|||
|
|
oscB.start(t);
|
|||
|
|
oscA.stop(t + 0.4);
|
|||
|
|
oscB.stop(t + 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* =========================================================
|
|||
|
|
PATTERN (desen) & PLAYBACK
|
|||
|
|
========================================================= */
|
|||
|
|
|
|||
|
|
// Tek bir enstrüman adımını seslendirir (time = ctx zamanı).
|
|||
|
|
function playStep(instrIdx, stepIdx, time) {
|
|||
|
|
if (!pattern[instrIdx] || !pattern[instrIdx][stepIdx]) { return; }
|
|||
|
|
switch (instrIdx) {
|
|||
|
|
case 0: playKick(time); break;
|
|||
|
|
case 1: playSnare(time); break;
|
|||
|
|
case 2:
|
|||
|
|
// HAT: her 2. vuruşta açık (open) hat — 8. ve 15. gibi
|
|||
|
|
playHiHat(time, stepIdx % 8 === 7);
|
|||
|
|
break;
|
|||
|
|
case 3:
|
|||
|
|
// SYNTH melodik: notayı time + deterministic bir adım indeksiyle üret
|
|||
|
|
playSynth(time, stepIdx % SYNTH_TABLE.length);
|
|||
|
|
break;
|
|||
|
|
default: break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Hazır örnek break/techno ritmi (kick, snare, hat, synth)
|
|||
|
|
function defaultPattern() {
|
|||
|
|
var p = [];
|
|||
|
|
// kick: 1-3-5-7 + 13. adım variasyon -> 0,2,4,6,12 teknik
|
|||
|
|
p.push([true, false, false, false, true, false, true, false,
|
|||
|
|
false, true, false, false, true, false, false, false]);
|
|||
|
|
// snare: backbeat üzerine 4 ve 12; 7. de tuhaf vuruş
|
|||
|
|
p.push([false, false, false, false, true, false, false, true,
|
|||
|
|
false, false, false, false, true, false, false, false]);
|
|||
|
|
// hat: neredeyse her adım kesik 8'lik his
|
|||
|
|
p.push([true, true, true, true, true, true, true, true,
|
|||
|
|
true, true, true, true, true, true, true, true]);
|
|||
|
|
// synth: melodik dizi — 0,3,6,10,12 gibi aralıklarla
|
|||
|
|
p.push([false, false, false, false, true, false, true, false,
|
|||
|
|
false, true, false, false, true, false, false, false]);
|
|||
|
|
return p;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// defaultPattern'i pattern'deki duruma kopyalar; UI'ya uygular.
|
|||
|
|
function loadDefault() {
|
|||
|
|
var d = defaultPattern();
|
|||
|
|
for (var r = 0; r < 4; r++) {
|
|||
|
|
for (var c = 0; c < STEPS; c++) {
|
|||
|
|
pattern[r][c] = !!d[r][c];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
renderRows();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearAll() {
|
|||
|
|
for (var r = 0; r < 4; r++) {
|
|||
|
|
for (var c = 0; c < STEPS; c++) {
|
|||
|
|
pattern[r][c] = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
renderRows();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* =========================================================
|
|||
|
|
UI — matris & kontroller
|
|||
|
|
========================================================= */
|
|||
|
|
|
|||
|
|
function getCell(r, c) {
|
|||
|
|
return document.querySelector('#r' + r + 'c' + c);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Satırları DOM olarak kurar (olaylar tek sefer; hücre içerikleri güncellenir)
|
|||
|
|
// Aktif sütunu dikey neon şeritle vurgulayan overlay (bir kez kurulur).
|
|||
|
|
// Satır hücreleri grid ile dizildiği için; konum hesaplaması ilk satırın
|
|||
|
|
// hücre genişliğine göre updateVisualStep içinde yapılır.
|
|||
|
|
var colHeadEl = null;
|
|||
|
|
|
|||
|
|
function buildMatrix() {
|
|||
|
|
var matrix = document.getElementById('matrix');
|
|||
|
|
matrix.innerHTML = '';
|
|||
|
|
// dikey playhead şeridi — .active sınıfı ve left/width inline verilir
|
|||
|
|
colHeadEl = document.createElement('div');
|
|||
|
|
colHeadEl.className = 'step-col-head';
|
|||
|
|
colHeadEl.style.left = '-999px'; // başlangıçta görünmez
|
|||
|
|
matrix.appendChild(colHeadEl);
|
|||
|
|
for (var r = 0; r < 4; r++) {
|
|||
|
|
var row = document.createElement('div');
|
|||
|
|
row.className = 'step-row ' + INSTRUMENTS[r].rowCls + '-row';
|
|||
|
|
row.dataset.row = r;
|
|||
|
|
for (var c = 0; c < STEPS; c++) {
|
|||
|
|
var cell = document.createElement('div');
|
|||
|
|
cell.className = 'step-cell';
|
|||
|
|
cell.id = 'r' + r + 'c' + c;
|
|||
|
|
cell.dataset.r = r;
|
|||
|
|
cell.dataset.c = c;
|
|||
|
|
var glow = document.createElement('span');
|
|||
|
|
glow.className = 'cell-glow';
|
|||
|
|
cell.appendChild(glow);
|
|||
|
|
row.appendChild(cell);
|
|||
|
|
|
|||
|
|
// hücre tıklaması
|
|||
|
|
cell.addEventListener('click', function (ev) {
|
|||
|
|
var t = ev.currentTarget;
|
|||
|
|
toggleCell(parseInt(t.dataset.r, 10), parseInt(t.dataset.c, 10));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
matrix.appendChild(row);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Her hücrenin .on sınıfını pattern matrisine göre günceller.
|
|||
|
|
function renderRows() {
|
|||
|
|
for (var r = 0; r < 4; r++) {
|
|||
|
|
for (var c = 0; c < STEPS; c++) {
|
|||
|
|
var cell = getCell(r, c);
|
|||
|
|
if (!cell) { continue; }
|
|||
|
|
cell.classList.toggle('on', !!pattern[r][c]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Tek bir hücreyi MATRIS ÜZERİNDE yansıtır + silah sesi ön izler.
|
|||
|
|
function toggleCell(r, c) {
|
|||
|
|
unlock(); // audio context açık olsun
|
|||
|
|
if (pattern[r] && pattern[r][c] !== undefined) {
|
|||
|
|
pattern[r][c] = !pattern[r][c];
|
|||
|
|
var cell = getCell(r, c);
|
|||
|
|
if (cell) { cell.classList.toggle('on', pattern[r][c]); }
|
|||
|
|
// aktifse o enstrümanı önizle (talep geri beslemesi için)
|
|||
|
|
if (pattern[r][c]) { playStep(r, c, audioCtx ? audioCtx.currentTime + 0.02 : 0); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// LED ızgarası (üst bardaki mini göstergeyi adım ile yakar)
|
|||
|
|
function buildLeds() {
|
|||
|
|
var ledGrid = document.getElementById('ledGrid');
|
|||
|
|
ledGrid.innerHTML = '';
|
|||
|
|
for (var c = 0; c < STEPS; c++) {
|
|||
|
|
var led = document.createElement('span');
|
|||
|
|
led.className = 'led';
|
|||
|
|
ledGrid.appendChild(led);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildStepLabels() {
|
|||
|
|
var labels = document.getElementById('stepLabels');
|
|||
|
|
labels.innerHTML = '';
|
|||
|
|
// ilk hücre boşluk (enstrüman sütununa kalsın)
|
|||
|
|
var spacer = document.createElement('span');
|
|||
|
|
spacer.className = 'step-num';
|
|||
|
|
labels.appendChild(spacer);
|
|||
|
|
for (var i = 0; i < STEPS; i++) {
|
|||
|
|
var s = document.createElement('span');
|
|||
|
|
s.className = 'step-num' + ((i % 4 === 0) ? ' group' : '');
|
|||
|
|
s.textContent = String(i + 1).padStart(2, '0');
|
|||
|
|
labels.appendChild(s);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Aktif playhead sütununu görsel ışıkla vurgular:
|
|||
|
|
// 1) matrix üzerinde o sütunu kaplayan DİKEY neon şerit (colHeadEl)
|
|||
|
|
// 2) çalan ON hücrelere kısa .hit parlaması
|
|||
|
|
// 3) üst bardaki LED göstergesini yakar
|
|||
|
|
function updateVisualStep(stepIdx) {
|
|||
|
|
if (stepIdx < 0 || stepIdx >= STEPS) { return; }
|
|||
|
|
var prev = currentStepVisual;
|
|||
|
|
currentStepVisual = stepIdx;
|
|||
|
|
|
|||
|
|
// --- Dikey sütun şeridi konumlandır ---
|
|||
|
|
if (colHeadEl) {
|
|||
|
|
// İlk satırın hedef sütunundaki hücrenin ofsetini kullanarak
|
|||
|
|
// overlay'i o sütunun tam üstüne bindiririz (satırlar 16 eşit sütun).
|
|||
|
|
var anchor = getCell(0, stepIdx);
|
|||
|
|
if (anchor) {
|
|||
|
|
// left: hücrenin matrix içindeki sol kenarı; width: hücre + gap
|
|||
|
|
var cellW = anchor.offsetWidth;
|
|||
|
|
var gap = 5; // .matrix gap (style.css) — hücre büyüklüğüne oransal yerine net
|
|||
|
|
// satırlar flex'tir ve satır ile matrix genişlikleri aynı anda başlar:
|
|||
|
|
var leftOfCell = anchor.offsetLeft;
|
|||
|
|
colHeadEl.style.left = leftOfCell + 'px';
|
|||
|
|
colHeadEl.style.width = (cellW) + 'px';
|
|||
|
|
colHeadEl.classList.add('active');
|
|||
|
|
} else {
|
|||
|
|
colHeadEl.classList.remove('active');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- LED barları güncelle ---
|
|||
|
|
var leds = document.querySelectorAll('#ledGrid .led');
|
|||
|
|
for (var i = 0; i < leds.length; i++) {
|
|||
|
|
leds[i].classList.toggle('on', i === stepIdx);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// önceki sütuna verilen .hit sınıfını gecikmeli düşür (temizlik)
|
|||
|
|
if (prev >= 0 && prev !== stepIdx) {
|
|||
|
|
for (var pc = 0; pc < 4; pc++) {
|
|||
|
|
var pcell = getCell(pc, prev);
|
|||
|
|
if (pcell) { pcell.classList.remove('hit'); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// --- Çalan hücrelere kısa parlama ---
|
|||
|
|
for (var rr = 0; rr < 4; rr++) {
|
|||
|
|
if (pattern[rr][stepIdx]) {
|
|||
|
|
var cc = getCell(rr, stepIdx);
|
|||
|
|
if (cc) { cc.classList.add('hit'); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// kısa animasyon sonunda çalan hücrelerin parlamasını düşür
|
|||
|
|
setTimeout(function () {
|
|||
|
|
for (var rrr = 0; rrr < 4; rrr++) {
|
|||
|
|
if (pattern[rrr][stepIdx]) {
|
|||
|
|
var ccell = getCell(rrr, stepIdx);
|
|||
|
|
if (ccell) { ccell.classList.remove('hit'); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 90);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* =========================================================
|
|||
|
|
ZAMANLAYICI (lookahead scheduler)
|
|||
|
|
========================================================= */
|
|||
|
|
|
|||
|
|
function scheduleStep() {
|
|||
|
|
if (!isPlaying) { return; }
|
|||
|
|
while (nextNoteTime < audioCtx.currentTime + 0.12) {
|
|||
|
|
var idx = step;
|
|||
|
|
// sürede çal — mümkün olan tüm enstrümanları
|
|||
|
|
for (var r = 0; r < 4; r++) {
|
|||
|
|
if (pattern[r][idx]) { playStep(r, idx, nextNoteTime); }
|
|||
|
|
}
|
|||
|
|
updateVisualStep(idx);
|
|||
|
|
// bir sonraki adıma ilerle
|
|||
|
|
step = (step + 1) % STEPS;
|
|||
|
|
nextNoteTime += stepDur();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startLoop() {
|
|||
|
|
if (!audioCtx) { return; }
|
|||
|
|
// görsel playhead intervali ~20ms
|
|||
|
|
visualTimer = setInterval(scheduleStep, LOOKAHEAD_MS);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopLoop() {
|
|||
|
|
if (visualTimer) { clearInterval(visualTimer); visualTimer = null; }
|
|||
|
|
if (scheduleTimer) { clearInterval(scheduleTimer); scheduleTimer = null; }
|
|||
|
|
step = 0;
|
|||
|
|
currentStepVisual = -1;
|
|||
|
|
lastScheduledStep = -1;
|
|||
|
|
var leds = document.querySelectorAll('#ledGrid .led');
|
|||
|
|
for (var i = 0; i < leds.length; i++) { leds[i].classList.remove('on'); }
|
|||
|
|
// aktif playhead sütun şeridini ve parlamaları sıfırla
|
|||
|
|
if (colHeadEl) { colHeadEl.classList.remove('active'); colHeadEl.style.left = '-999px'; }
|
|||
|
|
var hitCells = document.querySelectorAll('.step-cell.hit');
|
|||
|
|
for (var h = 0; h < hitCells.length; h++) { hitCells[h].classList.remove('hit'); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Oynat / duraklat. Space ve buton ortak.
|
|||
|
|
function togglePlay() {
|
|||
|
|
unlock();
|
|||
|
|
if (!audioCtx) {
|
|||
|
|
var st = document.getElementById('statusText');
|
|||
|
|
if (st) { st.textContent = 'SES YOK'; }
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isPlaying) {
|
|||
|
|
pause();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// başlat
|
|||
|
|
isPlaying = true;
|
|||
|
|
step = 0;
|
|||
|
|
nextNoteTime = (audioCtx.currentTime || 0) + 0.05;
|
|||
|
|
startLoop();
|
|||
|
|
// UI durum
|
|||
|
|
document.getElementById('playIndicator').classList.add('running');
|
|||
|
|
document.getElementById('statusText').classList.add('running');
|
|||
|
|
document.getElementById('statusText').textContent = 'ÇALIYOR';
|
|||
|
|
document.getElementById('playBtn').classList.add('running');
|
|||
|
|
document.getElementById('playBtn').textContent = '❚❚';
|
|||
|
|
document.getElementById('statusText').className = 'status-text running';
|
|||
|
|
statusVisual(true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pause() {
|
|||
|
|
isPlaying = false;
|
|||
|
|
stopLoop();
|
|||
|
|
document.getElementById('playIndicator').classList.remove('running');
|
|||
|
|
document.getElementById('statusText').textContent = 'DURDU';
|
|||
|
|
document.getElementById('playBtn').classList.remove('running');
|
|||
|
|
document.getElementById('playBtn').textContent = '▶';
|
|||
|
|
statusVisual(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function statusVisual(on) {
|
|||
|
|
document.getElementById('playIndicator').classList.toggle('running', on);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function statusStop() {
|
|||
|
|
isPlaying = false;
|
|||
|
|
stopLoop();
|
|||
|
|
document.getElementById('playIndicator').classList.remove('running');
|
|||
|
|
document.getElementById('statusText').textContent = 'DURDU';
|
|||
|
|
document.getElementById('playBtn').classList.remove('running');
|
|||
|
|
document.getElementById('playBtn').textContent = '▶';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setTempo(value) {
|
|||
|
|
var v = parseInt(value, 10);
|
|||
|
|
if (isNaN(v)) { v = 120; }
|
|||
|
|
v = Math.max(BPM_MIN, Math.min(BPM_MAX, v));
|
|||
|
|
bpm = v;
|
|||
|
|
document.getElementById('bpmValue').textContent = String(bpm);
|
|||
|
|
// loop ayaktaysa zamanlama otomatik olarak yeni stepDur ile devam eder
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* =========================================================
|
|||
|
|
OLAY BAĞLAMA
|
|||
|
|
========================================================= */
|
|||
|
|
|
|||
|
|
function bindControls() {
|
|||
|
|
var playBtn = document.getElementById('playBtn');
|
|||
|
|
var stopBtn = document.getElementById('stopBtn');
|
|||
|
|
var clearBtn = document.getElementById('clearBtn');
|
|||
|
|
var bpmSlider = document.getElementById('bpmSlider');
|
|||
|
|
var bpmValue = document.getElementById('bpmValue');
|
|||
|
|
|
|||
|
|
playBtn.addEventListener('click', togglePlay);
|
|||
|
|
stopBtn.addEventListener('click', statusStop);
|
|||
|
|
clearBtn.addEventListener('click', function () { clearAll(); });
|
|||
|
|
|
|||
|
|
bpmSlider.addEventListener('input', function () {
|
|||
|
|
setTempo(bpmSlider.value);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Space ile oynat/durdur (sayfa kaydırmayı engelle)
|
|||
|
|
document.addEventListener('keydown', function (e) {
|
|||
|
|
if (e.code === 'Space') {
|
|||
|
|
e.preventDefault();
|
|||
|
|
togglePlay();
|
|||
|
|
} else if (e.key === 'Escape') {
|
|||
|
|
statusStop();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// mobil dokunma ile de hücrelerde tam duyarlılık için kullanıcı tıklamaları
|
|||
|
|
// (click zaten mobilde çalışır).
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function previewSource(instrIdx) {
|
|||
|
|
if (!audioCtx) { initAudio(); }
|
|||
|
|
playStep(instrIdx, 0, audioCtx.currentTime + 0.02);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* =========================================================
|
|||
|
|
BAŞLAT
|
|||
|
|
========================================================= */
|
|||
|
|
|
|||
|
|
function buildUI() {
|
|||
|
|
initPattern();
|
|||
|
|
buildMatrix(); // hücreleri kur
|
|||
|
|
buildLeds();
|
|||
|
|
buildStepLabels();
|
|||
|
|
loadDefault(); // hazır örnek ritim yükle + render
|
|||
|
|
bindControls();
|
|||
|
|
|
|||
|
|
var bpmSlider = document.getElementById('bpmSlider');
|
|||
|
|
setTempo(bpmSlider.value);
|
|||
|
|
|
|||
|
|
// play (sahte önizleme hakkı sayacı) için etiket
|
|||
|
|
document.getElementById('statusText').textContent = 'DURDU';
|
|||
|
|
statusStop();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (document.readyState === 'loading') {
|
|||
|
|
document.addEventListener('DOMContentLoaded', buildUI);
|
|||
|
|
} else {
|
|||
|
|
buildUI();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// (iç API — gerekli: eksiksiz fonksiyon ispatı)
|
|||
|
|
window.CyberBeats = {
|
|||
|
|
start: togglePlay,
|
|||
|
|
stop: statusStop,
|
|||
|
|
setTempo: setTempo,
|
|||
|
|
clearAll: clearAll,
|
|||
|
|
loadDefault: loadDefault,
|
|||
|
|
toggleCell: toggleCell,
|
|||
|
|
previewSource: previewSource
|
|||
|
|
};
|
|||
|
|
})();
|