first commit
This commit is contained in:
+199
@@ -0,0 +1,199 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user