zwischenstand

This commit is contained in:
2026-03-21 13:49:05 +01:00
parent b863b04505
commit 01448223ad
30 changed files with 1446 additions and 295 deletions

View File

@@ -4,7 +4,7 @@
"singleAttributePerLine": false,
"overrides": [
{
"files": ["*.svelte", "*.astro"],
"files": ["*.svelte", "*.astro", "*.ts", "*.js", "*.tsx", "*.jsx"],
"options": {
"printWidth": 100
}

View File

@@ -0,0 +1,456 @@
<script lang="ts">
import { GearIcon, CloudArrowUpIcon, InfoIcon, CaretDownIcon } from "phosphor-svelte";
import { slide, fade } from "svelte/transition";
import { addToast } from "../lib/toast";
import { processAudioFile } from "../lib/audioProcessor";
import { saveLocalFile } from "../lib/db";
import { refreshLocal } from "../lib/sync";
import { updateLocalAudioFormat, updateLocalAudioCrc } from "../lib/tagHandler";
import { audioOptions } from "../lib/store";
import { AUDIO_PRESETS, type AudioPresetName } from "../lib/settings";
import { tooltip } from "../lib/actions/tooltip";
import { clickOutside } from "../lib/actions/clickOutside"; // Angenommen, diese Action existiert bereits (analog zu Ihren anderen Menüs).
let isFileDragOver = false;
let showSettings = false;
let showPresetDropdown = false; // Neuer Zustand für das Custom Dropdown
// Definitionen der Preset-Infos für Tooltips im Dropdown
const presetInfos: Record<AudioPresetName, { name: string; desc: string }> = {
normal: { name: "Normal", desc: "Für natürliche Sprache. Moderate Verdichtung." },
broadcast: { name: "Broadcast", desc: "Aggressiv, sehr laut (Radio-Style). Maximale Präsenz." },
custom: { name: "Benutzerdefiniert", desc: "Manuelle Anpassung aller Kompressor-Parameter." },
};
// Reagiert auf Preset-Wechsel
function handlePresetChange(preset: AudioPresetName) {
$audioOptions.preset = preset;
showPresetDropdown = false; // Dropdown nach Auswahl schließen
if (preset !== "custom") {
const p = AUDIO_PRESETS[preset];
$audioOptions = {
...$audioOptions,
compressorThreshold: p.compressorThreshold!,
compressorRatio: p.compressorRatio!,
compressorKnee: p.compressorKnee!,
compressorAttack: p.compressorAttack!,
compressorRelease: p.compressorRelease!,
};
}
}
// Setzt das Preset automatisch auf Custom, sobald ein Slider bewegt wird
function setCustomPreset() {
$audioOptions.preset = "custom";
}
async function handleDrop(event: DragEvent) {
isFileDragOver = false;
showSettings = false;
showPresetDropdown = false; // Dropdown sicherheitshalber schließen
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
const audioFiles = fileArray.filter((file) => file.type.startsWith("audio/"));
const rawFiles = fileArray.filter((file) => file.type === ""); // Ohne MIME-Type
if (audioFiles.length > 0 || rawFiles.length > 0) {
// 1. Reguläre Dateien umwandeln & taggen
for (const file of audioFiles) {
try {
const { buffer, name } = await processAudioFile(file, $audioOptions);
await saveLocalFile(name, new Blob([buffer]), buffer.byteLength);
await updateLocalAudioFormat(name, { codec: 0, bitDepth: 16, sampleRate: 16000 });
await updateLocalAudioCrc(name);
addToast(`Gespeichert & Getaggt: ${name}`, "success");
} catch (err) {
addToast(`Fehler bei ${file.name}: ${err}`, "error");
}
}
// 2. Rohdaten bypassen (direkt speichern & taggen)
for (const file of rawFiles) {
try {
const name = file.name;
await saveLocalFile(name, file, file.size);
// TagHandler erkennt alte Tags automatisch und fügt System-Tags sicher hinzu
await updateLocalAudioFormat(name, { codec: 0, bitDepth: 16, sampleRate: 16000 });
await updateLocalAudioCrc(name);
addToast(`Roh-Datei importiert: ${name}`, "success");
} catch (err) {
addToast(`Fehler bei Roh-Import ${file.name}: ${err}`, "error");
}
}
refreshLocal();
} else {
addToast("Keine gültigen Dateien gefunden.", "error");
}
}
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === "Escape") showSettings = false;
}}
/>
<section class="buzzer-card flex flex-col h-full transition-all duration-300 min-w-[320px]">
<div class="card-header" class:blur={isFileDragOver}>
<h3 class="card-title">Dateiverarbeitung</h3>
<button class="btn" aria-label="Einstellungen" on:click={() => (showSettings = !showSettings)}>
<GearIcon class="icon" />
</button>
</div>
<div
class="card-body flex flex-col flex-1"
role="region"
aria-label="Audiofile dropzone"
on:dragenter={() => (isFileDragOver = true)}
on:dragleave={() => (isFileDragOver = false)}
on:dragover|preventDefault
on:drop|preventDefault={handleDrop}
>
{#if !showSettings}
<div
class="flex justify-center items-center pointer-events-none p-4 pb-0"
in:fade={{ duration: 200 }}
>
<div
class="text-center text-2xs tracking-tight font-mono font-semibold transition-all"
class:blur={isFileDragOver}
>
<span class={$audioOptions.lowCut ? "text-emerald-500" : "text-text-muted"}>LOW CUT</span>
|
<span class={$audioOptions.compress ? "text-emerald-500" : "text-text-muted"}>
COMPRESSOR
</span>
|
<span class={$audioOptions.normalize ? "text-emerald-500" : "text-text-muted"}>
NORMALIZER
</span>
</div>
</div>
<div class="flex-1 p-4 pt-2">
<div
in:fade={{ duration: 200 }}
class="flex h-full items-center justify-center border-2 border-dashed rounded-lg pointer-events-none transition-all duration-300 min-h-[8rem]"
class:border-slate-300={!isFileDragOver}
class:border-indigo-500={isFileDragOver}
class:bg-indigo-50={isFileDragOver}
>
<div class="relative flex size-24 transition-transform" class:scale-110={isFileDragOver}>
<span
class="absolute inline-flex h-full w-full opacity-50"
class:animate-ping={isFileDragOver}
class:text-indigo-500={isFileDragOver}
class:text-transparent={!isFileDragOver}
>
<CloudArrowUpIcon class="w-full h-full" />
</span>
<span
class="relative inline-flex size-24 transition-colors"
class:text-slate-500={!isFileDragOver}
class:text-indigo-600={isFileDragOver}
>
<CloudArrowUpIcon class="w-full h-full transition-colors" />
</span>
</div>
</div>
</div>
{:else}
<div class="flex-1 flex flex-col bg-white h-full" in:slide={{ duration: 300 }}>
<div
class="flex items-center justify-between px-4 pt-4 pb-2 border-b border-slate-100 mb-4"
>
<h4 class="font-semibold text-sm text-slate-800">Audio-Optionen</h4>
</div>
<div class="space-y-5 flex-1 overflow-y-auto px-4 pb-4">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Entfernt tieffrequentes Rumpeln (Wind, Trittschall). Schont kleine Lautsprecher und verhindert Verzerrungen.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.lowCut}
class="accent-indigo-600"
/>
<span>Low-Cut Filter</span>
</label>
</div>
{#if $audioOptions.lowCut}
<div class="pl-6 flex items-center gap-3">
<span class="text-xs text-text-muted w-10 text-right">
{$audioOptions.lowCutFreq} Hz
</span>
<input
type="range"
min="50"
max="300"
step="10"
bind:value={$audioOptions.lowCutFreq}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Gleicht Lautstärkeunterschiede an. Macht leise Passagen lauter und fängt laute Spitzen ab.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.compress}
class="accent-indigo-600"
/>
<span>Dynamik-Kompressor</span>
</label>
</div>
{#if $audioOptions.compress}
<div class="pl-6 flex flex-col gap-3">
<div class="relative" use:clickOutside={() => (showPresetDropdown = false)}>
<button
type="button"
class="flex items-center justify-between w-full text-sm border border-slate-300 rounded p-1.5 bg-slate-50 hover:bg-slate-100 transition"
on:click={() => (showPresetDropdown = !showPresetDropdown)}
>
<div class="flex items-center gap-2">
<div
use:tooltip={{ text: presetInfos[$audioOptions.preset].desc, pos: "right" }}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<span>Preset: {presetInfos[$audioOptions.preset].name}</span>
</div>
<span class:rotate-180={showPresetDropdown}>
<CaretDownIcon class="size-4 text-slate-500 transition-transform" />
</span>
</button>
{#if showPresetDropdown}
<div
class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-20 overflow-hidden"
transition:fade={{ duration: 150 }}
>
{#each Object.entries(presetInfos) as [key, info]}
<button
type="button"
class="flex items-center gap-2 w-full text-left text-sm px-3 py-2 hover:bg-indigo-50 transition"
class:bg-indigo-100={$audioOptions.preset === key}
class:font-medium={$audioOptions.preset === key}
on:click={() => handlePresetChange(key as AudioPresetName)}
>
<div use:tooltip={{ text: info.desc, pos: "right" }}>
<InfoIcon class="text-text-muted size-4" />
</div>
<span>{info.name}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-2 text-xs">
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Pegel, ab dem der Kompressor zu arbeiten beginnt.",
pos: "right",
}}
>
Threshold
</span>
<span class="text-text-muted w-16 text-right">
{$audioOptions.compressorThreshold} dB
</span>
<input
type="range"
min="-60"
max="0"
step="1"
bind:value={$audioOptions.compressorThreshold}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Stärke der Pegelreduzierung oberhalb des Thresholds.",
pos: "right",
}}
>
Ratio
</span>
<span class="text-text-muted w-16 text-right">
{$audioOptions.compressorRatio}:1
</span>
<input
type="range"
min="1"
max="20"
step="1"
bind:value={$audioOptions.compressorRatio}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Reaktionszeit des Kompressors auf Pegelüberschreitungen.",
pos: "right",
}}
>
Attack
</span>
<span class="text-text-muted w-16 text-right">
{($audioOptions.compressorAttack * 1000).toFixed(0)} ms
</span>
<input
type="range"
min="0.001"
max="0.1"
step="0.001"
bind:value={$audioOptions.compressorAttack}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Zeit, bis der Kompressor nach Unterschreitung des Thresholds aufhört zu arbeiten.",
pos: "right",
}}
>
Release
</span>
<span class="text-text-muted w-16 text-right">
{($audioOptions.compressorRelease * 1000).toFixed(0)} ms
</span>
<input
type="range"
min="0.01"
max="1"
step="0.01"
bind:value={$audioOptions.compressorRelease}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
</div>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Hebt das verarbeitete Signal linear an, bis der lauteste Peak exakt den Zielwert erreicht.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.normalize}
class="accent-indigo-600"
/>
<span>Lautstärke normalisieren</span>
</label>
</div>
{#if $audioOptions.normalize}
<div class="pl-6 flex items-center gap-3">
<span class="text-xs text-text-muted w-10 text-right">
{$audioOptions.normalizeTargetDb} dB
</span>
<input
type="range"
min="-10"
max="0"
step="0.1"
bind:value={$audioOptions.normalizeTargetDb}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
{/if}
</div>
<div class="text-xs text-text-muted bg-slate-100 p-3 rounded mt-4">
Hinweis: Die Konvertierung zu 16kHz Mono PCM erfolgt immer automatisch beim Speichern.
</div>
</div>
</div>
{/if}
</div>
</section>
<style>
@reference "../styles/app.css";
.btn {
@apply rounded-full transition-colors border-0 p-2;
&:not(:disabled) {
@apply hover:bg-slate-200 hover:shadow-sm;
}
}
/* Styling für die Range-Slider */
input[type="range"] {
@apply cursor-pointer;
}
input[type="range"]:disabled {
@apply cursor-not-allowed;
}
</style>

View File

@@ -1,54 +1,145 @@
<script lang="ts">
import FlashUsage from "./FlashUsage.svelte"
import { BatteryEmptyIcon, BatteryLowIcon, BatteryMediumIcon, BatteryHighIcon, BatteryFullIcon, BatteryChargingIcon } from "phosphor-svelte"
import FlashUsage from "./FlashUsage.svelte";
import { deviceInfo, fwInfo } from "../lib/store";
import { FW_STATUS } from "../lib/protocol/constants";
import { tooltip } from "../lib/actions/tooltip";
import {
CheckCircleIcon,
WarningIcon,
BatteryEmptyIcon,
BatteryLowIcon,
BatteryMediumIcon,
BatteryHighIcon,
BatteryFullIcon,
BatteryChargingIcon,
} from "phosphor-svelte";
</script>
<div class="text-sm">
<div class="text-sm">
<table>
<tbody>
<tr>
<td class="key">
Modell
</td>
<td class="value">
nrf52840dk-prototyp
</td>
</tr>
<tr>
<td class="key">
Version
</td>
<td class="value">
2.3.22-debug
</td>
</tr>
<tr>
<td class="key">
HW-ID
</td>
<td class="value">
<span class="font-mono">DEAD-BEAF-0102-3456</span>
</td>
</tr>
<tr>
<td class="key">
Batterie
</td>
<td class="value flex items-center gap-2">
85% <BatteryChargingIcon weight="bold" class="w-5 h-5"/> 1200mAh
</td>
</tr>
<tr>
<td class="key">
Speicher
</td>
<td class="value">
<div class="py-1">
<FlashUsage/>
</div>
</td>
</tr>
</tbody>
<table>
<tbody>
<tr>
<td class="key">Modell</td>
<td class="value">
{#if $deviceInfo}
{$deviceInfo.boardName}
{#if $deviceInfo.boardRevision}
<span class="text-muted">>Rev. {$deviceInfo.boardRevision}</span>
{/if}
{:else}
unbekannt
{/if}
</td>
</tr>
<tr>
<td class="key">Version</td>
<td class="value flex items-center gap-1">
{#if $fwInfo}
<span
use:tooltip={{
text: `Die Firmware-Slotgrösse beträgt <span class="font-medium">${$fwInfo.slot1Size / 1024} kB</span>. Wieso musst Du das wissen? Edi sorgt schon dafür, dass die Updates nicht zu gross sind.<br/> <div class="text-3xl align-center text-center pb-2">😈</div>`,
pos: "bottom",
}}
>
{$fwInfo.fwVersion}
</span>
{#if $fwInfo.fwStatus === FW_STATUS.CONFIRMED}
<span
use:tooltip={{
text: "Firmware ist bestätigt und damit dauerhaft nutzbar",
pos: "bottom",
}}
class="relative flex size-5"
>
<CheckCircleIcon
weight="fill"
class="text-emerald-500 relative inline-flex h-full w-full"
/>
</span>
{:else if $fwInfo.fwStatus === FW_STATUS.PENDING}
<span
use:tooltip={{
text: "Firmware ist nur hochgeladen und noch nicht bestätigt. Du kannst sie nach einem <b>reboot</b> testen. Wenn sie funktioniert, bestätige sie, damit sie dauerhaft nutzbar ist.<br/><b><i>Achtung:</i></b> der Reboot dauert einen Moment, da die Firmware erst umkopiert werden muss.",
pos: "bottom",
}}
class="relative flex size-5"
>
<WarningIcon
weight="fill"
class="text-amber-500 absolute inline-flex h-full w-full animate-ping"
/>
<WarningIcon
weight="fill"
class="text-amber-500 relative inline-flex h-full w-full"
/>
</span>
{:else if $fwInfo.fwStatus === FW_STATUS.TESTING}
<span
use:tooltip={{
text: "Die Firmware ist im Testmodus. Wenn sie gut funktioniert, dann <b>bestätige</b> sie, damit sie dauerhaft nutzbar ist. Wenn sie nicht richtig funktioniert, dann kannst Du den Buzzer <b>rebooten</b>, er kehrt dann zur vorhergehenden Version zurück.<br/><b><i>Achtung:</i></b> der Reboot dauert einen Moment, da die Firmware erst umkopiert werden muss.",
pos: "bottom",
}}
class="relative flex size-5"
>
<WarningIcon
weight="fill"
class="text-amber-600 absolute inline-flex h-full w-full animate-ping"
/>
<WarningIcon
weight="fill"
class="text-amber-600 relative inline-flex h-full w-full"
/>
</span>
{:else if $fwInfo.fwStatus === FW_STATUS.UNKNOWN}
<span
use:tooltip={{
text: `Die Firmware hat den unbekannten Status <span class="bold font-mono">0x${$fwInfo.fwStatus.toString(16).padStart(2, "0").toUpperCase()}.</span> Weiss der Teufel, was da wieder passiert ist... Vielleicht hilft ein Reboot? Oder Firmware neu flashen? Oder... hast Du ne Idee???`,
pos: "bottom",
}}
class="relative flex size-5"
>
<WarningIcon
weight="fill"
class="text-red-500 absolute inline-flex h-full w-full animate-ping"
/>
<WarningIcon
weight="fill"
class="text-red-500 relative inline-flex h-full w-full"
/>
</span>
{/if}
<span class="text-text-muted ml-1">(Zephyr {$fwInfo.kernelVersion})</span>
{:else}
unbekannt
{/if}
</td>
</tr>
<tr>
<td class="key">HW-ID</td>
<td class="value">
{#if $deviceInfo}
<span class="font-mono">{$deviceInfo.deviceId}</span>
{:else}
unbekannt
{/if}
</td>
</tr>
<tr>
<td class="key">Batterie</td>
<td class="value flex items-center gap-2">
85% <BatteryChargingIcon weight="bold" class="w-5 h-5" /> 1200mAh
</td>
</tr>
<tr>
<td class="key">Speicher</td>
<td class="value">
<div class="py-1">
<FlashUsage />
</div>
</td>
</tr>
</tbody>
</table>
</div>
<style>

View File

@@ -28,7 +28,7 @@
import { tagEditorState } from "../lib/store";
import { tooltip } from "../lib/actions/tooltip";
import { deleteRemoteFile } from "../lib/transport";
import { deleteLocalFile } from "../lib/db";
import { deleteLocalFile, playLocalFile } from "../lib/db";
import { refreshRemote, refreshLocal } from "../lib/sync";
import { addToast } from "../lib/toast";
@@ -56,6 +56,7 @@
case SyncState.UNKNOWN:
return {
icon: QuestionIcon,
weight: "fill",
color: "text-amber-500",
variant: "warning",
text: "Prüfsumme fehlt. Bitte Metadaten aktualisieren.",
@@ -63,20 +64,24 @@
case SyncState.SINGLE_SIDED:
return {
icon: CircleIcon,
color: "text-green-600",
weight: "bold",
color: "text-emerald-600",
variant: "info",
text: `Datei existiert nur ${type === "buzzer" ? "auf dem Buzzer" : "lokal"}.`,
};
case SyncState.SYNCED:
return {
icon: CheckCircleIcon,
color: "text-green-600",
weight: "fill",
color: "text-emerald-500",
variant: "info",
text: "Datei ist synchronisiert.",
};
case SyncState.CONFLICT:
return {
icon: WarningIcon,
ping: true,
weight: "fill",
color: "text-amber-600",
variant: "warning",
text: `Konflikt: Name/Tags weichen ab. Vergleiche mit <span class="text-medium text-on-surface bg-white rounded px-1">${syncStatus.linkedFiles[0]}</span> ${type === "buzzer" ? "lokal" : "auf dem Buzzer"}`,
@@ -88,6 +93,8 @@
: `Mehrfach vorhanden: Gleicher Inhalt auch in <span class="text-medium text-on-surface bg-white rounded px-1">${syncStatus.linkedFiles.join("</span> <span class='text-medium text-on-surface bg-white rounded px-1'>")}</span>`;
return {
icon: WarningCircleIcon,
ping: true,
weight: "fill",
color: "text-red-600",
variant: "danger",
text: duplicateText,
@@ -158,6 +165,7 @@
></div>
{/if}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<button
class="relative z-10 w-full text-left flex-1 px-3 py-1 pr-16 flex items-center border-l-4 transition-colors border-b border-b-border-card
{file.selected ? 'border-l-blue-600' : 'border-l-transparent'}
@@ -169,11 +177,15 @@
{$isTransferingRemote ? 'cursor-default' : ''}
{state === 'pending' ? 'grayscale opacity-80' : ''}"
on:click={toggleSelection}
on:mouseenter|stopPropagation={() => (menuOpen = true)}
on:mouseleave|stopPropagation={() => (menuOpen = false)}
on:blur|stopPropagation={() => (menuOpen = false)}
on:focus|stopPropagation={() => (menuOpen = true)}
disabled={$isTransferingRemote}
>
<MusicNotesIcon weight="fill" class="mr-3 w-5 h-5 shrink-0" />
<MusicNotesIcon weight="fill" class="mr-2 w-5 h-5 shrink-0" />
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
<div class="flex flex-col flex-1 min-w-0 overflow-hidden pl-1">
<div class="flex items-center min-w-0">
<span class="font-light truncate min-w-0 text-sm">
{file.name || "Unbekannte Datei"}
@@ -193,11 +205,26 @@
variant: statusConfig.variant,
}}
>
<svelte:component
this={statusConfig.icon}
weight="fill"
class="mr-1 shrink-0 w-3.5 h-3.5 {statusConfig.color}"
/>
{#if statusConfig.ping}
<span class="mr-1 relative inline-flex size-3.5">
<svelte:component
this={statusConfig.icon}
weight={statusConfig.weight ? statusConfig.weight : "fill"}
class="opacity-57 text-amber-600 absolute inline-flex h-full w-full animate-ping"
/>
<svelte:component
this={statusConfig.icon}
weight={statusConfig.weight ? statusConfig.weight : "fill"}
class="shrink-0 w-3.5 h-3.5 {statusConfig.color}"
/>
</span>
{:else}
<svelte:component
this={statusConfig.icon}
weight={statusConfig.weight ? statusConfig.weight : "fill"}
class="mr-1 shrink-0 w-3.5 h-3.5 {statusConfig.color}"
/>
{/if}
</span>
{/if}
@@ -222,26 +249,32 @@
</div>
</button>
<div class="menu-btn-grp group/menu" class:is-open={menuOpen}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<div
class="menu-btn-grp group/menu"
class:is-open={menuOpen}
on:mouseout|stopPropagation={() => (menuOpen = false)}
>
<div
class="flex items-center overflow-hidden transition-all duration-300 ease-in-out
{menuOpen
? 'max-w-[120px] opacity-100'
: 'max-w-0 opacity-0 group-hover/menu:max-w-[120px] group-hover/menu:opacity-100'}"
>
<button
class="menu-btn danger"
title="Löschen"
on:click|stopPropagation={handleDeleteClick}
>
<button class="menu-btn danger" title="Löschen" on:click|stopPropagation={handleDeleteClick}>
<TrashIcon class="list-menu-icon" />
</button>
<button
class="menu-btn"
title="Abspielen"
on:click|stopPropagation={() => {
console.log("Play", file.name);
menuOpen = false;
if (type === "buzzer") {
addToast;
} else {
playLocalFile(file.name);
menuOpen = false;
}
}}
>
<PlayIcon class="list-menu-icon" />

View File

@@ -10,6 +10,7 @@
import FileMenuOverlay from "./FileMenuOverlay.svelte";
import FileTagEditor from "./FileTagEditor.svelte";
import FileListMenu from "./FileListMenu.svelte";
import AudioDropzone from "./AudioDropzone.svelte";
let showOverlay = false;
let isTransferFinished = false;
@@ -18,6 +19,7 @@
let showBuzzerMenu = false;
let editModeType: "local" | "buzzer" | null = null;
let fileToEdit: string | null = null;
let isFileDragOver = false;
$: currentDevice = $pairedDevices.find((d) => d.id === $activeDeviceId);
@@ -57,26 +59,7 @@
</script>
<div class="main-layout mt-16 lg:mt-20 pb-12">
<section class="buzzer-card flex flex-col">
<div class="card-header">
<h3 class="card-title">Dateiverarbeitung</h3>
<button class="btn" aria-label="Einstellungen">
<GearIcon class="icon" />
</button>
</div>
<div class="card-body p-4">
<div class="flex justify-center items-center">
<div class="text-center text-2xs tracking-tight font-mono font-semibold">
16kHz 16bit MONO | <span class="text-emerald-700">NORMALIZER ON</span>
|
<span class="text-red-700">COMPRESSOR OFF</span>
</div>
</div>
<div class="flex items-center justify-center">
<CloudArrowUpIcon class="w-24 h-24 text-slate-500" />
</div>
</div>
</section>
<AudioDropzone />
<section class="buzzer-card flex flex-col">
<div class="card-header">

View File

@@ -0,0 +1,23 @@
export function clickOutside(node: HTMLElement, callback: () => void) {
const handleClick = (event: MouseEvent | TouchEvent) => {
// Prüfen, ob der Klick auf ein Element außerhalb des Containers erfolgte
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
callback();
}
};
// Event-Listener in der Capture-Phase hinzufügen (true)
document.addEventListener('click', handleClick, true);
document.addEventListener('touchstart', handleClick, { passive: true, capture: true });
return {
update(newCallback: () => void) {
callback = newCallback;
},
destroy() {
// Event-Listener beim Zerstören der Komponente entfernen
document.removeEventListener('click', handleClick, true);
document.removeEventListener('touchstart', handleClick, true);
}
};
}

View File

@@ -0,0 +1,131 @@
export interface AudioProcessingOptions {
lowCut: boolean;
lowCutFreq: number;
compress: boolean;
compressorThreshold: number;
compressorRatio: number;
compressorKnee: number;
compressorAttack: number;
compressorRelease: number;
normalize: boolean;
normalizeTargetDb: number;
}
export const DEFAULT_AUDIO_OPTIONS: AudioProcessingOptions = {
lowCut: true,
lowCutFreq: 150,
compress: true,
compressorThreshold: -45, // Drastisch gesenkt: Erfasst auch sehr leises Flüstern
compressorRatio: 8, // Starke Kompression (8:1)
compressorKnee: 10, // Härterer Übergang (Hard Knee), damit der Kompressor direkter zupackt
compressorAttack: 0.005, // Etwas langsamer (5ms), lässt die initialen Konsonanten für die Sprachverständlichkeit durch
compressorRelease: 0.15, // Schnelleres Release (150ms), zieht leise Passagen zwischen Wörtern schneller hoch
normalize: true,
normalizeTargetDb: -0.5,
};
/**
* Konvertiert Float32 Samples (-1.0 bis 1.0) in Int16 PCM (-32768 bis 32767)
*/
function floatToInt16(float32Data: Float32Array): Int16Array {
const int16Data = new Int16Array(float32Data.length);
for (let i = 0; i < float32Data.length; i++) {
// Hard-Clipping verhindern, falls Signale leicht übersteuern
const s = Math.max(-1, Math.min(1, float32Data[i]));
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16Data;
}
/**
* Führt eine Peak-Normalisierung basierend auf einem dBFS-Zielwert durch.
*/
function applyNormalization(buffer: AudioBuffer, targetDb: number) {
const data = buffer.getChannelData(0);
let maxPeak = 0;
// 1. Höchsten absoluten Peak finden
for (let i = 0; i < data.length; i++) {
const absValue = Math.abs(data[i]);
if (absValue > maxPeak) maxPeak = absValue;
}
// 2. Multiplikator berechnen und anwenden
if (maxPeak > 0) {
const targetLinear = Math.pow(10, targetDb / 20);
const factor = targetLinear / maxPeak;
for (let i = 0; i < data.length; i++) {
data[i] *= factor;
}
}
}
export async function processAudioFile(
file: File,
// Partial bedeutet: Es müssen nicht alle Felder übergeben werden
userOptions: Partial<AudioProcessingOptions> = {}
): Promise<{ buffer: ArrayBuffer; name: string }> {
// Fügt die Standardwerte mit den User-Werten zusammen.
// Was der User nicht mitschickt, wird durch Defaults aufgefüllt.
const options = { ...DEFAULT_AUDIO_OPTIONS, ...userOptions };
// Cast auf ArrayBuffer, um TypeScript bzgl. SharedArrayBuffer zu beruhigen
const rawArrayBuffer = await file.arrayBuffer() as ArrayBuffer;
const audioCtx = new window.AudioContext();
const sourceBuffer = await audioCtx.decodeAudioData(rawArrayBuffer);
// Ziel-Kontext: Exakt 16kHz, Mono (1 Kanal), Länge berechnet aus Originaldauer
const offlineCtx = new window.OfflineAudioContext(1, sourceBuffer.duration * 16000, 16000);
const source = offlineCtx.createBufferSource();
source.buffer = sourceBuffer;
let lastNode: AudioNode = source;
// 1. Low-Cut Filter (Highpass)
if (options.lowCut) {
const filter = offlineCtx.createBiquadFilter();
filter.type = "highpass";
// Jetzt ist options.lowCutFreq garantiert eine Zahl
filter.frequency.setValueAtTime(options.lowCutFreq, offlineCtx.currentTime);
lastNode.connect(filter);
lastNode = filter;
}
// 2. Dynamics Compressor
if (options.compress) {
const compressor = offlineCtx.createDynamicsCompressor();
// Hier knallt es nicht mehr, weil options.compressorThreshold existiert
compressor.threshold.setValueAtTime(options.compressorThreshold, offlineCtx.currentTime);
compressor.knee.setValueAtTime(options.compressorKnee, offlineCtx.currentTime);
compressor.ratio.setValueAtTime(options.compressorRatio, offlineCtx.currentTime);
compressor.attack.setValueAtTime(options.compressorAttack, offlineCtx.currentTime);
compressor.release.setValueAtTime(options.compressorRelease, offlineCtx.currentTime);
lastNode.connect(compressor);
lastNode = compressor;
}
console.log("AudioProcessor: Audioeffekte angewendet", { options });
// Kette abschließen und Rendering starten
lastNode.connect(offlineCtx.destination);
source.start(0);
const renderedBuffer = await offlineCtx.startRendering();
// 3. Normalisierung
if (options.normalize) {
applyNormalization(renderedBuffer, options.normalizeTargetDb);
}
// 4. In Zielformat (16-bit PCM) wandeln
const pcm16 = floatToInt16(renderedBuffer.getChannelData(0));
return {
buffer: pcm16.buffer as ArrayBuffer,
// Original-Dateiendung entfernen
name: file.name.replace(/\.[^/.]+$/, "")
};
}

View File

@@ -25,7 +25,7 @@ export async function saveLocalFile(name: string, blob: Blob, size: number): Pro
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
// Speichert das Blob zusammen mit Metadaten
store.put({ name, blob, size, timestamp: Date.now() });
@@ -51,7 +51,7 @@ export async function deleteLocalFile(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.delete(name);
request.onsuccess = () => resolve();
@@ -59,7 +59,7 @@ export async function deleteLocalFile(name: string): Promise<void> {
});
}
export async function getLocalFile(name: string): Promise<{name: string, blob: Blob, size: number} | undefined> {
export async function getLocalFile(name: string): Promise<{ name: string, blob: Blob, size: number } | undefined> {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
@@ -69,4 +69,65 @@ export async function getLocalFile(name: string): Promise<{name: string, blob: B
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
let currentSource: AudioBufferSourceNode | null = null;
export async function playLocalFile(name: string): Promise<void> {
// Falls bereits etwas spielt: Stoppen und aufräumen
if (currentSource) {
try {
currentSource.stop();
} catch (e) {
// Ignorieren, falls die Quelle bereits natürlich gestoppt war
}
currentSource = null;
}
const fileEntry = await getLocalFile(name);
if (!fileEntry) throw new Error('Datei nicht gefunden');
const fullBuffer = await fileEntry.blob.arrayBuffer();
let audioByteLength = fullBuffer.byteLength;
// Metadaten-Limit berechnen (Footer an EOF - 8)
if (fullBuffer.byteLength >= 8) {
const view = new DataView(fullBuffer);
const footerOffset = fullBuffer.byteLength - 8;
const magic = new TextDecoder().decode(new Uint8Array(fullBuffer, footerOffset + 4, 4));
if (magic === "TAG!") {
const totalTagSize = view.getUint16(footerOffset, true); //
if (totalTagSize <= fullBuffer.byteLength) {
audioByteLength = fullBuffer.byteLength - totalTagSize;
}
}
}
const numSamples = Math.floor(audioByteLength / 2);
const int16Data = new Int16Array(fullBuffer, 0, numSamples);
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = audioCtx.createBuffer(1, numSamples, 16000);
const float32Data = audioBuffer.getChannelData(0);
for (let i = 0; i < numSamples; i++) {
float32Data[i] = int16Data[i] / 32768.0;
}
const source = audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioCtx.destination);
// Referenz speichern, bevor wir starten
currentSource = source;
// Wenn die Datei natürlich endet, Referenz löschen
source.onended = () => {
if (currentSource === source) {
currentSource = null;
}
};
source.start();
}

View File

@@ -24,7 +24,9 @@ export const FRAME = {
export const DATA = {
PROTO_INFO: 0x01,
DEVICE_INFO: 0x02,
FS_INFO: 0x03,
FW_INFO: 0x04,
FILE_GET: 0x20,
FILE_PUT: 0x21,
@@ -56,4 +58,11 @@ export const ZEPHYR_ERRORS: Record<number, ZephyrError> = {
36: { text: "Dateiname oder Pfad zu lang", zephyr: "ENAMETOOLONG" },
88: { text: "Funktion im Buzzer nicht implementiert", zephyr: "ENOSYS" },
134: { text: "Operation nicht unterstützt", zephyr: "ENOTSUP" }
};
};
export const FW_STATUS = {
CONFIRMED: 0x00,
PENDING: 0x01,
TESTING: 0x02,
UNKNOWN: 0xFF,
}

View File

@@ -1,5 +1,5 @@
import { FRAME, DATA, ZEPHYR_ERRORS } from './constants';
import { protocolInfo, fsInfo, transferStats, resetTransferStats, transferDetails } from '../store';
import { protocolInfo, deviceInfo, fsInfo, transferStats, fwInfo, resetTransferStats, transferDetails } from '../store';
import { addToast } from '../toast';
import { SETTINGS } from '../settings';
import { crc32 } from './crc32';
@@ -48,20 +48,42 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
switch (frameType) {
case FRAME.RESPONSE:
const dataType = view.getUint8(3);
if (dataType === DATA.PROTO_INFO && payloadLength >= 5) {
const version = view.getUint16(4, true);
const maxChunkSize = view.getUint16(6, true);
protocolInfo.set({ version, maxChunkSize });
} else if (dataType === DATA.FS_INFO && payloadLength >= 14) {
const totalSizeBytes = view.getUint32(4, true);
const freeSizeBytes = view.getUint32(8, true);
const maxPathLength = view.getUint8(12);
const sysPathLength = view.getUint8(13);
const audioPathLength = view.getUint8(14);
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength));
fsInfo.set({ totalSize: totalSizeBytes / 1024 / 1024, freeSize: freeSizeBytes / 1024 / 1024, maxPathLength, sysPath, audioPath });
switch (dataType) {
case DATA.PROTO_INFO:
const version = view.getUint16(4, true);
const maxChunkSize = view.getUint16(6, true);
protocolInfo.set({ version, maxChunkSize });
break;
case DATA.FS_INFO:
const totalSizeBytes = view.getUint32(4, true);
const freeSizeBytes = view.getUint32(8, true);
const maxPathLength = view.getUint8(12);
const sysPathLength = view.getUint8(13);
const audioPathLength = view.getUint8(14);
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength));
fsInfo.set({ totalSize: totalSizeBytes / 1024 / 1024, freeSize: freeSizeBytes / 1024 / 1024, maxPathLength, sysPath, audioPath });
break;
case DATA.DEVICE_INFO:
const deviceId = [0, 2, 4, 6]
.map(offset => view.getUint16(4 + offset, false).toString(16).padStart(4, '0').toUpperCase())
.join('-');
const boardNameLength = view.getUint8(12);
const boardRevisionLength = view.getUint8(13);
const socNameLength = view.getUint8(14);
const boardName = new TextDecoder().decode(new Uint8Array(view.buffer, 15, boardNameLength));
const boardRevision = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength, boardRevisionLength));
const socName = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength + boardRevisionLength, socNameLength));
deviceInfo.set({ deviceId, boardName, boardRevision, socName });
break;
case DATA.FW_INFO:
const fwStatus = view.getUint8(4);
const slot1Size = view.getUint32(5, true);
const fw_version_length = view.getUint8(9);
const kernel_version_length = view.getUint8(10);
const fwVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11, fw_version_length));
const kernelVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11 + fw_version_length, kernel_version_length));
fwInfo.set({ fwStatus, slot1Size, fwVersion, kernelVersion });
}
break;
@@ -92,9 +114,10 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
console.warn(`LS Stream: Erwartete Anzahl Einträge laut Header: ${total}, tatsächlich empfangen: ${lsBuffer.length}`);
addToast(`Warnung: LS Stream erwartete ${total} Einträge, aber nur ${lsBuffer.length} empfangen.`, 'warning');
} else if (lsResolve) {
lsResolve([...lsBuffer]);
const currentResolve = lsResolve;
lsResolve = null;
lsReject = null;
currentResolve([...lsBuffer]);
}
break;
@@ -137,10 +160,12 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
addToast("Dateitransfer abgebrochen (Timeout)", "error");
if (fileGetReject) {
fileGetReject(new Error("Timeout beim Dateitransfer"));
fileGetResolve = null;
fileGetReject = null;
const currentReject = fileGetReject;
fileGetResolve = null;
fileGetReject = null;
if (currentReject) {
currentReject(new Error("Timeout beim Dateitransfer"));
}
return;
}
@@ -206,34 +231,39 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
if (fileTransfer.mode === 'file') {
const fileName = get(transferStats).currentFileName;
const currentResolve = fileGetResolve;
const currentReject = fileGetReject;
// Direkt hier aufräumen, um Race Conditions bei schnellen Folge-Transfers zu vermeiden
fileGetResolve = null;
fileGetReject = null;
saveLocalFile(fileName, fileBlob, fileTransfer.totalBytes)
.then(() => {
refreshLocal();
if (fileGetResolve) {
fileGetResolve({ success: true });
if (currentResolve) {
currentResolve({ success: true });
}
})
.catch(err => {
console.error("Datenbankfehler:", err);
addToast(`Speichern von ${fileName} fehlgeschlagen.`, 'error');
if (fileGetReject) fileGetReject(err);
})
.finally(() => {
fileGetResolve = null;
fileGetReject = null;
if (currentReject) currentReject(err);
});
} else {
// TAGS Modus: Blob direkt zurückgeben, nichts speichern
if (fileGetResolve) fileGetResolve({ success: true, blob: fileBlob });
const currentResolve = fileGetResolve;
fileGetResolve = null;
fileGetReject = null;
if (currentResolve) currentResolve({ success: true, blob: fileBlob });
}
} else {
console.error("[CRC] Mismatch! Datei beschädigt.");
addToast("CRC Fehler: Datei wurde fehlerhaft übertragen.", "error");
if (fileGetReject) fileGetReject(new Error("CRC Mismatch"));
const currentReject = fileGetReject;
fileGetResolve = null;
fileGetReject = null;
if (currentReject) currentReject(new Error("CRC Mismatch"));
}
break;
@@ -260,16 +290,18 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
console.error(`Received error frame with code: 0x${errorCode.toString(16)}`);
showErrorToast(errorCode);
if (lsReject) {
lsReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
const currentReject = lsReject;
lsResolve = null;
lsReject = null;
currentReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
}
if (fileGetReject && fileTransfer.active) {
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false;
fileGetReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
const currentReject = fileGetReject;
fileGetResolve = null;
fileGetReject = null;
currentReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
}
if (uploadState.active && uploadState.onError) {
uploadState.onError(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
@@ -293,6 +325,17 @@ export function buildProtocolInfoRequest(): ArrayBuffer {
return buffer;
}
export function buildDeviceInfoRequest(): ArrayBuffer {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, 1, true);
view.setUint8(3, DATA.DEVICE_INFO);
return buffer;
}
export function buildFSInfoRequest(): ArrayBuffer {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
@@ -304,6 +347,17 @@ export function buildFSInfoRequest(): ArrayBuffer {
return buffer;
}
export function buildFWInfoRequest(): ArrayBuffer {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, 1, true);
view.setUint8(3, DATA.FW_INFO);
return buffer;
}
export function buildLSRequest(path: string): ArrayBuffer {
const encoder = new TextEncoder();
const pathBytes = encoder.encode(path);
@@ -342,9 +396,10 @@ function resetLsWatchdog() {
addToast("Verzeichnis-Streaming abgebrochen (Timeout)", "warning");
lsBuffer = [];
if (lsReject) {
lsReject(new Error("Timeout beim Lesen des Verzeichnisses"));
const currentReject = lsReject;
lsResolve = null;
lsReject = null;
currentReject(new Error("Timeout beim Lesen des Verzeichnisses"));
}
}, 3000);
}

