Files
client/src/App.vue
T
2026-10-07 18:56:27 +02:00

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>