200 lines
5.1 KiB
Vue
200 lines
5.1 KiB
Vue
<script setup>
|
|
import { ref, onBeforeUnmount } from "vue";
|
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
import { faMinus, faPlus, faPlay, faStop, faTrash, faArrowDown } from "@fortawesome/free-solid-svg-icons";
|
|
|
|
const MIN_BPM = 20;
|
|
const MAX_BPM = 300;
|
|
const STORAGE_KEY = "ClickPresets"
|
|
|
|
// Reaktiver Zustand (für die UI)
|
|
const bpm = ref(120);
|
|
const isRunning = ref(false);
|
|
|
|
// Nicht-reaktiver Zustand (reine Audio-Logik)
|
|
let audioContext = null;
|
|
let clickBuffer = null;
|
|
let nextNoteTime = 0;
|
|
let timerID = null;
|
|
|
|
const presets = ref(loadPresets());
|
|
let selectedPreset = null;
|
|
|
|
// Wie weit im Voraus Audio geplant wird
|
|
const scheduleAheadTime = 0.1; // 100 ms
|
|
|
|
// Wie häufig wir nach neuen Events schauen
|
|
const lookahead = 25; // ms
|
|
|
|
function clampBpm() {
|
|
if (!Number.isFinite(bpm.value)) {
|
|
bpm.value = 120;
|
|
}
|
|
bpm.value = Math.min(MAX_BPM, Math.max(MIN_BPM, Math.round(bpm.value)));
|
|
}
|
|
|
|
function changeBpm(delta) {
|
|
bpm.value = (Number.isFinite(bpm.value) ? bpm.value : 120) + delta;
|
|
clampBpm();
|
|
selectedPreset = null;
|
|
}
|
|
|
|
async function loadClickSound() {
|
|
const response = await fetch("/click.wav");
|
|
if (!response.ok) {
|
|
throw new Error(`click.wav konnte nicht geladen werden (${response.status})`);
|
|
}
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
clickBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
}
|
|
|
|
function playClick(time) {
|
|
const source = audioContext.createBufferSource();
|
|
source.buffer = clickBuffer;
|
|
source.connect(audioContext.destination);
|
|
source.start(time);
|
|
}
|
|
|
|
function scheduleNote() {
|
|
const secondsPerBeat = 60 / bpm.value;
|
|
playClick(nextNoteTime);
|
|
nextNoteTime += secondsPerBeat;
|
|
}
|
|
|
|
function scheduler() {
|
|
while (nextNoteTime < audioContext.currentTime + scheduleAheadTime) {
|
|
scheduleNote();
|
|
}
|
|
if (isRunning.value) {
|
|
timerID = setTimeout(scheduler, lookahead);
|
|
}
|
|
}
|
|
|
|
async function start() {
|
|
if (isRunning.value) return;
|
|
|
|
clampBpm();
|
|
|
|
try {
|
|
// AudioContext erst durch User-Interaktion starten
|
|
if (!audioContext) {
|
|
audioContext = new AudioContext();
|
|
}
|
|
if (!clickBuffer) {
|
|
await loadClickSound();
|
|
}
|
|
if (audioContext.state === "suspended") {
|
|
await audioContext.resume();
|
|
}
|
|
} catch (error) {
|
|
console.error("Metronom konnte nicht gestartet werden:", error);
|
|
return;
|
|
}
|
|
|
|
isRunning.value = true;
|
|
// Erster Klick sehr kurz in der Zukunft
|
|
nextNoteTime = audioContext.currentTime + 0.05;
|
|
scheduler();
|
|
}
|
|
|
|
function stop() {
|
|
isRunning.value = false;
|
|
if (timerID !== null) {
|
|
clearTimeout(timerID);
|
|
timerID = null;
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
stop();
|
|
if (audioContext) {
|
|
audioContext.close();
|
|
audioContext = null;
|
|
}
|
|
});
|
|
|
|
//PRESETS
|
|
function loadPresets() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch (error) {
|
|
console.error("Presets could not be loaded:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function addPreset() {
|
|
const nextId = Math.max(0, ...presets.value.map(p => p.id)) + 1;
|
|
const preset = { tempo: bpm.value, id: nextId };
|
|
presets.value.push(preset);
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets.value));
|
|
}
|
|
|
|
function deletePreset() {
|
|
presets.value = presets.value.filter(f => f.id != selectedPreset.id);
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets.value));
|
|
stop();
|
|
selectedPreset = null;
|
|
}
|
|
|
|
function startPreset(preset) {
|
|
selectedPreset = preset;
|
|
bpm.value = preset.tempo;
|
|
|
|
stop();
|
|
start();
|
|
}
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<header>
|
|
<div id="controls">
|
|
<h1>Speed</h1>
|
|
<div id="speedControls">
|
|
<button id="speedDown" class="thinButton" @click="changeBpm(-1)">
|
|
<font-awesome-icon :icon="faMinus" size="xl" />
|
|
</button>
|
|
<input id="bpm" type="number" v-model.number="bpm" :min="MIN_BPM" :max="MAX_BPM" @change="clampBpm">
|
|
<button id="speedUp" class="thinButton" @click="changeBpm(1)">
|
|
<font-awesome-icon :icon="faPlus" size="xl" />
|
|
</button>
|
|
</div>
|
|
|
|
<div id="presetsControls">
|
|
<button id="deletePreset" :disabled="selectedPreset === null" style="background: var(--red)" class="presetsControl" @click="deletePreset">
|
|
<font-awesome-icon :icon="faTrash" size="xl" />
|
|
</button>
|
|
<div id="playControls">
|
|
<button id="start" :hidden="isRunning" @click="start">
|
|
<font-awesome-icon :icon="faPlay" size="xl" />
|
|
</button>
|
|
<button id="stop" :hidden="!isRunning" @click="stop">
|
|
<font-awesome-icon :icon="faStop" size="xl" />
|
|
</button>
|
|
</div>
|
|
<button id="addPreset" style="background: var(--blue)" class="presetsControl" @click="addPreset">
|
|
<font-awesome-icon :icon="faArrowDown" size="xl" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</header>
|
|
|
|
<main>
|
|
<hr>
|
|
<div id="presets">
|
|
<h1>Presets</h1>
|
|
<div id="presetsItems">
|
|
<div v-for="preset in presets" :key="preset.id" class="presetItem" :class="{ selected: selectedPreset === preset }" @click="startPreset(preset)">
|
|
<h2>{{ preset.tempo }}</h2>
|
|
BPM
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</template>
|