View File

@@ -1,3 +1,5 @@
import type { AudioProcessingOptions } from './types';
export const SETTINGS = {
storage: {
connectionKey: 'buzzer_connection_state'
@@ -13,4 +15,32 @@ export const SETTINGS = {
transferOverlayPersistMs: 4000,
estimatedInterFileGapMs: 700, // Initialer Schätzwert für die Pause zwischen zwei Dateien
},
};
};
export const AUDIO_PRESETS: Record<'normal' | 'broadcast', Partial<AudioProcessingOptions>> = {
normal: {
compressorThreshold: -45,
compressorRatio: 8,
compressorKnee: 10,
compressorAttack: 0.005,
compressorRelease: 0.15,
},
broadcast: {
// Noch aggressiver für maximale, konstante Lautstärke (Radio-Style)
compressorThreshold: -50,
compressorRatio: 12,
compressorKnee: 2,
compressorAttack: 0.002,
compressorRelease: 0.10,
}
};
export const DEFAULT_AUDIO_OPTIONS: AudioProcessingOptions = {
preset: 'normal',
lowCut: true,
lowCutFreq: 150,
compress: true,
...AUDIO_PRESETS.normal, // Initialisierung mit Normal-Werten
normalize: true,
normalizeTargetDb: -0.5,
} as AudioProcessingOptions;

View File

@@ -1,6 +1,6 @@
import { writable, derived } from 'svelte/store';
import{ type BuzzerFile, SyncState, type SyncStatus } from './types';
import { SETTINGS } from './settings';
import{ type BuzzerFile, SyncState, type SyncStatus, type AudioProcessingOptions } from './types';
import {DEFAULT_AUDIO_OPTIONS, SETTINGS } from './settings';
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
@@ -31,6 +31,13 @@ export interface ProtocolInfo {
maxChunkSize: number;
}
export interface DeviceInfo {
deviceId: string;
boardName: string;
boardRevision: string;
socName: string;
}
export interface FsInfo {
totalSize: number;
freeSize: number;
@@ -39,6 +46,13 @@ export interface FsInfo {
audioPath: string;
}
export interface FwInfo {
fwStatus: number;
slot1Size: number;
fwVersion: string;
kernelVersion: string;
}
export interface StorageUsage {
totalBytes: number;
freeBytes: number;
@@ -65,9 +79,11 @@ export const targetDeviceId = writable<string | null>(null);
export const pairedDevices = writable<BluetoothDevice[]>([]);
export const availableDevices = writable<Set<string>>(new Set()); // IDs der derzeit advertisierten Geräte
// Protokoll- und Dateisystem-Metadaten aus dem Device
// Metadaten aus dem Device
export const protocolInfo = writable<ProtocolInfo | null>(null);
export const deviceInfo = writable<DeviceInfo | null>(null);
export const fsInfo = writable<FsInfo | null>(null);
export const fwInfo = writable<FwInfo | null>(null);
// Dateilisten
export const buzzerAudioFiles = writable<BuzzerFile[]>([]);
@@ -258,7 +274,9 @@ export function resetRemote(): void {
isConnected.set(false);
isConnecting.set(false);
protocolInfo.set(null);
deviceInfo.set(null);
fsInfo.set(null);
fwInfo.set(null);
activeDeviceId.set(null);
buzzerAudioFiles.set([]);
buzzerSysFiles.set([]);
@@ -395,4 +413,29 @@ export const syncStateMap = derived(
return result;
}
);
);
function createAudioOptionsStore() {
// 1. Initialen Wert aus dem Local Storage laden
const stored = localStorage.getItem("edi_audio_options");
// Merge aus Defaults und gespeicherten Werten, falls neue Felder hinzukommen
const initialValue: AudioProcessingOptions = stored
? { ...DEFAULT_AUDIO_OPTIONS, ...JSON.parse(stored) }
: DEFAULT_AUDIO_OPTIONS;
const { subscribe, set, update } = writable<AudioProcessingOptions>(initialValue);
// 2. Bei jeder Änderung automatisch in den Local Storage schreiben
subscribe((currentValue) => {
localStorage.setItem("edi_audio_options", JSON.stringify(currentValue));
});
return {
subscribe,
set,
update,
};
}
export const audioOptions = createAudioOptionsStore();

View File

@@ -1,8 +1,7 @@
import { get } from 'svelte/store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isTransferingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
import { isConnected, deviceInfo, fsInfo, fwInfo, buzzerAudioFiles, buzzerSysFiles, isTransferingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory, getFile, putFile, deleteRemoteFile, requestDeviceInfo, requestFWInfo } from './transport';
import type { BuzzerFile } from './types';
import { getFile, putFile, deleteRemoteFile } from './transport';
import { addToast } from './toast';
import { getLocalFiles, deleteLocalFile, getLocalFile } from './db';
import { parseAudioFileTags } from './tagHandler';
@@ -28,6 +27,8 @@ export async function refreshRemote() {
try {
await requestProtocolInfo();
await requestFSInfo();
await requestFWInfo();
await requestDeviceInfo();
// Kurze Verzögerung für Store-Propagation
await new Promise(r => setTimeout(r, 100));
@@ -230,7 +231,7 @@ export async function uploadSelectedFiles() {
try {
for (const file of files) {
console.debug(`Starte Upload von: ${file.name} (${(file.size / 1024).toFixed(1)} kB)`);
// Resetted die Store-Stats VOR der DB-Abfrage, UI glättet sich sofort
transferStats.update(s => ({
...s,

View File

@@ -1,5 +1,5 @@
import { getLocalFiles, saveLocalFile, deleteLocalFile } from './db';
import type { SystemTags, MetadataTags } from './types';
import type { SystemTags, MetadataTags, AudioFormat } from './types';
import { addToast } from './toast';
import { crc32 } from './protocol/crc32';
import { getTags, putTags, renameRemoteFile } from './transport';
@@ -348,4 +348,22 @@ export async function updateLocalAudioCrc(filename: string): Promise<number> {
await updateLocalFile(filename, filename, updatedSysTags, metaTags);
return newCrc;
}
export async function updateLocalAudioFormat(filename: string, audioFormat: AudioFormat ): Promise<AudioFormat> {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
// Tags auslesen
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, filename);
// Neue CRC berechnen
console.log(`TagHandler: Aktualisiere Audioformat für ${filename}`, { oldFormat: sysTags.format, newFormat: audioFormat });
// Mit aktualisierter CRC speichern
const updatedSysTags = { ...sysTags, format: audioFormat };
await updateLocalFile(filename, filename, updatedSysTags, metaTags);
return audioFormat;
}

View File

@@ -1,7 +1,7 @@
import { buildLSRequest, buildProtocolInfoRequest, buildFSInfoRequest, setLsResolver, buildFileGetRequest, setFileGetResolver, buildTagsGetRequest, uploadState } from './protocol/parser';
import { buildLSRequest, buildProtocolInfoRequest, buildDeviceInfoRequest, buildFSInfoRequest, buildFWInfoRequest, setLsResolver, buildFileGetRequest, setFileGetResolver, buildTagsGetRequest, uploadState } from './protocol/parser';
import { crc32 } from './protocol/crc32';
import { get } from 'svelte/store';
import { protocolInfo, transferStats, } from './store';
import { protocolInfo, transferStats, } from './store';
import { DATA, FRAME } from './protocol/constants';
import { isConnected, resetRemote } from './store';
import { SETTINGS } from './settings';
@@ -26,6 +26,8 @@ export async function handleTransportConnect(sender: FrameSender) {
// Basis-Informationen zwingend vorab laden
await requestProtocolInfo();
await requestFSInfo();
await requestDeviceInfo();
await requestFWInfo();
// Erst wenn diese Basisdaten da sind, wird die UI freigeschaltet
isConnected.set(true);
@@ -44,10 +46,18 @@ export async function requestProtocolInfo() {
await sendFrame(buildProtocolInfoRequest());
}
export async function requestDeviceInfo() {
await sendFrame(buildDeviceInfoRequest());
}
export async function requestFSInfo() {
await sendFrame(buildFSInfoRequest());
}
export async function requestFWInfo() {
await sendFrame(buildFWInfoRequest());
}
let isListing = false;
export async function fetchDirectory(path: string): Promise<any[]> {
@@ -223,8 +233,8 @@ export async function putFile(fileBlob: Blob, remotePath: string, fileNameForUI:
}
// Abschließendes UI Update
transferStats.update(s => ({
...s,
transferStats.update(s => ({
...s,
bytesDone: fileData.length,
overallDone: s.overallDone + (fileData.length - s.bytesDone)
}));
@@ -269,18 +279,18 @@ export async function putFile(fileBlob: Blob, remotePath: string, fileNameForUI:
export async function deleteRemoteFile(fullPath: string): Promise<void> {
const pathBytes = new TextEncoder().encode(fullPath);
const payloadLength = 1 + 1 + pathBytes.length; // data_type(1) + path_length(1) + path
const buffer = new ArrayBuffer(3 + payloadLength);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, payloadLength, true);
view.setUint8(3, 0x24); // BUZZ_DATA_RM_FILE
view.setUint8(4, pathBytes.length);
uint8View.set(pathBytes, 5);
await sendFrame(buffer);
// Kurze Wartezeit, bis der Parser SUCCESS verarbeitet und der Flash fertig ist
await new Promise(resolve => setTimeout(resolve, 200));
@@ -289,23 +299,23 @@ export async function deleteRemoteFile(fullPath: string): Promise<void> {
export async function renameRemoteFile(oldFullPath: string, newFullPath: string): Promise<void> {
const oldBytes = new TextEncoder().encode(oldFullPath);
const newBytes = new TextEncoder().encode(newFullPath);
const payloadLength = 1 + 1 + 1 + oldBytes.length + newBytes.length; // data_type + 2x len + 2x string
const buffer = new ArrayBuffer(3 + payloadLength);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, payloadLength, true);
view.setUint8(3, 0x25); // BUZZ_DATA_RENAME_FILE
view.setUint8(4, oldBytes.length);
view.setUint8(5, newBytes.length);
uint8View.set(oldBytes, 6);
uint8View.set(newBytes, 6 + oldBytes.length);
await sendFrame(buffer);
// Kurze Wartezeit, bis der Parser SUCCESS verarbeitet und der Flash fertig ist
await new Promise(resolve => setTimeout(resolve, 200));

View File

@@ -40,4 +40,18 @@ export enum SyncState {
export interface SyncStatus {
state: SyncState;
linkedFiles: string[]; // Referenzen auf die Dateinamen der Gegenseite (für Tooltips)
}
export interface AudioProcessingOptions {
preset: 'normal' | 'broadcast' | 'custom';
lowCut: boolean;
lowCutFreq: number;
compress: boolean;
compressorThreshold: number;
compressorRatio: number;
compressorKnee: number;
compressorAttack: number;
compressorRelease: number;
normalize: boolean;
normalizeTargetDb: number;
}