initial commit.
This commit is contained in:
parent
938cf06e82
commit
562356de18
|
|
@ -0,0 +1,195 @@
|
|||
# VoicePult
|
||||
|
||||
Ein lokales, responsives Audio-Patchpanel für Linux mit PipeWire-Pulse oder PulseAudio. Anwendungen werden auf virtuelle Kanäle gelegt. Jeder Kanal kann gleichzeitig auf einen gewählten Geräteausgang und in ein virtuelles Mikrofon spielen.
|
||||
|
||||
## Automatische Einrichtung auf einem neuen Computer
|
||||
|
||||
Den **gesamten VoicePult-Ordner** auf den neuen Linux-Rechner kopieren und darin ausführen:
|
||||
|
||||
```bash
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
Alternativ bei gesetztem Ausführungsrecht: `./setup.sh`. **Als normalen Benutzer in der angemeldeten Desktop-Sitzung ausführen, nicht mit `sudo`.** Das Setup fordert das sudo-Passwort selbst an, wenn Systempakete fehlen. Für Paketdownloads ist Internet erforderlich; VoicePult selbst arbeitet danach lokal.
|
||||
|
||||
Das Setup:
|
||||
|
||||
1. Prüft Python ≥ 3.10, `pactl`, Browser und `xdg-open`; installiert fehlende Pakete über **apt** (Debian/Ubuntu/Mint), **dnf** (Fedora) oder **pacman** (Arch/Manjaro).
|
||||
2. Nutzt einen vorhandenen PulseAudio-/PipeWire-Server. Fehlt die Audio-Umgebung vollständig, installiert es PipeWire-Pulse und WirePlumber. Ein vorhandener Audiostack wird nicht ersetzt.
|
||||
3. Installiert eine eigenständige Programmkopie unter `~/.local/share/voicepult/app` und einen Eintrag **VoicePult** im Anwendungsmenü.
|
||||
4. Richtet Autostart nach Benutzeranmeldung als `systemd --user`-Dienst ein; ohne erreichbare systemd-Benutzersitzung über einen XDG-Autostart-Eintrag.
|
||||
5. Startet VoicePult, prüft das virtuelle Eingabegerät **VoicePult Mikrofon** und öffnet die Oberfläche im Browser. Beim späteren Anmelden startet der Audioteil im Hintergrund, ohne ein Browserfenster zu öffnen.
|
||||
|
||||
Persönliche Einstellungen liegen bei dieser Installation in `~/.config/voicepult/config.json`. Die Standardpfade berücksichtigen `XDG_DATA_HOME`, `XDG_CONFIG_HOME` und `XDG_STATE_HOME`. Die ursprüngliche Projektkopie darf nach der Installation verschoben werden. Einstellungen aus deren `.voicepult`-Ordner werden bewusst nicht importiert: Audiogeräte eines anderen Rechners passen nicht zur neuen Installation. Vorhandene Einstellungen der **installierten** Version bleiben bei erneutem Setup erhalten.
|
||||
|
||||
Nützliche Optionen:
|
||||
|
||||
```bash
|
||||
./setup.sh --dry-run # Nur anzeigen, nichts ändern
|
||||
./setup.sh --no-autostart # Installation ohne Anmeldestart
|
||||
./setup.sh --no-open # Starten, ohne Browser zu öffnen
|
||||
./setup.sh --no-start # Nur installieren, keine Dienste starten
|
||||
./setup.sh --skip-packages --no-start # Voraussetzungen extern bereitstellen
|
||||
```
|
||||
|
||||
`--no-start` lässt den konfigurierten Autostart bestehen. Für eine reine Dateieinrichtung zusätzlich `--no-autostart` angeben. Die Optionen können kombiniert werden. Erneute Ausführung aktualisiert die Programmdateien; ein laufender verwalteter Dienst wird bei normalem Setup neu gestartet. Eine noch manuell gestartete Projektinstanz wird nicht ungefragt beendet; das Setup weist auf den nötigen Wechsel zur installierten Version hin.
|
||||
|
||||
Bei systemd-Installation:
|
||||
|
||||
```bash
|
||||
systemctl --user status voicepult.service
|
||||
journalctl --user -u voicepult.service -n 80
|
||||
systemctl --user restart voicepult.service
|
||||
systemctl --user stop voicepult.service
|
||||
systemctl --user disable voicepult.service # Autostart ausschalten
|
||||
```
|
||||
|
||||
Ohne systemd liegen Servermeldungen in `~/.local/state/voicepult/server.log`; der Autostart liegt in `~/.config/autostart/voicepult.desktop`. Das Setup installiert keine Desktop-Umgebung und keine Hardwaretreiber. Ein funktionsfähiger Linux-Desktop wird vorausgesetzt; Windows/macOS werden nicht unterstützt. Bei Arch wird keine vollständige Systemaktualisierung ausgelöst. Falls die Paketdatenbank veraltet ist, das System regulär aktualisieren und das Setup erneut ausführen.
|
||||
|
||||
Die Installationslogik ist automatisiert mit isolierten Zielverzeichnissen, wiederholter Installation und Pfaden mit Leer-/Sonderzeichen getestet. Paketinstallationen auf frischen Distributions-VMs sind noch nicht getestet. Paketzuordnung nach den offiziellen Verzeichnissen: [Ubuntu PipeWire-Pulse](https://packages.ubuntu.com/en/noble/pipewire-pulse), [Fedora PulseAudio-Werkzeuge](https://packages.fedoraproject.org/pkgs/pulseaudio/pulseaudio-utils/), [Fedora PipeWire](https://packages.fedoraproject.org/pkgs/pipewire/), [Arch PipeWire](https://wiki.archlinux.org/title/PipeWire).
|
||||
|
||||
## Direkt aus dem Projekt starten (ohne Installation)
|
||||
|
||||
Voraussetzungen: Python 3.10 oder neuer, `pactl` und ein laufender PipeWire-Pulse-/PulseAudio-Server in deiner normalen Desktop-Sitzung. Keine Python-/npm-Pakete erforderlich. Unter Debian/Ubuntu gehört `pactl` zu `pulseaudio-utils`.
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Danach **http://127.0.0.1:8765** im Browser öffnen. Nicht mit `sudo` starten: Audio läuft im Kontext deines angemeldeten Benutzers. Die App läuft, solange der Server läuft; das Browserfenster darf geschlossen werden. Beenden mit `Strg+C` im Serverterminal. Dabei werden zugeordnete Audiostreams zurückgeleitet und die eigenen virtuellen Geräte entfernt.
|
||||
|
||||
Anderer Port:
|
||||
|
||||
```bash
|
||||
./start.sh --port 8766
|
||||
```
|
||||
|
||||
## Firefox auf Kopfhörer und Mikrofon
|
||||
|
||||
1. Einen Kanal erstellen, beispielsweise **Browser**.
|
||||
2. In Firefox Audio abspielen, damit ein Stream erscheint. Bei **Firefox** den Kanal **Browser** auswählen.
|
||||
3. Am Kanal **Kopfhörer** und **Mikrofon-Mix** einschalten.
|
||||
4. Rechts als Ausgabegerät den Kopfhörer-Ausgang auswählen.
|
||||
5. In Discord, OBS, Firefox oder der Konferenz-App **VoicePult Mikrofon** als Eingabegerät auswählen.
|
||||
VoicePult zeigt unter dem Mikrofon-Mix an, welche laufenden Anwendungen das virtuelle Eingabegerät tatsächlich verwenden. Falls eine Anwendung ihre Geräteliste nicht aktualisiert, die Audioeinstellungen neu öffnen oder die Anwendung neu starten. „VoicePult_Mix“ ist der interne Misch-Ausgang; als App-Eingabe wird „VoicePult Mikrofon“ verwendet.
|
||||
6. Für die eigene Stimme rechts **Stimme direkt in den Mix** aktivieren und das echte Mikrofon auswählen.
|
||||
|
||||
Ein physisches Mikrofon ist ein Eingabegerät. Deshalb stellt VoicePult ein zusätzliches virtuelles Mikrofon bereit, in dem Anwendungs-Audio und optional deine Stimme zusammenlaufen. Gesprächspartner hören diesen Mix erst, wenn die Gesprächs-App dieses Eingabegerät verwendet. Die Gesprächs-App selbst sollte nicht in denselben Mikrofon-Mix geleitet werden, um Echos zu vermeiden. Rauschunterdrückung in Gesprächs-Apps kann Musik im Mix unterdrücken.
|
||||
|
||||
## Live-Wellenform, Darkmode und Mehrfach-Routing
|
||||
|
||||
Oben rechts schaltet der **Darkmode-Schieber** das Erscheinungsbild um. Im Live-Panel wählst du den Mikrofon-Mix oder einen Kanal als Signal. Die Anzeige wertet echte PCM-Daten aus, zeigt Stereo-Wellenformen und RMS-Pegel in dBFS und markiert Vollaussteuerung. **Wellenform vergrößern** reicht von ×1 bis ×32 (anfangs ×8); dies verändert ausschließlich die Darstellung, nicht das Audiosignal oder den gemessenen Pegel. Mit **Anzeige pausieren** endet die Auswertung nach spätestens etwa zwei Sekunden ohne weitere Abrufe. Audiodaten bleiben nur kurz im Arbeitsspeicher und werden nicht als Aufnahme gespeichert.
|
||||
|
||||
Anwendungen und **Dein Mikrofon** besitzen jetzt Kanal-Checkboxen. Mehrere Häkchen senden dasselbe Signal gleichzeitig an mehrere Kanäle; Lautstärke, Stummschaltung und Ausgabeziele sind pro Kanal unabhängig. Ohne App-Häkchen erfolgt die Wiedergabe direkt auf dem Geräteausgang. **Stimme direkt in den Mix** ist ein zusätzlicher direkter Mikrofonweg. Wenn dieses Signal außerdem über einen Kanal in denselben Mix gelangt, wird es doppelt addiert. Den direkten Weg gegebenenfalls ausschalten.
|
||||
|
||||
Darkmode, Wellenform-Vergrößerung, ausgewähltes Signal, Pause, Kanalzuordnungen, Mikrofonwege, Geräte, Lautstärken und Stummschaltungen werden serverseitig gespeichert und nach Neustart wiederhergestellt. Die installierte Version verwendet `~/.config/voicepult/config.json`. Alte Einzelkanal-Zuordnungen werden automatisch in die Mehrfachauswahl übernommen.
|
||||
|
||||
## Zugriff von Handy und Tablet
|
||||
|
||||
```bash
|
||||
./setup.sh --lan
|
||||
```
|
||||
|
||||
Danach auf dem Rechner in VoicePult unten **Handy & Tablet** öffnen. Handy/Tablet mit demselben erreichbaren lokalen Netzwerk verbinden, die angezeigte IP-Adresse mit Port `8765` im Browser öffnen und den **Zugangscode** eingeben. Der Code bleibt bei Neustarts erhalten und wird auf dem Mobilgerät lokal gespeichert. Ohne Code sind im Netzwerk weder Status, Audiodaten noch Änderungen zugänglich. Auf dem Rechner selbst funktioniert der Zugriff über `127.0.0.1` weiterhin ohne Code.
|
||||
|
||||
Die Verbindung verwendet HTTP und ist für ein vertrauenswürdiges privates Netzwerk vorgesehen; keine Router-Portweiterleitung einrichten. Der Code ist keine TLS-Verschlüsselung. Audio bleibt auf dem Rechner; das Mobilgerät ist Fernbedienung und Pegelanzeige. Falls die Rechner-IP per DHCP wechselt, die neue Adresse unten in VoicePult verwenden. Gast-WLAN bzw. Client-Isolation kann Verbindungen zwischen Geräten verhindern. Eine aktive Rechner-Firewall muss TCP-Port 8765 aus dem lokalen Netz erlauben; das Setup verändert keine Firewall-Regeln.
|
||||
|
||||
`--lan` wird in der installierten Startkonfiguration gespeichert. Ohne Installation: `./start.sh --lan`. Ohne LAN-Option ist die ursprüngliche Projektversion weiterhin nur unter `127.0.0.1` erreichbar. Die Netzwerkadresse wird beim Serverstart ermittelt.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Bis zu 16 virtuelle Kanäle erstellen, umbenennen und löschen.
|
||||
- Mehrere Anwendungen auf einem Kanal mischen.
|
||||
- Kopfhörer und Mikrofon-Mix je Kanal unabhängig schalten.
|
||||
- Lautstärke und Stummschaltung je Kanal; beide Ziele werden gemeinsam geregelt.
|
||||
- Physisches Mikrofon optional dem Mikrofon-Mix hinzufügen.
|
||||
- Audiostreams alle zwei Sekunden neu erkennen und gespeicherte App-Zuordnungen wieder anwenden.
|
||||
- Konfiguration lokal in `.voicepult/config.json` speichern und beim nächsten Start wiederherstellen.
|
||||
- Beim Entfernen einer Zuordnung oder eines Kanals auf den ursprünglichen Ausgang zurückschalten; wenn dieser nicht mehr verfügbar ist, einen verfügbaren Geräteausgang verwenden.
|
||||
- Nur eigene Audiomodule verwalten; Module nach einem unvorhergesehenen Prozessabbruch beim nächsten Start bereinigen.
|
||||
- Desktop-, Tablet- und Mobil-Layout; Tastaturbedienung und beschriftete Bedienelemente.
|
||||
|
||||
Neue Kanäle geben zunächst nur auf dem gewählten Geräteausgang aus. Das echte Mikrofon ist anfangs ausgeschaltet. Gespeicherte Mikrofon-Routen werden beim nächsten Start wiederhergestellt.
|
||||
|
||||
## Grenzen und Fehlersuche
|
||||
|
||||
- Ohne `--lan` ist der Server nur lokal erreichbar. Mit `--lan` ist die Oberfläche mit Zugangscode auch von Handy und Tablet erreichbar.
|
||||
- Die Anwendungserkennung verwendet die PulseAudio-Schnittstelle. Native JACK-/ALSA- oder ausschließlich native PipeWire-Streams sind nicht Bestandteil dieser ersten Version.
|
||||
- Alle Streams mit derselben Kombination aus Prozessname und Anwendungsname teilen sich eine Kanalauswahl; Firefox-Tabs sind keine separaten Anwendungen.
|
||||
- Ein Kanal hat zwei mögliche Ziele: einen gemeinsamen Geräteausgang und den gemeinsamen Mikrofon-Mix. Weitere Geräteausgänge pro Kanal werden noch nicht unterstützt.
|
||||
- Die Anzeige „Spielt Audio“ beruht auf dem Stream-Status; sie ist kein gemessener Pegel.
|
||||
- Loopbacks fordern 30 ms Pufferung an. Die tatsächliche Gesamtlatenz hängt von den Geräten und der Audiokonfiguration ab.
|
||||
- Wenn ein Gerät abgezogen wird, bleibt es ausgewählt und wird als nicht verfügbar angezeigt. Wähle ein anderes Gerät aus oder verbinde es erneut.
|
||||
- Wenn eine Anwendung fehlt, dort zuerst Audio starten. Die Oberfläche aktualisiert sich automatisch.
|
||||
- Bei „Audioserver nicht erreichbar“ `pactl info` in deiner Desktop-Sitzung prüfen.
|
||||
- Es läuft nur eine VoicePult-Instanz pro Benutzer. Der direkte Projektstart richtet keinen Autostart ein; `setup.sh` richtet ihn standardmäßig ein.
|
||||
|
||||
VoicePult speichert keine Audioaufnahmen und überträgt keine Daten an externe Dienste. Die Live-Anzeige verarbeitet kurze Audioblöcke im Arbeitsspeicher. Die HTTP-API prüft Host, bei LAN-Zugriff zusätzlich den Zugangscode und für Änderungen einen zufälligen Sitzungstoken; es gibt keine CORS-Freigabe.
|
||||
|
||||
## Entwicklung und Tests
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
node --check web/app.js
|
||||
```
|
||||
|
||||
Optionaler Integrationstest bei laufender App:
|
||||
|
||||
```bash
|
||||
python3 tests/live_smoke.py
|
||||
```
|
||||
|
||||
Der Integrationstest benötigt `paplay` und `parec` sowie einen ungenutzten Mikrofon-Mix (alle Mikrofon-Routen und die eigene Stimme ausgeschaltet). Während des Tests keine Einstellungen ändern. Er erzeugt einen temporären Kanal, prüft ein künstliches Signal im virtuellen Mikrofon, Lautstärke, Stummschaltung, Anwendungszuordnung, neue Streams, Rückleitung und API-Zugriffsschutz. Das künstliche Tonsignal wird nicht auf den physischen Ausgang geleitet. Testkanäle werden im `finally`-Block entfernt. Keine Aufnahme des echten Mikrofons.
|
||||
|
||||
### Aufbau
|
||||
|
||||
- `setup.sh`: Paketprüfung und Einrichtung für neue Linux-Rechner.
|
||||
- `scripts/install.py`: Benutzerinstallation, Menüeintrag und Autostart.
|
||||
- `scripts/launcher.py`: Start und Browseröffnung der installierten Anwendung.
|
||||
- `audio_meter.py`: PCM-Wellenformen und Pegel, kurzlebige Audioauswertung.
|
||||
- `lan_access.py`: LAN-Zugangscode und Netzwerkadresse.
|
||||
- `server.py`: HTTP-Server, Konfiguration, Anwendungszuordnung und `pactl`-Anbindung; Python-Standardbibliothek.
|
||||
- `web/`: Oberfläche in HTML, CSS und JavaScript, ohne Build-Schritt oder externe Ressourcen.
|
||||
- `tests/test_audio.py`: automatisierte Tests ohne Audioserver.
|
||||
- `tests/live_smoke.py`: optionaler Test mit echtem Audioserver.
|
||||
- `tests/test_setup.py`: isolierte Installation, Updates, Autostart und Pfadbehandlung.
|
||||
|
||||
Signalfluss:
|
||||
|
||||
```text
|
||||
Firefox ─┐ ┌─ Loopback → Kopfhörer / Geräteausgang
|
||||
Musik ───┴→ Virtueller Kanal ─┤
|
||||
└─ Loopback → Mix → VoicePult Mikrofon
|
||||
Echtes Mikrofon (optional) ────────────────→ Mix
|
||||
```
|
||||
|
||||
### Gespeicherte Apps
|
||||
|
||||
Der Arbeitsbereich **Gespeicherte Apps** zeigt alle dauerhaft zugeordneten Anwendungen, auch wenn sie pausiert oder geschlossen sind. Über die Kanal-Checkboxen lässt sich die Mehrfachzuordnung jederzeit ändern. Bestehende Streams werden sofort verschoben; neue Streams erhalten automatisch die gespeicherten Ziele. Die Zuordnungen überstehen einen Neustart. Wird der letzte Kanal abgewählt, verschwindet die gespeicherte Zuordnung. Auf Handy und Tablet findest du den Arbeitsbereich über die Navigation oberhalb der Seite.
|
||||
|
||||
### Zusätzliche virtuelle Mikrofone
|
||||
|
||||
Unter **Ziele → Virtuelles Mikrofon hinzufügen** lassen sich im laufenden Betrieb bis zu 16 zusätzliche Eingabegeräte anlegen, etwa „VoicePult OBS“ und „VoicePult Gespräch“. Wähle die gewünschten Mikrofone unter **Weiterleiten an** an jedem virtuellen Kanal. Ein Kanal kann mehrere Mikrofone gleichzeitig speisen; mehrere Kanäle können dasselbe Mikrofon speisen. Jedes Ziel hat eine eigene Lautstärke und Stummschaltung und ist in der Live-Anzeige auswählbar. Für deine Stimme leite dein echtes Mikrofon zunächst auf einen virtuellen Kanal und diesen auf die gewünschten Ziele.
|
||||
|
||||
Namen, Lautstärken und Verbindungen werden automatisch gespeichert und beim Start wiederhergestellt. Wähle das jeweilige Gerät in OBS, Element usw. als Eingabe; manche Apps müssen ihre Geräteliste erst neu laden. Beim Löschen eines Mikrofonziels werden nur dessen Verbindungen entfernt. Apps, die es verwendet haben, benötigen anschließend ein anderes Eingabegerät.
|
||||
|
||||
### Geräuscharmes Umschalten
|
||||
|
||||
Zielverbindungen bleiben nach dem Ausschalten intern stumm bestehen. Beim nächsten Einschalten wird kein Loopback neu angelegt; eine kurze Lautstärkerampe blendet das Signal ein und aus. Neue PipeWire-Verbindungen starten stumm, bevor ihr Puffer gefüllt und das Signal eingeblendet wird. Die Stummschaltung von Kanälen und Mikrofonzielen verwendet ebenfalls eine kurze Rampe. Gelöschte Kanäle/Ziele und entfernte Geräteverbindungen werden weiterhin vollständig abgebaut.
|
||||
|
||||
Der Integrationstest `python3 tests/live_quiet_routes.py` prüft fünf Umschaltzyklen, konstante Modul-IDs, Signalspitzen und Stille im ausgeschalteten Zustand. Das Testsignal wird ausschließlich zwischen temporären virtuellen Geräten übertragen. Eine Prüfung der tatsächlichen Kopfhörer-Hardware ersetzt das nicht.
|
||||
|
||||
### OpenDeck / Stream Deck
|
||||
|
||||
Die Integration unter `integrations/opendeck/de.voicepult.control.sdPlugin` steuert VoicePult über die lokale API. Sie benötigt Node.js ab Version 22 und keine zusätzlichen npm-Pakete. Eine Taste schaltet einen ganzen Kanal oder ein virtuelles Mikrofonziel stumm/an, ohne dessen Verbindungen zu löschen. Die Farben und die AN/AUS-Anzeige folgen dem tatsächlichen Zustand, auch bei Änderungen über die Website. Ist VoicePult nicht erreichbar, erscheint OFFLINE; fehlende Kanäle werden als FEHLT angezeigt.
|
||||
|
||||
Die Einrichtung erfolgt bewusst getrennt von der allgemeinen Installation, damit bestehende Stream-Deck-Profile erhalten bleiben:
|
||||
|
||||
```sh
|
||||
python3 scripts/install_opendeck.py --device DEINE_GERAETE_ID
|
||||
python3 scripts/install_opendeck.py --device DEINE_GERAETE_ID --apply
|
||||
```
|
||||
|
||||
Der erste Aufruf zeigt nur die geplante Belegung. Der zweite sichert das Ausgangsprofil unter `~/.config/opendeck/voicepult-backups/`, startet OpenDeck neu und verwendet eine freie Taste für einen VoicePult-Ordner (eigenes Profil mit Zurück-Taste). Ein vorhandenes VoicePult-Profil wird nicht überschrieben. Auf diesem Rechner liegt der Ordner im Default-Profil auf Taste 3, die Zurück-Taste auf Position 15. Enthalten sind Kopfhörer, Voice-Chats, Musik und das Mikrofonziel Discord.
|
||||
|
||||
Weitere Kanäle können in OpenDeck über die Aktion **VoicePult → Kanal ein/aus** auf freie Tasten gezogen und im Einstellungsfeld ausgewählt werden. Die Profilseite wird nicht automatisch mit neuen Kanälen überschrieben.
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"""Short-lived, memory-only PCM analysis for the local waveform panel."""
|
||||
import array
|
||||
import math
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def analyze(raw, bins=80):
|
||||
pcm = array.array('h', raw)
|
||||
if sys.byteorder != 'little':
|
||||
pcm.byteswap()
|
||||
channels = [pcm[0::2], pcm[1::2]]
|
||||
result = []
|
||||
for samples in channels:
|
||||
if not samples:
|
||||
raise ValueError('Leerer Audioblock')
|
||||
normalized = [v / 32768 for v in samples]
|
||||
peak = max(abs(v) for v in normalized)
|
||||
rms = math.sqrt(sum(v*v for v in normalized) / len(normalized))
|
||||
wave = []
|
||||
for i in range(min(bins, len(samples))):
|
||||
start = i * len(samples) // min(bins, len(samples))
|
||||
end = (i + 1) * len(samples) // min(bins, len(samples))
|
||||
part = normalized[start:end]
|
||||
wave.append([round(min(part), 5), round(max(part), 5)])
|
||||
result.append({'wave': wave, 'peak': peak, 'rms': rms,
|
||||
'db': round(20 * math.log10(rms), 1) if rms > 0 else None,
|
||||
'clipping': peak >= 0.999})
|
||||
return result
|
||||
|
||||
|
||||
class MeterHub:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.entries = {}
|
||||
self.closed = False
|
||||
|
||||
def snapshot(self, source):
|
||||
with self.lock:
|
||||
if self.closed:
|
||||
return {'ready': False, 'error': 'Audioanzeige beendet.'}
|
||||
entry = self.entries.get(source)
|
||||
if entry is None:
|
||||
entry = {'touch': time.monotonic(), 'data': {'ready': False}, 'process': None}
|
||||
self.entries[source] = entry
|
||||
threading.Thread(target=self.capture, args=(source, entry), daemon=True).start()
|
||||
entry['touch'] = time.monotonic()
|
||||
data = dict(entry['data'])
|
||||
if data.get('ready') and time.monotonic() - entry.get('updated', 0) > .5:
|
||||
return {'ready': False, 'error': 'Warte auf Audiodaten.'}
|
||||
return data
|
||||
|
||||
def capture(self, source, entry):
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(['parec', '--device='+source, '--raw', '--format=s16le',
|
||||
'--rate=16000', '--channels=2', '--latency-msec=50',
|
||||
'--client-name=VoicePult Pegel', '--stream-name=VoicePult Pegel'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
with self.lock:
|
||||
entry['process'] = process
|
||||
buffer = bytearray()
|
||||
block_size = 800 * 4 # 50 ms, 16 kHz, stereo int16
|
||||
while True:
|
||||
with self.lock:
|
||||
if self.closed or time.monotonic() - entry['touch'] > 2:
|
||||
break
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError('Audioquelle nicht erreichbar.')
|
||||
readable, _, _ = select.select([process.stdout], [], [], .2)
|
||||
if not readable:
|
||||
continue
|
||||
chunk = process.stdout.read1(8192)
|
||||
if not chunk:
|
||||
raise RuntimeError('Audiostream wurde beendet.')
|
||||
buffer.extend(chunk)
|
||||
while len(buffer) >= block_size:
|
||||
data = analyze(buffer[:block_size])
|
||||
del buffer[:block_size]
|
||||
with self.lock:
|
||||
entry['updated'] = time.monotonic()
|
||||
entry['data'] = {'ready': True, 'channels': data}
|
||||
except (OSError, RuntimeError) as exc:
|
||||
with self.lock:
|
||||
entry['data'] = {'ready': False, 'error': 'Live-Anzeige nicht verfügbar: ' + str(exc)}
|
||||
# Keep failures visible briefly; later requests can retry.
|
||||
time.sleep(1)
|
||||
finally:
|
||||
if process:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
with self.lock:
|
||||
if self.entries.get(source) is entry:
|
||||
del self.entries[source]
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
self.closed = True
|
||||
for entry in self.entries.values():
|
||||
process = entry['process']
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 120 120"><rect width="120" height="120" rx="15" fill="#253328"/><path d="M22 38h24l8 8h36v40H22z" fill="none" stroke="#c3e983" stroke-width="5"/></svg>
|
||||
|
After Width: | Height: | Size: 229 B |
|
|
@ -0,0 +1,10 @@
|
|||
<!doctype html><html lang="de"><meta charset="utf-8"><title>VoicePult</title><style>body{background:#202622;color:#eee;font:14px sans-serif;padding:18px}select{width:100%;padding:10px;margin:12px 0}p{line-height:1.5;color:#bbb}</style><label for="channel">VoicePult-Kanal</label><select id="channel"><option>Verbinde VoicePult…</option></select><p>Ein Tastendruck schaltet den Kanal stumm oder wieder ein. AN/AUS folgt auch Änderungen auf der Website.</p><script>
|
||||
let socket,context,action,items=[];
|
||||
function connectElgatoStreamDeckSocket(port,uuid,register,info,raw){
|
||||
action=JSON.parse(raw);context=action.context;
|
||||
socket=new WebSocket('ws://127.0.0.1:'+port);
|
||||
socket.onopen=()=>{socket.send(JSON.stringify({event:register,uuid}));socket.send(JSON.stringify({event:'sendToPlugin',action:action.action,context,payload:{event:'list'}}));};
|
||||
socket.onmessage=e=>{const msg=JSON.parse(e.data);if(msg.event!=='sendToPropertyInspector')return;items=msg.payload.items||[];const select=document.getElementById('channel');select.replaceChildren();items.forEach((item,i)=>{const option=document.createElement('option');option.value=i;option.textContent=(item.kind==='target'?'Mikrofon: ':'Kanal: ')+item.name;option.selected=item.id===action.payload.settings.id;select.append(option);});};
|
||||
document.getElementById('channel').onchange=e=>{const item=items[Number(e.target.value)];if(item)socket.send(JSON.stringify({event:'setSettings',context,payload:{id:item.id,kind:item.kind,name:item.name}}));};
|
||||
}
|
||||
</script></html>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"Name": "VoicePult",
|
||||
"Description": "VoicePult-Kanäle schalten und ihren aktuellen Zustand anzeigen",
|
||||
"Author": "VoicePult",
|
||||
"Version": "1.0.0",
|
||||
"SDKVersion": 2,
|
||||
"CodePathLin": "plugin.js",
|
||||
"Icon": "on",
|
||||
"Category": "VoicePult",
|
||||
"CategoryIcon": "on",
|
||||
"OS": [
|
||||
{
|
||||
"Platform": "linux"
|
||||
}
|
||||
],
|
||||
"Actions": [
|
||||
{
|
||||
"UUID": "de.voicepult.control.toggle",
|
||||
"Name": "Kanal ein/aus",
|
||||
"Icon": "on",
|
||||
"Tooltip": "VoicePult-Kanal stumm oder an schalten",
|
||||
"Controllers": [
|
||||
"Keypad"
|
||||
],
|
||||
"DisableAutomaticStates": true,
|
||||
"SupportedInMultiActions": true,
|
||||
"PropertyInspectorPath": "inspector.html",
|
||||
"States": [
|
||||
{
|
||||
"Image": "on",
|
||||
"TitleAlignment": "middle",
|
||||
"FontSize": 13
|
||||
},
|
||||
{
|
||||
"Image": "off",
|
||||
"TitleAlignment": "middle",
|
||||
"FontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 120 120"><rect width="120" height="120" rx="15" fill="#3d2226"/><circle cx="60" cy="60" r="49" fill="none" stroke="#efa5aa" stroke-width="5"/></svg>
|
||||
|
After Width: | Height: | Size: 226 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144" viewBox="0 0 120 120"><rect width="120" height="120" rx="15" fill="#27381e"/><circle cx="60" cy="60" r="49" fill="none" stroke="#c3e983" stroke-width="5"/></svg>
|
||||
|
After Width: | Height: | Size: 226 B |
|
|
@ -0,0 +1,76 @@
|
|||
'use strict';
|
||||
// OpenAction / Stream Deck protocol; Node 22+, no npm dependencies.
|
||||
const args = process.argv.slice(2);
|
||||
const arg = name => args[args.indexOf(name) + 1];
|
||||
const port = Number(arg('-port'));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Missing OpenDeck port');
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const visible = new Map(), rendered = new Map();
|
||||
let cached = null, refreshing = false, queue = Promise.resolve();
|
||||
function send(event, context, payload) {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({event,context,payload}));
|
||||
}
|
||||
async function state() {
|
||||
const response = await fetch('http://127.0.0.1:8765/api/state',{signal:AbortSignal.timeout(3000)});
|
||||
if (!response.ok) throw new Error('VoicePult unavailable');
|
||||
return response.json();
|
||||
}
|
||||
function itemFor(settings, data) {
|
||||
return (settings.kind === 'target' ? data.targets : data.channels)?.find(c=>c.id === settings.id);
|
||||
}
|
||||
function paint(context, settings, data) {
|
||||
const item = data && itemFor(settings, data);
|
||||
const label = item?.name || settings.name || 'Kanal wählen';
|
||||
const lines = label.replace(/(.{1,12})(?:\s+|$)/g,'$1\n').trim();
|
||||
const status = !data ? 'OFFLINE' : !item ? 'FEHLT' : item.muted ? 'AUS' : 'AN';
|
||||
const selected = !item || item.muted || !data ? 1 : 0;
|
||||
const title = `${lines}\n${status}`;
|
||||
const signature = JSON.stringify([selected,title]);
|
||||
if (rendered.get(context) === signature) return;
|
||||
send('setTitle',context,{title,target:0,state:0});
|
||||
send('setTitle',context,{title,target:0,state:1});
|
||||
send('setState',context,{state:selected});
|
||||
rendered.set(context,signature);
|
||||
}
|
||||
async function refresh() {
|
||||
if (refreshing || !visible.size) return;
|
||||
refreshing = true;
|
||||
try { cached = await state(); }
|
||||
catch { cached = null; }
|
||||
finally { refreshing = false; }
|
||||
for (const [context, settings] of visible) paint(context,settings,cached);
|
||||
}
|
||||
async function toggle(context, settings) {
|
||||
try {
|
||||
// Server toggles atomically; don't infer the next state from a stale key image.
|
||||
const before = await state();
|
||||
if (!itemFor(settings,before)) throw new Error('Channel removed');
|
||||
const response = await fetch('http://127.0.0.1:8765/api/action',{
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-VoicePult-Token':before.token},
|
||||
body:JSON.stringify({action:settings.kind==='target'?'toggle_target':'toggle_channel',id:settings.id}),
|
||||
signal:AbortSignal.timeout(15000)
|
||||
});
|
||||
if (!response.ok) throw new Error('VoicePult action failed');
|
||||
cached = await response.json();
|
||||
for (const [key,settings] of visible) paint(key,settings,cached);
|
||||
} catch (error) { send('showAlert',context); console.error(error.message); await refresh(); }
|
||||
}
|
||||
socket.addEventListener('open',()=>socket.send(JSON.stringify({event:arg('-registerEvent'),uuid:arg('-pluginUUID')})));
|
||||
socket.addEventListener('message',async event=>{
|
||||
try {
|
||||
const msg=JSON.parse(event.data), settings=msg.payload?.settings || visible.get(msg.context) || {};
|
||||
if (msg.event==='willAppear' || msg.event==='didReceiveSettings') {
|
||||
visible.set(msg.context,settings); rendered.delete(msg.context); await refresh();
|
||||
} else if (msg.event==='willDisappear') {
|
||||
visible.delete(msg.context); rendered.delete(msg.context);
|
||||
} else if (msg.event==='keyDown') {
|
||||
queue=queue.then(()=>toggle(msg.context,settings)).catch(error=>console.error(error.message));
|
||||
} else if (msg.event==='sendToPlugin') {
|
||||
const data=await state();
|
||||
send('sendToPropertyInspector',msg.context,{items:[...data.channels.map(c=>({...c,kind:'channel'})),...data.targets.map(c=>({...c,kind:'target'}))]});
|
||||
}
|
||||
} catch(error) {console.error(error.message);}
|
||||
});
|
||||
socket.addEventListener('close',()=>process.exit(0));
|
||||
socket.addEventListener('error',()=>console.error('OpenDeck connection failed'));
|
||||
setInterval(refresh,1000);
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"""LAN access control and local network address discovery."""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
|
||||
class LanAccess:
|
||||
def __init__(self, config, enabled, port):
|
||||
self.enabled, self.port = enabled, port
|
||||
self.key = ''
|
||||
self.urls = []
|
||||
if enabled:
|
||||
path = Path(config).with_name('lan-key')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError:
|
||||
self.key = path.read_text().strip()
|
||||
if len(self.key) < 20:
|
||||
raise ValueError('Ungültiger LAN-Zugangscode in ' + str(path))
|
||||
else:
|
||||
self.key = secrets.token_urlsafe(24)
|
||||
with os.fdopen(fd, 'w') as file:
|
||||
file.write(self.key)
|
||||
try:
|
||||
result = subprocess.run(['ip', '-j', '-4', 'route', 'get', '1.1.1.1'], capture_output=True, text=True, timeout=3)
|
||||
addresses = {entry['prefsrc'] for entry in json.loads(result.stdout) if entry.get('prefsrc')}
|
||||
self.urls = [f'http://{address}:{port}' for address in sorted(addresses)]
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def local(address):
|
||||
try:
|
||||
return ipaddress.ip_address(address).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def allowed(self, address, supplied):
|
||||
return self.local(address) or (self.enabled and bool(supplied) and secrets.compare_digest(self.key.encode(), supplied.encode()))
|
||||
|
||||
def info(self, address):
|
||||
return {'enabled': self.enabled, 'urls': self.urls,
|
||||
'key': self.key if self.local(address) else None}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Per-user installation backend used by setup.sh, with safe path quoting."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def xdg(variable, fallback):
|
||||
path = Path(os.environ.get(variable) or Path.home() / fallback)
|
||||
if not path.is_absolute() or path == Path('/') or any(ord(c) < 32 for c in str(path)):
|
||||
raise ValueError('Ungültiger XDG-Pfad: ' + str(path))
|
||||
return path
|
||||
|
||||
|
||||
def systemd_arg(value):
|
||||
return '"' + str(value).replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%').replace('$', '$$') + '"'
|
||||
|
||||
|
||||
def desktop_arg(value):
|
||||
# Desktop Entry escaping has both a value layer and an Exec argument layer.
|
||||
value = str(value).replace('%', '%%')
|
||||
for char in ('\\', '"', '`', '$'):
|
||||
value = value.replace(char, '\\' + char)
|
||||
return '"' + value.replace('\\', '\\\\') + '"'
|
||||
|
||||
|
||||
def atomic_write(path, content, mode=0o644):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, mode='w', delete=False) as f:
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
os.fchmod(f.fileno(), mode)
|
||||
tmp.replace(path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def install(source, autostart, start, open_browser, lan=None):
|
||||
data = xdg('XDG_DATA_HOME', '.local/share')
|
||||
config_root = xdg('XDG_CONFIG_HOME', '.config')
|
||||
state = xdg('XDG_STATE_HOME', '.local/state')
|
||||
target = data / 'voicepult' / 'app'
|
||||
config = config_root / 'voicepult' / 'config.json'
|
||||
log = state / 'voicepult' / 'server.log'
|
||||
unit = config_root / 'systemd' / 'user' / 'voicepult.service'
|
||||
startup = config_root / 'autostart' / 'voicepult.desktop'
|
||||
python = Path(sys.executable).resolve()
|
||||
if lan is None:
|
||||
previous = target / 'install.json'
|
||||
lan = json.loads(previous.read_text()).get('lan', False) if previous.exists() else False
|
||||
# Check everything before writing destination files.
|
||||
files = ['lan_access.py', 'audio_meter.py', 'server.py', 'start.sh', 'web/index.html', 'web/app.js', 'web/style.css', 'web/icon.svg']
|
||||
for name in files + ['scripts/launcher.py']:
|
||||
if not (source / name).is_file():
|
||||
raise ValueError('Projektdatei fehlt: ' + name)
|
||||
have_systemd = bool(shutil.which('systemctl')) and subprocess.run(
|
||||
['systemctl', '--user', 'show-environment'], capture_output=True).returncode == 0
|
||||
was_active = have_systemd and subprocess.run(
|
||||
['systemctl', '--user', 'is-active', '--quiet', 'voicepult.service'], capture_output=True).returncode == 0
|
||||
# Files are replaced atomically; local configuration is deliberately not copied
|
||||
# from the download, which may have come from a different computer.
|
||||
for name in files:
|
||||
atomic_write(target / name, (source / name).read_text(), 0o755 if name.endswith('.sh') else 0o644)
|
||||
installed_start = ('#!/usr/bin/env bash\nset -euo pipefail\nexec '
|
||||
+ ' '.join(shlex.quote(str(arg)) for arg in
|
||||
([python, target / 'server.py', '--config', config] + (['--lan'] if lan else [])))
|
||||
+ ' "$@"\n')
|
||||
atomic_write(target / 'start.sh', installed_start, 0o755)
|
||||
atomic_write(target / 'launcher.py', (source / 'scripts/launcher.py').read_text(), 0o755)
|
||||
config.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write(target / 'install.json', json.dumps({'config': str(config), 'log': str(log), 'systemd': have_systemd, 'lan': lan}, indent=2))
|
||||
# Icon names avoid path quoting issues in desktop entry Icon values.
|
||||
atomic_write(data / 'icons/hicolor/scalable/apps/voicepult.svg', (source / 'web/icon.svg').read_text())
|
||||
exec_line = f'{desktop_arg(python)} {desktop_arg(target / "launcher.py")}'
|
||||
desktop = ('[Desktop Entry]\nType=Application\nVersion=1.0\nName=VoicePult\n'
|
||||
'Comment=Anwendungs-Audio, Kopfhörer und virtuelles Mikrofon verbinden\n'
|
||||
f'Exec={exec_line}\nIcon=voicepult\nTerminal=false\nCategories=AudioVideo;Audio;Mixer;\n')
|
||||
atomic_write(data / 'applications' / 'voicepult.desktop', desktop)
|
||||
service = ('[Unit]\nDescription=VoicePult Audio-Patchpanel\n'
|
||||
'After=pipewire-pulse.service pulseaudio.service\n\n[Service]\nType=simple\n'
|
||||
f'ExecStart={systemd_arg(python)} {systemd_arg(target / "launcher.py")} --serve\n'
|
||||
'Restart=on-failure\nRestartSec=5\nTimeoutStopSec=30\n'
|
||||
'Environment=PYTHONUNBUFFERED=1\n\n[Install]\nWantedBy=default.target\n')
|
||||
atomic_write(unit, service)
|
||||
if have_systemd:
|
||||
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
|
||||
subprocess.run(['systemctl', '--user', 'enable' if autostart else 'disable', 'voicepult.service'], check=True)
|
||||
# Remove only a previous VoicePult fallback entry, never an unrelated file.
|
||||
if startup.exists() and 'X-VoicePult-Managed=true' in startup.read_text():
|
||||
startup.unlink()
|
||||
elif autostart:
|
||||
atomic_write(startup, desktop.replace(f'Exec={exec_line}', f'Exec={exec_line} --background') + 'X-VoicePult-Managed=true\n')
|
||||
elif startup.exists() and 'X-VoicePult-Managed=true' in startup.read_text():
|
||||
startup.unlink()
|
||||
if shutil.which('update-desktop-database'):
|
||||
subprocess.run(['update-desktop-database', str(data / 'applications')], check=False)
|
||||
if not start:
|
||||
print('Dateien installiert. Es wurden keine Dienste gestartet. Start über den Menüeintrag VoicePult.')
|
||||
return
|
||||
if have_systemd and was_active:
|
||||
subprocess.run(['systemctl', '--user', 'restart', 'voicepult.service'], check=True)
|
||||
launcher = [str(python), str(target / 'launcher.py'), '--background']
|
||||
subprocess.run(launcher, check=True)
|
||||
# Confirm actual audio readiness, not just the HTTP server.
|
||||
sys.path.insert(0, str(target))
|
||||
from launcher import status
|
||||
current = None
|
||||
for _ in range(30):
|
||||
current = status()
|
||||
if current and current.get('online') and current.get('mic_available'):
|
||||
break
|
||||
time.sleep(.5)
|
||||
else:
|
||||
raise RuntimeError('Dateien installiert, aber Audio nicht bereit: ' + str((current or {}).get('error', 'Server nicht erreichbar')))
|
||||
if have_systemd and not was_active:
|
||||
active_now = subprocess.run(['systemctl', '--user', 'is-active', '--quiet', 'voicepult.service'], capture_output=True).returncode == 0
|
||||
if not active_now:
|
||||
print('Hinweis: Eine zuvor manuell gestartete VoicePult-Instanz läuft noch. Diese beenden und VoicePult über den Menüeintrag starten, um die installierte Version zu nutzen.')
|
||||
print('Audioprüfung erfolgreich: VoicePult Mikrofon ist als Eingabegerät bereit.')
|
||||
if open_browser:
|
||||
if not (os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')):
|
||||
print('Kein grafischer Browser erreichbar. Öffne später http://127.0.0.1:8765 im Browser.')
|
||||
else:
|
||||
result = subprocess.run([str(python), str(target / 'launcher.py')], check=False)
|
||||
if result.returncode:
|
||||
print('Browser konnte nicht automatisch geöffnet werden. URL: http://127.0.0.1:8765')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, required=True)
|
||||
parser.add_argument('--autostart', type=int, choices=[0, 1], required=True)
|
||||
parser.add_argument('--start', type=int, choices=[0, 1], required=True)
|
||||
parser.add_argument('--open', type=int, choices=[0, 1], required=True)
|
||||
parser.add_argument('--lan', action='store_const', const=True, default=None)
|
||||
args = parser.parse_args()
|
||||
if os.geteuid() == 0:
|
||||
parser.error('Bitte als normalen Desktop-Benutzer ausführen.')
|
||||
try:
|
||||
install(args.source.resolve(), bool(args.autostart), bool(args.start), bool(args.open), args.lan)
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
|
||||
print('VoicePult: ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Install a VoicePult folder/profile without replacing existing OpenDeck keys."""
|
||||
import argparse
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
PLUGIN='de.voicepult.control.sdPlugin'
|
||||
STARTER='com.amansprojects.starterpack.sdPlugin'
|
||||
|
||||
def build(default, audio, device, parent, profile):
|
||||
keys=default['keys']
|
||||
free=next((i for i,key in enumerate(keys) if key is None),None)
|
||||
if free is None:
|
||||
raise ValueError('Im Ausgangsprofil ist keine freie Taste für den VoicePult-Ordner.')
|
||||
items=[dict(c,kind='channel') for c in audio['channels']]+[dict(t,kind='target') for t in audio['targets']]
|
||||
if len(items)>len(keys)-1:
|
||||
raise ValueError('Zu viele Audiokanäle für eine einzelne Stream-Deck-Seite.')
|
||||
base={'alignment':'middle','background_colour':'#1d281f','colour':'#ffffff','family':'Liberation Sans','image_scale':100,'name':'','show':True,'size':13,'stroke_colour':'#000000','stroke_size':1,'style':'Bold','text':'','underline':False}
|
||||
def key(index,name,uuid,plugin,settings,images,text,automatic=False,inspector=None):
|
||||
states=[dict(base,image=f'plugins/{plugin}/{image}',text=text) for image in images]
|
||||
action={'controllers':['Keypad'],'disable_automatic_states':not automatic,'encoder':None,'icon':states[0]['image'],'name':name,'plugin':plugin,'property_inspector':inspector,'states':copy.deepcopy(states),'supported_in_multi_actions':True,'tooltip':name,'uuid':uuid,'visible_in_action_list':True}
|
||||
return {'action':action,'children':None,'context':f'Keypad.{index}.0','current_state':0,'settings':settings,'states':states}
|
||||
def switch(index,destination,title):
|
||||
result=key(index,'Switch Profile','com.amansprojects.starterpack.switchprofile',STARTER,{'device':device,'profile':destination},['icons/switchProfile.png'],title,True,f'plugins/{STARTER}/propertyInspector/switchProfile.html')
|
||||
result['states'][0]['image']=f'plugins/{PLUGIN}/folder.svg'
|
||||
return result
|
||||
updated=copy.deepcopy(default)
|
||||
updated['keys'][free]=switch(free,profile,'VoicePult')
|
||||
page=copy.deepcopy(default)
|
||||
page['keys']=[None]*len(keys)
|
||||
# A profile page must not inherit encoder or touchscreen actions.
|
||||
for field in ('sliders','encoders','touchscreens','infobars'):
|
||||
if field in page:page[field]=[None]*len(page[field]) if field!='infobars' else []
|
||||
for index,item in enumerate(items):
|
||||
label=('Mic: ' if item['kind']=='target' else '')+item['name']
|
||||
page['keys'][index]=key(index,'Kanal ein/aus','de.voicepult.control.toggle',PLUGIN,{'kind':item['kind'],'id':item['id'],'name':label},['on.svg','off.svg'],label+'\n'+('AUS' if item['muted'] else 'AN'),inspector=f'plugins/{PLUGIN}/inspector.html')
|
||||
page['keys'][index]['current_state']=int(item['muted'])
|
||||
page['keys'][-1]=switch(len(keys)-1,parent,'Zurück')
|
||||
return updated,page,free
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--device',required=True)
|
||||
parser.add_argument('--parent',default='Default')
|
||||
parser.add_argument('--profile',default='VoicePult')
|
||||
parser.add_argument('--apply',action='store_true')
|
||||
args=parser.parse_args()
|
||||
for value in (args.device,args.parent,args.profile):
|
||||
if not value or '/' in value or '\\' in value or value in ('.','..'):
|
||||
parser.error('Ungültiger Profil- oder Gerätename')
|
||||
config=Path(os.environ.get('XDG_CONFIG_HOME',Path.home()/'.config'))/'opendeck'
|
||||
directory=config/'profiles'/args.device
|
||||
parent=directory/(args.parent+'.json');profile=directory/(args.profile+'.json')
|
||||
if profile.exists() or (config/'plugins'/PLUGIN).exists():
|
||||
raise SystemExit('VoicePult-Profil oder Plugin bereits vorhanden; bestehende Einrichtung wird nicht überschrieben.')
|
||||
if not (config/'plugins'/STARTER/'manifest.json').exists():
|
||||
raise SystemExit('OpenDeck Starter Pack wird für die Ordnernavigation benötigt.')
|
||||
version=subprocess.check_output(['node','--version'],text=True).strip()
|
||||
if int(version.lstrip('v').split('.')[0])<22:raise SystemExit('Node.js 22 oder neuer benötigt.')
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
audio=json.load(opener.open('http://127.0.0.1:8765/api/state',timeout=10))
|
||||
updated,page,slot=build(json.loads(parent.read_text()),audio,args.device,args.parent,args.profile)
|
||||
print(f'Ordner {args.profile}: Taste {slot+1}, {len(audio["channels"])} Kanäle und {len(audio["targets"])} Mikrofonziele; Zurück auf Taste {len(page["keys"])}.')
|
||||
if not args.apply:return
|
||||
pids=subprocess.run(['pgrep','-u',str(os.getuid()),'-x','opendeck'],capture_output=True,text=True).stdout.split()
|
||||
for pid in pids:os.kill(int(pid),signal.SIGTERM)
|
||||
for _ in range(80):
|
||||
running=subprocess.run(['pgrep','-u',str(os.getuid()),'-x','opendeck'],capture_output=True).returncode==0
|
||||
if not running:break
|
||||
time.sleep(.1)
|
||||
else:raise SystemExit('OpenDeck hat sich noch nicht beendet; keine Dateien verändert.')
|
||||
try:
|
||||
# Read again after shutdown, so the last user changes are preserved.
|
||||
updated,page,slot=build(json.loads(parent.read_text()),audio,args.device,args.parent,args.profile)
|
||||
backup=config/'voicepult-backups'/datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||
backup.mkdir(parents=True)
|
||||
shutil.copy2(parent,backup/parent.name)
|
||||
shutil.copy2(config/'profiles'/(args.device+'.json'),backup/'device.json')
|
||||
shutil.copytree(ROOT/'integrations/opendeck'/PLUGIN,config/'plugins'/PLUGIN)
|
||||
for path,data in ((profile,page),(parent,updated)):
|
||||
temp=path.with_suffix('.voicepult.tmp');temp.write_text(json.dumps(data,ensure_ascii=False,indent=2));temp.replace(path)
|
||||
print('Sicherung:',backup)
|
||||
finally:
|
||||
subprocess.Popen(['/usr/bin/opendeck','--hide'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
|
||||
print('OpenDeck mit VoicePult-Ordner gestartet.')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Installed VoicePult desktop launcher. No third-party dependencies."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
URL = 'http://127.0.0.1:8765'
|
||||
|
||||
|
||||
def status():
|
||||
try:
|
||||
# Localhost should not pass through a user's HTTP proxy.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(URL + '/api/state', timeout=2) as response:
|
||||
data = json.load(response)
|
||||
if isinstance(data, dict) and data.get('virtual_mic') == 'VoicePult Mikrofon' and 'channels' in data:
|
||||
return data
|
||||
except (OSError, ValueError, urllib.error.URLError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--serve', action='store_true')
|
||||
parser.add_argument('--background', action='store_true')
|
||||
args = parser.parse_args()
|
||||
meta = json.loads((ROOT / 'install.json').read_text())
|
||||
config = Path(meta['config'])
|
||||
command = [sys.executable, str(ROOT / 'server.py'), '--config', str(config)]
|
||||
if meta.get('lan'):
|
||||
command.append('--lan')
|
||||
if args.serve:
|
||||
os.execv(sys.executable, command)
|
||||
if status() is None:
|
||||
service_started = False
|
||||
if meta['systemd']:
|
||||
result = subprocess.run(['systemctl', '--user', 'start', 'voicepult.service'], capture_output=True)
|
||||
service_started = result.returncode == 0
|
||||
if not service_started:
|
||||
log = Path(meta['log'])
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log.open('ab') as output:
|
||||
subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=output,
|
||||
stderr=output, start_new_session=True, cwd=ROOT)
|
||||
for _ in range(40):
|
||||
if status() is not None:
|
||||
break
|
||||
time.sleep(.25)
|
||||
else:
|
||||
log_hint = 'journalctl --user -u voicepult.service' if meta['systemd'] else meta['log']
|
||||
raise RuntimeError('VoicePult startet nicht. Eventuell läuft bereits eine andere Instanz oder Port 8765 ist belegt. Protokoll: ' + log_hint)
|
||||
if not args.background:
|
||||
subprocess.run(['xdg-open', URL], check=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
|
||||
print('VoicePult: ' + str(exc), file=sys.stderr)
|
||||
# Desktop launchers usually have no visible terminal.
|
||||
try:
|
||||
subprocess.run(['notify-send', 'VoicePult konnte nicht geöffnet werden', str(exc)], check=False)
|
||||
except OSError:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,669 @@
|
|||
#!/usr/bin/env python3
|
||||
"""VoicePult: local PipeWire/PulseAudio patch panel, Python standard library only."""
|
||||
import argparse
|
||||
import copy
|
||||
import fcntl
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from audio_meter import MeterHub
|
||||
from lan_access import LanAccess
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
PREFIX = 'voicepult_'
|
||||
MIX = PREFIX + 'mix'
|
||||
MIC = PREFIX + 'microphone'
|
||||
|
||||
|
||||
class AudioError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Pulse:
|
||||
def run(self, *args):
|
||||
try:
|
||||
p = subprocess.run(['pactl', *map(str, args)], capture_output=True,
|
||||
text=True, timeout=8, env={**os.environ, 'LC_ALL': 'C.UTF-8'})
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise AudioError('Audioserver nicht erreichbar. PipeWire/PulseAudio und pactl prüfen.') from exc
|
||||
if p.returncode:
|
||||
raise AudioError(p.stderr.strip() or 'Audiobefehl fehlgeschlagen.')
|
||||
return p.stdout.strip()
|
||||
|
||||
def items(self, kind):
|
||||
if kind == 'modules':
|
||||
# Some pactl versions omit module indices in their JSON output.
|
||||
modules = []
|
||||
for line in self.run('list', 'short', 'modules').splitlines():
|
||||
fields = line.split('\t')
|
||||
if len(fields) >= 3 and fields[0].isdigit():
|
||||
modules.append({'index': int(fields[0]), 'name': fields[1], 'argument': fields[2]})
|
||||
return modules
|
||||
return json.loads(self.run('-f', 'json', 'list', kind))
|
||||
|
||||
|
||||
def app_key(stream):
|
||||
props = stream.get('properties', {})
|
||||
return props.get('application.process.binary', '') + '|' + props.get('application.name', 'Unbekannte Anwendung')
|
||||
|
||||
|
||||
def is_internal(stream):
|
||||
props = stream.get('properties', {})
|
||||
return props.get('application.id') == 'de.voicepult' or props.get('media.name', '').startswith('VoicePult')
|
||||
|
||||
|
||||
class Panel:
|
||||
def __init__(self, path, pulse=None):
|
||||
self.path, self.pulse = Path(path), pulse or Pulse()
|
||||
self.lock = threading.RLock()
|
||||
self.modules = {} # logical key -> (module id, exact argument string)
|
||||
self.originals = {}
|
||||
self.route_states = {}
|
||||
self.error = None
|
||||
self.config = {'targets': [], 'channels': [], 'assignments': {}, 'output': '', 'input': '', 'mic_enabled': False, 'mix_volume': 100, 'mix_muted': False, 'theme': 'light', 'wave_gain': 8, 'mic_channels': [], 'scope_source': MIC, 'scope_enabled': True}
|
||||
if self.path.exists():
|
||||
self.config.update(json.loads(self.path.read_text()))
|
||||
|
||||
self.config['assignments'] = {k: ([v] if isinstance(v, str) else v)
|
||||
for k, v in self.config['assignments'].items() if v}
|
||||
|
||||
@staticmethod
|
||||
def bus(app):
|
||||
return PREFIX + 'app_' + hashlib.sha256(app.encode()).hexdigest()[:16]
|
||||
|
||||
def save(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix('.tmp')
|
||||
temp.write_text(json.dumps(self.config, ensure_ascii=False, indent=2))
|
||||
temp.replace(self.path)
|
||||
|
||||
def ensure_module(self, key, kind, *args):
|
||||
if key not in self.modules:
|
||||
mid = int(self.pulse.run('load-module', kind, *args))
|
||||
self.modules[key] = (mid, ' '.join(args))
|
||||
|
||||
def unload(self, key):
|
||||
record = self.modules.get(key)
|
||||
if record:
|
||||
if key.startswith('route:'):
|
||||
self.set_route_enabled(key, False)
|
||||
self.route_states.pop(key, None)
|
||||
self.pulse.run('unload-module', record[0])
|
||||
del self.modules[key]
|
||||
|
||||
def fade(self, kind, index, start, end):
|
||||
# Keep transitions short, but avoid a full-level discontinuity.
|
||||
for step in range(1, 9):
|
||||
value = round(start + (end - start) * step / 8)
|
||||
self.pulse.run('set-' + kind + '-volume', index, f'{value}%')
|
||||
if step < 8:
|
||||
time.sleep(0.008)
|
||||
|
||||
def sink_level(self, sink, volume, muted, previous):
|
||||
if muted != previous['muted']:
|
||||
if muted:
|
||||
self.fade('sink', sink, previous['volume'], 0)
|
||||
self.pulse.run('set-sink-mute', sink, 1)
|
||||
self.pulse.run('set-sink-volume', sink, f'{volume}%')
|
||||
else:
|
||||
self.pulse.run('set-sink-volume', sink, '0%')
|
||||
self.pulse.run('set-sink-mute', sink, 0)
|
||||
self.fade('sink', sink, 0, volume)
|
||||
else:
|
||||
self.pulse.run('set-sink-volume', sink, f'{volume}%')
|
||||
self.pulse.run('set-sink-mute', sink, int(muted))
|
||||
|
||||
def set_route_enabled(self, key, enabled):
|
||||
record = self.modules.get(key)
|
||||
if not record or self.route_states.get(key) == (record[0], enabled):
|
||||
return
|
||||
stream = None
|
||||
for attempt in range(20):
|
||||
stream = next((s for s in self.pulse.items('sink-inputs')
|
||||
if str(s.get('owner_module')) == str(record[0]) and is_internal(s)), None)
|
||||
if stream:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
if not stream:
|
||||
raise AudioError('Audioverbindung ist noch nicht bereit. Bitte erneut versuchen.')
|
||||
idx = stream['index']
|
||||
if enabled:
|
||||
self.pulse.run('set-sink-input-volume', idx, '0%')
|
||||
self.pulse.run('set-sink-input-mute', idx, 0)
|
||||
self.fade('sink-input', idx, 0, 100)
|
||||
else:
|
||||
if not stream.get('mute', False):
|
||||
values = [v['value'] * 100 / 65536 for v in stream.get('volume', {}).values()]
|
||||
self.fade('sink-input', idx, max(values, default=100), 0)
|
||||
self.pulse.run('set-sink-input-mute', idx, 1)
|
||||
self.route_states[key] = (record[0], enabled)
|
||||
|
||||
def loop(self, key, source, sink, enabled=True):
|
||||
# A disabled, previously used route stays connected and silent. This avoids
|
||||
# graph rebuilds and buffer transients each time a destination is toggled.
|
||||
if not enabled and key not in self.modules:
|
||||
return
|
||||
new = key not in self.modules
|
||||
self.ensure_module(key, 'module-loopback', f'source={source}', f'sink={sink}',
|
||||
'latency_msec=30', 'source_dont_move=true', 'sink_dont_move=true',
|
||||
'sink_input_properties="application.id=de.voicepult state.restore-props=false node.param.Props=\'{ mute = true channelVolumes = [ 0.0 0.0 ] }\'"',
|
||||
'source_output_properties=application.id=de.voicepult')
|
||||
if new:
|
||||
self.set_route_enabled(key, False)
|
||||
time.sleep(0.04) # Let the initially silent loopback fill its buffer.
|
||||
self.set_route_enabled(key, enabled)
|
||||
|
||||
def recover(self):
|
||||
"""Remove only our modules left behind by an interrupted prior process."""
|
||||
owned = [m for m in self.pulse.items('modules') if
|
||||
(m['name'] == 'module-null-sink' and m['argument'].startswith('sink_name=voicepult_')) or
|
||||
(m['name'] == 'module-remap-source' and (m['argument'].startswith('master=voicepult_mix.monitor ') or m['argument'].startswith('master=voicepult_target_'))) or
|
||||
(m['name'] == 'module-loopback' and 'application.id=de.voicepult' in m['argument'])]
|
||||
if not owned:
|
||||
return
|
||||
sinks = self.pulse.items('sinks')
|
||||
physical = {s['name'] for s in sinks if not s['name'].startswith(PREFIX)}
|
||||
virtual = {s['index'] for s in sinks if s['name'].startswith(PREFIX)}
|
||||
for stream in self.pulse.items('sink-inputs'):
|
||||
if stream['sink'] in virtual and not is_internal(stream):
|
||||
self.restore(stream, physical)
|
||||
for module in reversed(owned):
|
||||
self.pulse.run('unload-module', module['index'])
|
||||
|
||||
def reconcile(self):
|
||||
# A restarted audio server invalidates ids. Never unload a recycled module id.
|
||||
live = {int(m['index']): m for m in self.pulse.items('modules') if 'index' in m}
|
||||
self.modules = {k: v for k, v in self.modules.items()
|
||||
if v[0] in live and live[v[0]].get('argument', '') == v[1]}
|
||||
self.route_states = {k: v for k, v in self.route_states.items() if k in self.modules and self.modules[k][0] == v[0]}
|
||||
sinks = self.pulse.items('sinks')
|
||||
sources = self.pulse.items('sources')
|
||||
outputs = [s for s in sinks if not s['name'].startswith(PREFIX)]
|
||||
inputs = [s for s in sources if not s['name'].startswith(PREFIX)
|
||||
and s.get('properties', {}).get('device.class') != 'monitor'
|
||||
and not s['name'].endswith('.monitor')]
|
||||
if not self.config['output'] and outputs:
|
||||
default = self.pulse.run('get-default-sink')
|
||||
self.config['output'] = next((s['name'] for s in outputs if s['name'] == default), outputs[0]['name'])
|
||||
if not self.config['input'] and inputs:
|
||||
default = self.pulse.run('get-default-source')
|
||||
self.config['input'] = next((s['name'] for s in inputs if s['name'] == default), inputs[0]['name'])
|
||||
new_mix = 'mix' not in self.modules
|
||||
self.ensure_module('mix', 'module-null-sink', f'sink_name={MIX}',
|
||||
'sink_properties=device.description=VoicePult_Mix', 'rate=48000', 'channels=2')
|
||||
if new_mix:
|
||||
self.pulse.run('set-sink-volume', MIX, f'{self.config["mix_volume"]}%')
|
||||
self.pulse.run('set-sink-mute', MIX, int(self.config['mix_muted']))
|
||||
self.ensure_module('mic', 'module-remap-source', f'master={MIX}.monitor', f'source_name={MIC}',
|
||||
'source_properties="device.description=\'VoicePult Mikrofon\' device.class=sound device.icon_name=audio-input-microphone"')
|
||||
for target in self.config['targets']:
|
||||
tid = target['id']
|
||||
sink = PREFIX + 'target_' + tid
|
||||
new = 'target:' + tid not in self.modules
|
||||
self.ensure_module('target:' + tid, 'module-null-sink', f'sink_name={sink}',
|
||||
f'sink_properties=device.description=VoicePult_Target_{tid}', 'rate=48000', 'channels=2')
|
||||
if new:
|
||||
self.pulse.run('set-sink-volume', sink, f'{target["volume"]}%')
|
||||
self.pulse.run('set-sink-mute', sink, int(target['muted']))
|
||||
self.ensure_module('target-source:' + tid, 'module-remap-source', f'master={sink}.monitor',
|
||||
f'source_name={sink}_mic',
|
||||
'source_properties=' + json.dumps('device.description=' + json.dumps('VoicePult ' + target['name'], ensure_ascii=False) + ' device.class=sound device.icon_name=audio-input-microphone', ensure_ascii=False))
|
||||
desired = set()
|
||||
for ch in self.config['channels']:
|
||||
name = PREFIX + ch['id']
|
||||
key = 'channel:' + ch['id']
|
||||
new = key not in self.modules
|
||||
self.ensure_module(key, 'module-null-sink', f'sink_name={name}',
|
||||
f'sink_properties=device.description=VoicePult_{ch["id"]}', 'rate=48000', 'channels=2')
|
||||
if new:
|
||||
self.pulse.run('set-sink-volume', name, f'{ch["volume"]}%')
|
||||
self.pulse.run('set-sink-mute', name, int(ch['muted']))
|
||||
if any(s['name'] == self.config['output'] for s in outputs):
|
||||
route = 'route:' + ch['id'] + ':' + self.config['output']
|
||||
desired.add(route)
|
||||
self.loop(route, name + '.monitor', self.config['output'], ch['monitor'])
|
||||
for target in self.config['targets']:
|
||||
tid = target['id']
|
||||
route = 'route:' + ch['id'] + ':target:' + tid
|
||||
desired.add(route)
|
||||
self.loop(route, name + '.monitor', PREFIX + 'target_' + tid, tid in ch.get('targets', []))
|
||||
route = 'route:' + ch['id'] + ':mic'
|
||||
desired.add(route)
|
||||
self.loop(route, name + '.monitor', MIX, ch['microphone'])
|
||||
if any(s['name'] == self.config['input'] for s in inputs):
|
||||
route = 'route:input:' + self.config['input']
|
||||
desired.add(route)
|
||||
self.loop(route, self.config['input'], MIX, self.config['mic_enabled'])
|
||||
for channel in self.config['channels']:
|
||||
cid = channel['id']
|
||||
route = 'route:physical:' + cid + ':' + self.config['input']
|
||||
desired.add(route)
|
||||
self.loop(route, self.config['input'], PREFIX + cid, cid in self.config['mic_channels'])
|
||||
buses = set()
|
||||
for app, channels in self.config['assignments'].items():
|
||||
if len(channels) > 1:
|
||||
bus = self.bus(app)
|
||||
buses.add('bus:' + bus)
|
||||
self.ensure_module('bus:' + bus, 'module-null-sink', f'sink_name={bus}',
|
||||
'sink_properties=device.description=VoicePult_App', 'rate=48000', 'channels=2')
|
||||
for cid in channels:
|
||||
route = 'route:app:' + bus + ':to:' + cid
|
||||
desired.add(route)
|
||||
self.loop(route, bus + '.monitor', PREFIX + cid)
|
||||
for key in list(self.modules):
|
||||
if key.startswith('route:') and key not in desired:
|
||||
self.unload(key)
|
||||
sink_names = {s['index']: s['name'] for s in self.pulse.items('sinks')}
|
||||
streams = self.pulse.items('sink-inputs')
|
||||
# Stream indices may be reused after disconnect; discard stale restore entries.
|
||||
identities = {s['index']: self.identity(s) for s in streams}
|
||||
self.originals = {idx: rec for idx, rec in self.originals.items() if identities.get(idx) == rec[0]}
|
||||
for stream in streams:
|
||||
if is_internal(stream):
|
||||
continue
|
||||
target = self.config['assignments'].get(app_key(stream))
|
||||
if target:
|
||||
name = PREFIX + target[0] if len(target) == 1 else self.bus(app_key(stream))
|
||||
current = sink_names.get(stream['sink'], '')
|
||||
if current != name:
|
||||
if stream['index'] not in self.originals and not current.startswith(PREFIX):
|
||||
self.originals[stream['index']] = (self.identity(stream), current)
|
||||
self.pulse.run('move-sink-input', stream['index'], name)
|
||||
for key in list(self.modules):
|
||||
if key.startswith('bus:') and key not in buses:
|
||||
self.unload(key)
|
||||
self.error = None
|
||||
|
||||
@staticmethod
|
||||
def identity(stream):
|
||||
p = stream.get('properties', {})
|
||||
return (app_key(stream), p.get('object.serial'), p.get('application.process.id'))
|
||||
|
||||
def restore(self, stream, sinks):
|
||||
old = self.originals.get(stream['index'])
|
||||
dest = old[1] if old and old[0] == self.identity(stream) else self.config['output']
|
||||
if dest not in sinks:
|
||||
dest = next(iter(sinks), None)
|
||||
if not dest:
|
||||
raise AudioError('Kein physischer Ausgang zum Zurücksetzen verfügbar.')
|
||||
self.pulse.run('move-sink-input', stream['index'], dest)
|
||||
self.originals.pop(stream['index'], None)
|
||||
|
||||
def meter_sources(self):
|
||||
return ({MIC} | {PREFIX + c['id'] + '.monitor' for c in self.config['channels']}
|
||||
| {PREFIX + 'target_' + t['id'] + '_mic' for t in self.config['targets']})
|
||||
|
||||
def saved_apps(self, live=None):
|
||||
live = live or {}
|
||||
result = []
|
||||
for key, channels in self.config['assignments'].items():
|
||||
if not channels:
|
||||
continue
|
||||
app = {'id': key, 'name': key.partition('|')[2] or key.partition('|')[0],
|
||||
'active': False, 'streams': 0, 'destinations': []}
|
||||
app.update(live.get(key, {}))
|
||||
app.update(channels=list(channels), present=key in live)
|
||||
result.append(app)
|
||||
return sorted(result, key=lambda app: (app['name'].lower(), app['id']))
|
||||
|
||||
def state(self):
|
||||
with self.lock:
|
||||
try:
|
||||
sinks = self.pulse.items('sinks')
|
||||
sources = self.pulse.items('sources')
|
||||
streams = self.pulse.items('sink-inputs')
|
||||
devices = lambda xs: [{'id': x['name'], 'name': x.get('description', x['name'])} for x in xs]
|
||||
apps = {}
|
||||
sink_names = {s['index']: s['name'] for s in sinks}
|
||||
for s in streams:
|
||||
if is_internal(s):
|
||||
continue
|
||||
key = app_key(s)
|
||||
app = apps.setdefault(key, {'id': key, 'name': s.get('properties', {}).get('application.name', 'Anwendung'),
|
||||
'active': False, 'streams': 0, 'channels': self.config['assignments'].get(key, []), 'channel': next(iter(self.config['assignments'].get(key, [])), ''), 'destinations': []})
|
||||
app['active'] |= not s.get('corked', False)
|
||||
app['streams'] += 1
|
||||
dest = sink_names.get(s['sink'], '')
|
||||
if dest not in app['destinations']:
|
||||
app['destinations'].append(dest)
|
||||
return {**copy.deepcopy(self.config), 'online': not bool(self.error), 'error': self.error,
|
||||
'outputs': devices([s for s in sinks if not s['name'].startswith(PREFIX)]),
|
||||
'inputs': devices([s for s in sources if not s['name'].startswith(PREFIX) and
|
||||
s.get('properties', {}).get('device.class') != 'monitor' and not s['name'].endswith('.monitor')]),
|
||||
'saved_apps': self.saved_apps(apps),
|
||||
'apps': sorted(apps.values(), key=lambda a: a['name'].lower()), 'virtual_mic': 'VoicePult Mikrofon',
|
||||
'mic_available': any(s['name'] == MIC for s in sources),
|
||||
'mic_consumers': sorted({s.get('properties', {}).get('application.name', 'Anwendung')
|
||||
for s in self.pulse.items('source-outputs')
|
||||
if not is_internal(s) and s['source'] in
|
||||
{src['index'] for src in sources if src['name'] == MIC}})}
|
||||
except (AudioError, ValueError) as exc:
|
||||
return {**copy.deepcopy(self.config), 'online': False, 'error': str(exc),
|
||||
'outputs': [], 'inputs': [], 'apps': [], 'saved_apps': self.saved_apps(), 'virtual_mic': 'VoicePult Mikrofon',
|
||||
'mic_available': False, 'mic_consumers': []}
|
||||
|
||||
def action(self, data):
|
||||
with self.lock:
|
||||
previous = copy.deepcopy(self.config)
|
||||
try:
|
||||
return self._action(data)
|
||||
except (ValueError, TypeError):
|
||||
self.config = previous
|
||||
raise
|
||||
|
||||
def _action(self, data):
|
||||
with self.lock:
|
||||
action = data.get('action')
|
||||
if action in ('toggle_channel', 'toggle_target'):
|
||||
collection = 'channels' if action == 'toggle_channel' else 'targets'
|
||||
item = next((c for c in self.config[collection] if c['id'] == data.get('id')), None)
|
||||
if item is None:
|
||||
raise ValueError('Audiokanal nicht gefunden.')
|
||||
return self._action({'action': 'channel' if collection == 'channels' else 'target',
|
||||
'id': item['id'], 'muted': not item['muted']})
|
||||
if action == 'target_create':
|
||||
name = str(data.get('name', '')).strip()
|
||||
if not 1 <= len(name) <= 40 or any(ord(c) < 32 for c in name):
|
||||
raise ValueError('Mikrofonname muss 1–40 druckbare Zeichen enthalten.')
|
||||
if len(self.config['targets']) >= 16:
|
||||
raise ValueError('Maximal 16 zusätzliche Mikrofone möglich.')
|
||||
if any(t['name'].casefold() == name.casefold() for t in self.config['targets']):
|
||||
raise ValueError('Dieser Mikrofonname ist bereits vergeben.')
|
||||
self.config['targets'].append({'id': uuid.uuid4().hex[:12], 'name': name, 'volume': 100, 'muted': False})
|
||||
elif action in ('target', 'target_delete'):
|
||||
target = next((t for t in self.config['targets'] if t['id'] == data.get('id')), None)
|
||||
if target is None:
|
||||
raise ValueError('Mikrofonziel nicht gefunden.')
|
||||
tid = target['id']
|
||||
if action == 'target_delete':
|
||||
for ch in self.config['channels']:
|
||||
if tid in ch.get('targets', []):
|
||||
ch['targets'] = [i for i in ch['targets'] if i != tid]
|
||||
for key in list(self.modules):
|
||||
if key.startswith('route:') and key.endswith(':target:' + tid):
|
||||
self.unload(key)
|
||||
self.unload('target-source:' + tid)
|
||||
self.unload('target:' + tid)
|
||||
self.config['targets'].remove(target)
|
||||
if self.config['scope_source'] == PREFIX + 'target_' + tid + '_mic':
|
||||
self.config['scope_source'] = MIC
|
||||
else:
|
||||
volume = data.get('volume', target['volume'])
|
||||
muted = data.get('muted', target['muted'])
|
||||
if type(volume) is not int or not 0 <= volume <= 100 or type(muted) is not bool:
|
||||
raise ValueError('Ungültige Mikrofon-Lautstärke oder Stummschaltung.')
|
||||
self.sink_level(PREFIX + 'target_' + tid, volume, muted, target)
|
||||
target.update(volume=volume, muted=muted)
|
||||
elif action == 'create':
|
||||
name = str(data.get('name', '')).strip()
|
||||
if not name or len(name) > 40:
|
||||
raise ValueError('Kanalname muss 1–40 Zeichen enthalten.')
|
||||
if len(self.config['channels']) >= 16:
|
||||
raise ValueError('Maximal 16 Kanäle möglich.')
|
||||
ch = {'id': uuid.uuid4().hex[:12], 'name': name, 'volume': 100, 'muted': False,
|
||||
'monitor': True, 'microphone': False}
|
||||
self.config['channels'].append(ch)
|
||||
elif action in ('channel', 'delete'):
|
||||
ch = next((c for c in self.config['channels'] if c['id'] == data.get('id')), None)
|
||||
if not ch:
|
||||
raise ValueError('Kanal nicht gefunden.')
|
||||
if action == 'delete':
|
||||
sinks = {s['name'] for s in self.pulse.items('sinks') if not s['name'].startswith(PREFIX)}
|
||||
channel_sink = next((s['index'] for s in self.pulse.items('sinks') if s['name'] == PREFIX + ch['id']), None)
|
||||
for s in self.pulse.items('sink-inputs'):
|
||||
if s['sink'] == channel_sink and not is_internal(s):
|
||||
self.restore(s, sinks)
|
||||
updated = {k: [cid for cid in v if cid != ch['id']] for k, v in self.config['assignments'].items()}
|
||||
for stream in self.pulse.items('sink-inputs'):
|
||||
key = app_key(stream)
|
||||
if key in updated and not updated[key] and not is_internal(stream) and stream['sink'] != channel_sink:
|
||||
self.restore(stream, sinks)
|
||||
self.config['assignments'] = {k: v for k, v in updated.items() if v}
|
||||
self.config['mic_channels'] = [cid for cid in self.config['mic_channels'] if cid != ch['id']]
|
||||
if self.config['scope_source'] == PREFIX + ch['id'] + '.monitor':
|
||||
self.config['scope_source'] = MIC
|
||||
for key in list(self.modules):
|
||||
if key.startswith('route:' + ch['id'] + ':') or key.endswith(':to:' + ch['id']) or key.startswith('route:physical:' + ch['id'] + ':'):
|
||||
self.unload(key)
|
||||
self.unload('channel:' + ch['id'])
|
||||
self.config['channels'].remove(ch)
|
||||
else:
|
||||
old_channel = copy.deepcopy(ch)
|
||||
if 'targets' in data:
|
||||
targets = data['targets']
|
||||
if not isinstance(targets, list) or len(targets) > 16 or any(
|
||||
not isinstance(t, str) or t not in [x['id'] for x in self.config['targets']] for t in targets):
|
||||
raise ValueError('Ungültige Mikrofonziele.')
|
||||
ch['targets'] = list(dict.fromkeys(targets))
|
||||
for flag in ('monitor', 'microphone', 'muted'):
|
||||
if flag in data:
|
||||
if type(data[flag]) is not bool:
|
||||
raise ValueError('Ungültiger Schalterwert.')
|
||||
ch[flag] = data[flag]
|
||||
if 'volume' in data:
|
||||
if type(data['volume']) is not int or not 0 <= data['volume'] <= 100:
|
||||
raise ValueError('Lautstärke muss zwischen 0 und 100 liegen.')
|
||||
ch['volume'] = data['volume']
|
||||
if 'name' in data:
|
||||
name = str(data['name']).strip()
|
||||
if not 1 <= len(name) <= 40:
|
||||
raise ValueError('Kanalname muss 1–40 Zeichen enthalten.')
|
||||
ch['name'] = name
|
||||
self.sink_level(PREFIX + ch['id'], ch['volume'], ch['muted'], old_channel)
|
||||
elif action in ('assign', 'mic_channels'):
|
||||
channels = data.get('channels', [data['channel']] if data.get('channel') else [])
|
||||
if not isinstance(channels, list) or len(channels) > 16 or any(
|
||||
not isinstance(cid, str) or cid not in [c['id'] for c in self.config['channels']] for cid in channels):
|
||||
raise ValueError('Ungültige Kanalauswahl.')
|
||||
channels = list(dict.fromkeys(channels))
|
||||
if action == 'mic_channels':
|
||||
self.config['mic_channels'] = channels
|
||||
else:
|
||||
app = data.get('app')
|
||||
if not isinstance(app, str) or not app or len(app) > 512:
|
||||
raise ValueError('Ungültige Anwendung.')
|
||||
if channels:
|
||||
self.config['assignments'][app] = channels
|
||||
else:
|
||||
sinks = {s['name'] for s in self.pulse.items('sinks') if not s['name'].startswith(PREFIX)}
|
||||
for stream in self.pulse.items('sink-inputs'):
|
||||
if app_key(stream) == app:
|
||||
self.restore(stream, sinks)
|
||||
self.config['assignments'].pop(app, None)
|
||||
elif action == 'preferences':
|
||||
theme = data.get('theme', self.config['theme'])
|
||||
gain = data.get('wave_gain', self.config['wave_gain'])
|
||||
if theme not in ('light', 'dark') or type(gain) is not int or not 1 <= gain <= 32:
|
||||
raise ValueError('Ungültige Anzeigeeinstellung.')
|
||||
source = data.get('scope_source', self.config['scope_source'])
|
||||
enabled = data.get('scope_enabled', self.config['scope_enabled'])
|
||||
allowed = self.meter_sources()
|
||||
if not isinstance(source, str) or source not in allowed or type(enabled) is not bool:
|
||||
raise ValueError('Ungültige Wellenform-Einstellung.')
|
||||
self.config.update(theme=theme, wave_gain=gain, scope_source=source, scope_enabled=enabled)
|
||||
elif action == 'mix':
|
||||
volume = data.get('volume', self.config['mix_volume'])
|
||||
muted = data.get('muted', self.config['mix_muted'])
|
||||
if type(volume) is not int or not 0 <= volume <= 100 or type(muted) is not bool:
|
||||
raise ValueError('Ungültige Mix-Lautstärke oder Stummschaltung.')
|
||||
self.sink_level(MIX, volume, muted, {'volume': self.config['mix_volume'], 'muted': self.config['mix_muted']})
|
||||
self.config.update(mix_volume=volume, mix_muted=muted)
|
||||
elif action == 'settings':
|
||||
state = self.state()
|
||||
for field, collection in [('output', 'outputs'), ('input', 'inputs')]:
|
||||
if field in data:
|
||||
if data[field] not in [d['id'] for d in state[collection]]:
|
||||
raise ValueError('Audiogerät nicht verfügbar.')
|
||||
self.config[field] = data[field]
|
||||
if 'mic_enabled' in data:
|
||||
if type(data['mic_enabled']) is not bool:
|
||||
raise ValueError('Ungültiger Schalterwert.')
|
||||
self.config['mic_enabled'] = data['mic_enabled']
|
||||
else:
|
||||
raise ValueError('Unbekannte Aktion.')
|
||||
self.save()
|
||||
self.reconcile()
|
||||
return self.state()
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
# Validate ownership against live modules before unloading.
|
||||
live = {int(m['index']): m for m in self.pulse.items('modules') if 'index' in m}
|
||||
self.modules = {k: v for k, v in self.modules.items() if v[0] in live and live[v[0]].get('argument', '') == v[1]}
|
||||
sinks = self.pulse.items('sinks')
|
||||
physical = {s['name'] for s in sinks if not s['name'].startswith(PREFIX)}
|
||||
virtual = {s['index'] for s in sinks if s['name'].startswith(PREFIX)}
|
||||
for s in self.pulse.items('sink-inputs'):
|
||||
if s['sink'] in virtual and not is_internal(s):
|
||||
self.restore(s, physical)
|
||||
for key in reversed(list(self.modules)):
|
||||
try:
|
||||
self.unload(key)
|
||||
except AudioError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--port', type=int, default=8765)
|
||||
parser.add_argument('--config', default=str(ROOT / '.voicepult' / 'config.json'))
|
||||
parser.add_argument('--lan', action='store_true', help='Zugriff im lokalen Netzwerk mit Zugangscode aktivieren')
|
||||
args = parser.parse_args()
|
||||
# One instance per user, even with different config paths.
|
||||
runtime = Path(os.environ.get('XDG_RUNTIME_DIR', '/tmp'))
|
||||
lockfile = (runtime / f'voicepult-{os.getuid()}.lock').open('w')
|
||||
try:
|
||||
fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
parser.exit(1, 'VoicePult läuft bereits. Öffne http://127.0.0.1:8765\n')
|
||||
panel = Panel(args.config)
|
||||
access = LanAccess(args.config, args.lan, args.port)
|
||||
meters = MeterHub()
|
||||
token = secrets.token_urlsafe(32)
|
||||
stop = threading.Event()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *values):
|
||||
if values and str(values[1] if len(values) > 1 else '') != '200':
|
||||
super().log_message(fmt, *values)
|
||||
|
||||
def send(self, code, payload, mime='application/json; charset=utf-8'):
|
||||
if not isinstance(payload, bytes):
|
||||
payload = json.dumps(payload, ensure_ascii=False).encode()
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', mime)
|
||||
self.send_header('Content-Length', str(len(payload)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('X-Content-Type-Options', 'nosniff')
|
||||
self.send_header('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; frame-ancestors 'none'")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(payload)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def valid_host(self):
|
||||
host = self.headers.get('Host', '')
|
||||
if host in (f'127.0.0.1:{args.port}', f'localhost:{args.port}'):
|
||||
return True
|
||||
if not args.lan:
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse('http://' + host)
|
||||
ipaddress.ip_address(parsed.hostname)
|
||||
return parsed.port == args.port
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def authenticated(self):
|
||||
return access.allowed(self.client_address[0], self.headers.get('X-VoicePult-Access', ''))
|
||||
|
||||
def do_GET(self):
|
||||
if not self.valid_host():
|
||||
return self.send(403, {'error': 'Ungültiger Host.'})
|
||||
path = urlparse(self.path).path
|
||||
if path.startswith('/api/') and not self.authenticated():
|
||||
return self.send(401, {'error': 'Zugangscode erforderlich.'})
|
||||
if path == '/api/meter':
|
||||
if self.headers.get('X-VoicePult-Token') != token:
|
||||
return self.send(403, {'error': 'Zugriff verweigert.'})
|
||||
source = parse_qs(urlparse(self.path).query).get('source', [MIC])[0]
|
||||
with panel.lock:
|
||||
allowed = panel.meter_sources()
|
||||
if source not in allowed:
|
||||
return self.send(400, {'error': 'Unbekannte Audioquelle.'})
|
||||
return self.send(200, meters.snapshot(source))
|
||||
if path == '/api/state':
|
||||
return self.send(200, {**panel.state(), 'token': token, 'network': access.info(self.client_address[0])})
|
||||
files = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript; charset=utf-8'),
|
||||
'/style.css': ('style.css', 'text/css; charset=utf-8'), '/icon.svg': ('icon.svg', 'image/svg+xml')}
|
||||
if path not in files:
|
||||
return self.send(404, {'error': 'Nicht gefunden.'})
|
||||
name, mime = files[path]
|
||||
self.send(200, (ROOT / 'web' / name).read_bytes(), mime)
|
||||
|
||||
def do_POST(self):
|
||||
if not self.authenticated():
|
||||
return self.send(401, {'error': 'Zugangscode erforderlich.'})
|
||||
if not self.valid_host() or self.headers.get('X-VoicePult-Token') != token:
|
||||
return self.send(403, {'error': 'Zugriff verweigert. Seite neu laden.'})
|
||||
if self.path != '/api/action':
|
||||
return self.send(404, {'error': 'Nicht gefunden.'})
|
||||
try:
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
if not 0 < length <= 8192:
|
||||
raise ValueError('Ungültige Anfragegröße.')
|
||||
data = json.loads(self.rfile.read(length))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Ungültige Anfrage.')
|
||||
self.send(200, panel.action(data))
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.send(400, {'error': str(exc)})
|
||||
except AudioError as exc:
|
||||
panel.error = str(exc)
|
||||
self.send(503, {'error': str(exc)})
|
||||
|
||||
httpd = ThreadingHTTPServer(('0.0.0.0' if args.lan else '127.0.0.1', args.port), Handler)
|
||||
def worker():
|
||||
recovered = False
|
||||
while not stop.is_set():
|
||||
with panel.lock:
|
||||
try:
|
||||
if not recovered:
|
||||
panel.recover()
|
||||
recovered = True
|
||||
panel.reconcile()
|
||||
except (AudioError, ValueError) as exc:
|
||||
panel.error = str(exc)
|
||||
stop.wait(2)
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
def shutdown(*_):
|
||||
stop.set()
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
print(f'VoicePult bereit: http://127.0.0.1:{args.port}', flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
finally:
|
||||
stop.set()
|
||||
httpd.server_close()
|
||||
meters.close()
|
||||
try:
|
||||
panel.close()
|
||||
except AudioError as exc:
|
||||
print(f'Audio-Bereinigung: {exc}', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install VoicePult for the logged-in Linux desktop user.
|
||||
set -Eeuo pipefail
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
NO_START=0
|
||||
NO_OPEN=0
|
||||
AUTOSTART=1
|
||||
SKIP_PACKAGES=0
|
||||
DRY_RUN=0
|
||||
LAN_ARGS=()
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
VoicePult – automatische Einrichtung für Linux
|
||||
|
||||
Aufruf: ./setup.sh [Optionen]
|
||||
|
||||
Ohne sudo in der angemeldeten Desktop-Sitzung ausführen. Nur fehlende
|
||||
Systempakete werden über sudo installiert. Unterstützt apt, dnf und pacman.
|
||||
|
||||
--lan Tablet-/Handyzugriff mit Zugangscode aktivieren
|
||||
--no-start Dateien einrichten, aber keine Dienste starten
|
||||
--no-open Nach der Einrichtung keinen Browser öffnen
|
||||
--no-autostart Nicht automatisch bei der Anmeldung starten
|
||||
--skip-packages Paketinstallation überspringen (Voraussetzungen prüfen)
|
||||
--dry-run Einrichtung anzeigen, ohne Änderungen vorzunehmen
|
||||
-h, --help Diese Hilfe anzeigen
|
||||
|
||||
Die Installation erfolgt benutzerspezifisch in den XDG-Daten-,
|
||||
Konfigurations- und Statusverzeichnissen. Vorhandene Einstellungen bleiben
|
||||
bei erneuter Ausführung erhalten. Die heruntergeladene Projektkopie kann
|
||||
anschließend verschoben werden.
|
||||
HELP
|
||||
}
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--lan) LAN_ARGS=(--lan) ;;
|
||||
--no-start) NO_START=1; NO_OPEN=1 ;;
|
||||
--no-open) NO_OPEN=1 ;;
|
||||
--no-autostart) AUTOSTART=0 ;;
|
||||
--skip-packages) SKIP_PACKAGES=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'Unbekannte Option: %s\n' "$arg" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
say() { printf '\n[VoicePult] %s\n' "$*"; }
|
||||
fail() { printf '\n[VoicePult] Fehler: %s\n' "$*" >&2; exit 1; }
|
||||
trap 'printf "\n[VoicePult] Einrichtung fehlgeschlagen (Zeile %s). Nach Behebung erneut ./setup.sh ausführen.\n" "$LINENO" >&2' ERR
|
||||
[[ "$(uname -s)" == Linux ]] || fail 'Diese Version unterstützt Linux, nicht Windows oder macOS.'
|
||||
[[ "$EUID" -ne 0 ]] || fail 'Bitte ./setup.sh als normalen Desktop-Benutzer ausführen, nicht mit sudo. Paketinstallationen fragen sudo selbst an.'
|
||||
for file in lan_access.py audio_meter.py server.py start.sh web/index.html web/app.js web/style.css web/icon.svg scripts/install.py scripts/launcher.py; do
|
||||
[[ -f "$SCRIPT_DIR/$file" ]] || fail "Projektdatei fehlt: $file. Bitte den vollständigen VoicePult-Ordner kopieren."
|
||||
done
|
||||
|
||||
# Validate XDG destinations before using them as installation paths.
|
||||
for destination in "${XDG_DATA_HOME:-$HOME/.local/share}" "${XDG_CONFIG_HOME:-$HOME/.config}" "${XDG_STATE_HOME:-$HOME/.local/state}"; do
|
||||
[[ "$destination" == /* && "$destination" != / && "$destination" != *$'\n'* && "$destination" != *$'\r'* && "$destination" != *$'\t'* ]] || fail 'XDG-Verzeichnisse müssen absolute Pfade ohne Steuerzeichen sein.'
|
||||
done
|
||||
|
||||
manager=''
|
||||
if command -v apt-get >/dev/null; then manager=apt
|
||||
elif command -v dnf >/dev/null; then manager=dnf
|
||||
elif command -v pacman >/dev/null; then manager=pacman
|
||||
fi
|
||||
packages=()
|
||||
if ! command -v python3 >/dev/null || ! python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' 2>/dev/null; then
|
||||
if [[ "$manager" == pacman ]]; then packages+=(python); else packages+=(python3); fi
|
||||
fi
|
||||
if ! command -v pactl >/dev/null || ! command -v parec >/dev/null; then
|
||||
if [[ "$manager" == pacman ]]; then packages+=(libpulse); else packages+=(pulseaudio-utils); fi
|
||||
fi
|
||||
command -v xdg-open >/dev/null || packages+=(xdg-utils)
|
||||
browser_found=0
|
||||
for browser_cmd in firefox firefox-esr chromium chromium-browser google-chrome brave-browser microsoft-edge epiphany; do
|
||||
if command -v "$browser_cmd" >/dev/null; then browser_found=1; break; fi
|
||||
done
|
||||
if [[ "$browser_found" == 0 ]] && command -v xdg-settings >/dev/null; then
|
||||
# Also recognize browsers installed through Flatpak or desktop launchers.
|
||||
if [[ -n "$(xdg-settings get default-web-browser 2>/dev/null || true)" ]]; then browser_found=1; fi
|
||||
fi
|
||||
if [[ "$browser_found" == 0 ]]; then
|
||||
os_id="$(awk -F= '$1=="ID" {gsub(/"/,"",$2); print $2}' /etc/os-release 2>/dev/null || true)"
|
||||
if [[ "$manager" == apt && "$os_id" != ubuntu && "$os_id" != linuxmint && "$os_id" != pop && "$os_id" != neon ]]; then
|
||||
packages+=(firefox-esr)
|
||||
else
|
||||
packages+=(firefox)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Do not replace an existing PulseAudio or PipeWire installation.
|
||||
audio_ready=0
|
||||
new_audio=0
|
||||
if command -v pactl >/dev/null && timeout 5 pactl info >/dev/null 2>&1; then audio_ready=1; fi
|
||||
if [[ "$audio_ready" == 0 ]] && ! command -v pipewire-pulse >/dev/null && ! command -v pulseaudio >/dev/null; then
|
||||
new_audio=1
|
||||
case "$manager" in
|
||||
apt) packages+=(pipewire pipewire-pulse wireplumber) ;;
|
||||
dnf) packages+=(pipewire pipewire-pulseaudio wireplumber) ;;
|
||||
pacman) packages+=(pipewire pipewire-audio pipewire-pulse wireplumber) ;;
|
||||
*) [[ "$SKIP_PACKAGES" == 1 ]] || fail 'Kein unterstützter Paketmanager. Python ≥ 3.10, pactl, xdg-open und PipeWire-Pulse installieren und --skip-packages verwenden.' ;;
|
||||
esac
|
||||
fi
|
||||
say "Installation für $USER"
|
||||
printf ' Programm: %s/voicepult/app\n' "${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
printf ' Einstellungen: %s/voicepult/config.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
printf ' Protokoll: %s/voicepult/server.log\n' "${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||
printf ' Autostart: %s\n' "$([[ "$AUTOSTART" == 1 ]] && printf ja || printf nein)"
|
||||
if ((${#packages[@]})); then printf ' Fehlende Pakete (%s): %s\n' "${manager:-manuell}" "${packages[*]}"; fi
|
||||
if [[ "$DRY_RUN" == 1 ]]; then
|
||||
say 'Vorschau beendet. Keine Dateien, Pakete oder Dienste geändert.'
|
||||
exit 0
|
||||
fi
|
||||
if ((${#packages[@]})) && [[ "$SKIP_PACKAGES" == 0 ]]; then
|
||||
[[ -n "$manager" ]] || fail 'Kein unterstützter Paketmanager; Voraussetzungen manuell installieren und --skip-packages verwenden.'
|
||||
command -v sudo >/dev/null || fail 'sudo fehlt. Bitte die oben genannten Pakete administrativ installieren und Setup erneut ausführen.'
|
||||
say 'Installiere fehlende Systempakete. sudo fragt gegebenenfalls dein Passwort ab.'
|
||||
sudo -v
|
||||
case "$manager" in
|
||||
apt) sudo apt-get update; sudo apt-get install -y --no-remove "${packages[@]}" ;;
|
||||
dnf) sudo dnf install -y "${packages[@]}" ;;
|
||||
pacman) sudo pacman -S --needed --noconfirm "${packages[@]}" ;;
|
||||
esac
|
||||
fi
|
||||
command -v python3 >/dev/null || fail 'Python 3 fehlt.'
|
||||
python3 -c 'import sys; sys.exit(sys.version_info < (3, 10))' || fail 'Python ≥ 3.10 erforderlich. Bitte das Betriebssystem bzw. Python aktualisieren.'
|
||||
command -v pactl >/dev/null || fail 'pactl fehlt. Bitte pulseaudio-utils (Arch: libpulse) installieren.'
|
||||
command -v xdg-open >/dev/null || fail 'xdg-open fehlt. Bitte xdg-utils installieren.'
|
||||
if [[ "$browser_found" == 0 && "$SKIP_PACKAGES" == 1 ]]; then
|
||||
say 'Kein Browser erkannt. Bitte einen Browser installieren oder die lokale URL in einem vorhandenen Browser öffnen.'
|
||||
fi
|
||||
|
||||
# Only start the already-installed audio stack if it is currently unreachable.
|
||||
if [[ "$NO_START" == 0 && "$audio_ready" == 0 ]]; then
|
||||
say 'Prüfe den Audioserver der Desktop-Sitzung.'
|
||||
if command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then
|
||||
if command -v pipewire-pulse >/dev/null; then
|
||||
if [[ "$new_audio" == 1 ]]; then
|
||||
systemctl --user enable --now pipewire.socket pipewire-pulse.socket wireplumber.service || fail 'Der neue PipeWire-Dienst konnte nicht aktiviert werden.'
|
||||
else
|
||||
systemctl --user start pipewire.socket pipewire-pulse.socket wireplumber.service || fail 'PipeWire konnte nicht gestartet werden. Bitte in einer angemeldeten Desktop-Sitzung erneut versuchen.'
|
||||
fi
|
||||
elif command -v pulseaudio >/dev/null; then
|
||||
systemctl --user start pulseaudio.socket || fail 'PulseAudio konnte nicht gestartet werden.'
|
||||
fi
|
||||
elif command -v pulseaudio >/dev/null; then
|
||||
pulseaudio --start || fail 'PulseAudio konnte nicht gestartet werden.'
|
||||
fi
|
||||
for attempt in {1..10}; do
|
||||
if timeout 5 pactl info >/dev/null 2>&1; then audio_ready=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$audio_ready" == 1 ]] || fail 'Kein Audioserver erreichbar. Bitte in deiner grafischen Desktop-Sitzung ausführen; pactl info muss funktionieren. Bei Systemen ohne systemd PipeWire über die Sitzungsverwaltung starten.'
|
||||
fi
|
||||
|
||||
say 'Installiere Programm, Menüeintrag und Benutzer-Autostart.'
|
||||
python3 "$SCRIPT_DIR/scripts/install.py" --source "$SCRIPT_DIR" --autostart "$AUTOSTART" --start "$((1-NO_START))" --open "$((1-NO_OPEN))" "${LAN_ARGS[@]}"
|
||||
say 'Einrichtung abgeschlossen. VoicePult findest du im Anwendungsmenü.'
|
||||
printf ' Oberfläche: http://127.0.0.1:8765\n'
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
|
||||
exec python3 server.py "$@"
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
'use strict';
|
||||
const icons = {
|
||||
plus:'M12 5v14M5 12h14', arrow:'M5 12h14m-5-5 5 5-5 5', close:'m6 6 12 12M6 18 18 6',
|
||||
apps:'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z',
|
||||
sliders:'M5 4v7m0 4v5M12 4v3m0 4v9M19 4v11m0 4v1M2 11h6M9 7h6m1 8h6',
|
||||
routing:'M5 6h5a3 3 0 0 1 3 3v6a3 3 0 0 0 3 3h3M5 18h4m7-12h3M3 4h3v4H3zM18 4h3v4h-3zM3 16h3v4H3zM18 16h3v4h-3z',
|
||||
book:'M12 5v15M3 4c4-1 7 0 9 2 2-2 5-3 9-2v15c-4-1-7 0-9 2-2-2-5-3-9-2z',
|
||||
info:'M12 11v6m0-10v.1M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',
|
||||
headphones:'M4 14v-3a8 8 0 0 1 16 0v3M4 12h3v8H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2m16 0h-3v8h3a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2',
|
||||
mic:'M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0zm-3 6v1a6 6 0 0 0 12 0v-1M12 18v4m-3 0h6',
|
||||
volume:'M11 4 6 8H2v8h4l5 4zM15 8a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14',
|
||||
mute:'M11 4 6 8H2v8h4l5 4zm5 5 6 6m-6 0 6-6',
|
||||
trash:'M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7',
|
||||
edit:'m14 4 6 6M4 20l1-6L16 3a2 2 0 0 1 3 0l2 2a2 2 0 0 1 0 3L10 19z',
|
||||
check:'m5 12 4 4L19 6', link:'m10 14 4-4m-6 6-2 2a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m4 0 2-2a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0',
|
||||
wave:'M3 10v4m4-8v12m5-15v18m5-15v12m4-8v4',
|
||||
};
|
||||
const svg = name => `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${icons[name] || icons.wave}"/></svg>`;
|
||||
document.querySelectorAll('[data-icon]').forEach(el => el.innerHTML = svg(el.dataset.icon));
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
let accessCode = localStorage.getItem('voicepult-access') || '';
|
||||
const accessHeaders = () => ({'X-VoicePult-Access': accessCode});
|
||||
let state, token, busy = false, editing = null, deleting = null, toastTimer, lastRendered = '';
|
||||
function toast(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').hidden = true, 4000); }
|
||||
function option(id, name, current) { return `<option value="${esc(id)}" ${id === current ? 'selected' : ''}>${esc(name)}</option>`; }
|
||||
function deviceOptions(list, current) { return (list.some(d => d.id === current) ? '' : option(current, 'Gerät nicht verfügbar', current)) + list.map(d => option(d.id, d.name, current)).join(''); }
|
||||
function appCard(a, s, view) {
|
||||
const cls = ['firefox','discord','spotify'].find(c => a.name.toLowerCase().includes(c)) || '';
|
||||
return `<article class="app-card"><div class="app-top"><div class="app-icon ${cls}">${esc((a.name[0] || '?').toUpperCase())}</div><div><div class="app-name">${esc(a.name)}</div><div class="app-state ${a.active ? '' : 'idle'}"><span class="local-dot"></span>${a.active ? 'Spielt Audio' : a.present === false ? 'Ohne Audiostream' : 'Pausiert'}</div></div><span>${a.streams > 1 ? a.streams + ' Streams' : ''}</span></div><div class="field-label">KANÄLE · MEHRFACHAUSWAHL ${svg('routing')}</div><div class="channel-choices">${s.channels.map(c=>`<label><input type="checkbox" id="${esc(view + '-' + encodeURIComponent(a.id) + '-' + c.id)}" data-app-route="${esc(a.id)}" value="${c.id}" ${(a.channels||[]).includes(c.id)?'checked':''} aria-label="${esc(a.name)} an ${esc(c.name)}">${esc(c.name)}</label>`).join('') || '<small>Erstelle zuerst einen Kanal.</small>'}</div><small class="routing-note">${a.channels?.length ? a.channels.length + (a.channels.length===1?' Kanal verbunden':' Kanäle verbunden') : 'Keine Auswahl: direkter Geräteausgang'}</small></article>`;
|
||||
}
|
||||
function render(s) {
|
||||
state = s;
|
||||
updateScopeControls(s);
|
||||
document.documentElement.dataset.theme = s.theme || 'light';
|
||||
$('dark-mode').checked = s.theme === 'dark';
|
||||
if (document.activeElement !== $('wave-gain')) $('wave-gain').value = s.wave_gain ?? 8;
|
||||
$('wave-gain-value').textContent = '×' + $('wave-gain').value;
|
||||
$('wave-scale-label').textContent = 'Ansicht ×' + $('wave-gain').value + ' · Pegel unverändert';
|
||||
if (s.network) {
|
||||
$('network-panel').hidden = !s.network.enabled;
|
||||
$('network-links').innerHTML = s.network.urls.map(url=>`<a href="${esc(url)}">${esc(url)}</a>`).join('<br>');
|
||||
$('network-code').value = s.network.key || 'Auf dem Rechner anzeigen';
|
||||
$('reveal-code').hidden = !s.network.key;
|
||||
}
|
||||
$('engine-label').textContent = s.online ? 'PipeWire / PulseAudio bereit' : 'Audio nicht verbunden';
|
||||
$('engine-dot').classList.toggle('online', s.online);
|
||||
$('live-label').textContent = s.online ? 'Live verbunden' : 'Verbindung prüfen';
|
||||
document.querySelector('.local-badge').lastChild.textContent = s.online ? ' Lokal verbunden' : ' Audio offline';
|
||||
$('error-banner').hidden = !s.error;
|
||||
$('error-banner').textContent = s.error ? `Audio-Verbindung: ${s.error}` : '';
|
||||
$('app-count').textContent = s.apps.length;
|
||||
$('channel-count').textContent = s.channels.length;
|
||||
$('nav-count').textContent = s.channels.length;
|
||||
$('route-count').textContent = s.channels.reduce((n,c) => n + Number(c.monitor) + Number(c.microphone) + (c.targets||[]).length, 0) + Number(s.mic_enabled);
|
||||
$('saved-count').textContent = s.saved_apps?.length || 0;
|
||||
const signature = JSON.stringify([s.targets,s.saved_apps,s.apps,s.channels,s.outputs,s.inputs,s.output,s.input,s.mic_enabled,s.mic_available,s.mic_consumers,s.mic_channels]);
|
||||
// Preserve focus, open selectors and sliders while background state refreshes.
|
||||
if (signature === lastRendered || document.activeElement?.matches('select,input[type="range"]') && !busy) return;
|
||||
const focusedId = document.activeElement?.id;
|
||||
lastRendered = signature;
|
||||
$('apps').innerHTML = s.apps.length ? s.apps.map(a => appCard(a,s,'live')).join('') : `<div class="empty-card">${svg('apps')}<strong>Noch keine Audioquellen</strong><p>Spiele Audio in Firefox oder einer anderen Anwendung ab.</p><a href="#saved-apps">Gespeicherte Apps öffnen</a></div>`;
|
||||
$('saved-app-list').innerHTML = s.saved_apps?.length ? s.saved_apps.map(a => appCard(a,s,'saved')).join('') : `<div class="empty-card">${svg('apps')}<strong>Noch keine gespeicherten Zuordnungen</strong><p>Ordne im Patchpanel einer Anwendung einen Kanal zu. Sie bleibt dann hier auch ohne Wiedergabe sichtbar.</p><a href="#patchpanel">Zum Patchpanel</a></div>`;
|
||||
$('microphone-routing').innerHTML = `<article class="app-card microphone-source"><div class="app-top"><div class="app-icon">${svg('mic')}</div><div><div class="app-name">Dein Mikrofon</div><div class="app-state">${esc(s.inputs.find(d=>d.id===s.input)?.name || 'Gerät wählen')}</div></div></div><div class="field-label">AN KANÄLE SENDEN · MEHRFACHAUSWAHL</div><div class="channel-choices">${s.channels.map(c=>`<label><input type="checkbox" data-mic-route value="${c.id}" ${(s.mic_channels||[]).includes(c.id)?'checked':''} aria-label="Mikrofon an ${esc(c.name)}">${esc(c.name)}</label>`).join('') || '<small>Erstelle zuerst einen Kanal.</small>'}</div><small class="routing-note">Mehrere Wege zum gleichen Ziel werden addiert. Kopfhörer am Mikrofon-Kanal nur zum Mithören aktivieren.</small></article>`;
|
||||
$('channels').innerHTML = s.channels.length ? s.channels.map((c,i) => {
|
||||
const apps = s.apps.filter(a => (a.channels||[]).includes(c.id));
|
||||
const count = apps.length;
|
||||
return `<article class="channel-card"><div class="channel-header"><span class="channel-symbol">${svg('sliders')}</span><span class="channel-name">${esc(c.name)}</span><span class="channel-id">CH ${String(i+1).padStart(2,'0')}</span><button class="icon-button" data-edit="${c.id}" aria-label="${esc(c.name)} umbenennen" title="Umbenennen">${svg('edit')}</button><button class="icon-button" data-delete="${c.id}" aria-label="${esc(c.name)} löschen" title="Kanal löschen">${svg('trash')}</button></div><div class="channel-body"><div class="channel-caption"><span>${count ? count + (count === 1 ? ' Anwendung verbunden' : ' Anwendungen verbunden') : 'Bereit für deine Anwendungen'}</span><span>${c.muted ? 'STUMM' : 'STEREO'}</span></div><div class="volume-row"><button class="icon-button ${c.muted?'muted':''}" data-mute="${c.id}" aria-label="${esc(c.name)} ${c.muted ? 'einschalten' : 'stummschalten'}" aria-pressed="${c.muted}">${svg(c.muted?'mute':'volume')}</button><input id="volume-${c.id}" type="range" min="0" max="100" value="${c.volume}" data-volume="${c.id}" aria-label="Lautstärke ${esc(c.name)}"><output>${c.volume}%</output></div><span class="route-label">WEITERLEITEN AN</span><div class="route-buttons"><button class="route-button ${c.monitor?'active':''}" data-route="monitor" data-channel="${c.id}" aria-pressed="${c.monitor}">${svg('headphones')}Kopfhörer ${c.monitor?svg('check'):''}</button><button class="route-button ${c.microphone?'active':''}" data-route="microphone" data-channel="${c.id}" aria-pressed="${c.microphone}">${svg('mic')}Mikrofon-Mix ${c.microphone?svg('check'):''}</button></div><div class="channel-choices extra-routes">${(s.targets||[]).map(t=>`<label><input type="checkbox" id="target-route-${c.id}-${t.id}" data-target-route="${c.id}" value="${t.id}" ${(c.targets||[]).includes(t.id)?'checked':''} aria-label="${esc(c.name)} an VoicePult ${esc(t.name)}">${svg('mic')}${esc(t.name)}</label>`).join('')}</div></div></article>`;
|
||||
}).join('') : `<div class="empty-card">${svg('sliders')}<strong>Dein erster Kanal</strong><p>Ein Kanal verbindet eine oder mehrere Anwendungen mit deinen Ausgängen.</p></div>`;
|
||||
const listening = s.channels.filter(c => c.monitor).length, sharing = s.channels.filter(c => c.microphone).length;
|
||||
$('outputs').innerHTML = `<article class="output-card"><div class="app-top"><div class="app-icon">${svg('headphones')}</div><div><div class="app-name">Kopfhörer</div><div class="app-state">Dein persönlicher Monitor</div></div></div><label class="field-label" for="output-device">AUSGABEGERÄT</label><select id="output-device" data-setting="output">${deviceOptions(s.outputs,s.output)}</select><div class="output-caption">${svg('link')}${listening} ${listening===1?'Kanal verbunden':'Kanäle verbunden'}</div></article><article class="output-card mic-card"><div class="app-top"><div class="app-icon">${svg('mic')}</div><div><div class="app-name">Mikrofon-Mix</div><div class="app-state">Eingabegerät für Element, OBS & Co.</div></div></div><div class="mic-name">${svg('wave')}VoicePult Mikrofon</div><div class="output-caption">${svg('link')}${sharing} ${sharing===1?'Kanal verbunden':'Kanäle verbunden'}</div><div class="output-caption">${svg(s.mic_available ? 'check' : 'info')}${s.mic_available ? 'Als Mikrofon verfügbar' : 'Eingabegerät nicht verfügbar'}</div><div class="output-caption">${svg('mic')}${s.mic_consumers?.length ? 'Verwendet von: ' + s.mic_consumers.map(esc).join(', ') : 'Noch in keiner App als Eingabe gewählt'}</div><div class="input-section"><label class="toggle-label" for="mic-enabled">Stimme direkt in den Mix<input id="mic-enabled" type="checkbox" data-setting="mic_enabled" ${s.mic_enabled?'checked':''}></label><label class="field-label" for="input-device">DEIN MIKROFON</label><select id="input-device" data-setting="input">${deviceOptions(s.inputs,s.input)}</select></div></article>`;
|
||||
$('extra-targets').innerHTML = (s.targets||[]).map(t=>`<article class="output-card"><div class="app-top"><div class="app-icon">${svg('mic')}</div><div><div class="app-name">${esc(t.name)}</div><div class="app-state">Virtuelles Mikrofon</div></div><button class="icon-button" data-target-delete="${t.id}" aria-label="Mikrofon ${esc(t.name)} löschen">${svg('trash')}</button></div><div class="mic-name">${svg('wave')}VoicePult ${esc(t.name)}</div><p class="routing-note">In deiner App als Eingabegerät auswählen.</p><div class="output-caption">${svg('link')}${s.channels.filter(c=>(c.targets||[]).includes(t.id)).length} Kanäle verbunden</div><div class="volume-row"><button class="icon-button" data-target-mute="${t.id}" aria-label="Mikrofon ${esc(t.name)} ${t.muted?'einschalten':'stummschalten'}" aria-pressed="${t.muted}">${svg(t.muted?'mute':'volume')}</button><input type="range" id="target-volume-${t.id}" data-target-volume="${t.id}" min="0" max="100" value="${t.volume}" aria-label="Lautstärke Mikrofon ${esc(t.name)}"><output>${t.volume}%</output></div></article>`).join('');
|
||||
if (focusedId) document.getElementById(focusedId)?.focus({preventScroll:true});
|
||||
}
|
||||
async function refresh() {
|
||||
if (busy || document.hidden) return;
|
||||
try {
|
||||
const res = await fetch('/api/state', {headers: accessHeaders(), signal: AbortSignal.timeout(12000)});
|
||||
if (res.status === 401) { token=null; if(!$('access-dialog').open) $('access-dialog').showModal(); return; }
|
||||
if (!res.ok) throw new Error('Server nicht erreichbar');
|
||||
const s = await res.json(); token = s.token; render(s);
|
||||
} catch (error) {
|
||||
$('error-banner').hidden = false;
|
||||
$('error-banner').textContent = 'VoicePult ist nicht erreichbar. Starte die App mit ./start.sh. Die Verbindung wird automatisch erneut versucht.';
|
||||
$('engine-label').textContent = 'Server nicht verbunden'; $('engine-dot').classList.remove('online'); $('live-label').textContent = 'Offline';
|
||||
}
|
||||
}
|
||||
async function action(payload, message) {
|
||||
if (busy) { await new Promise(resolve=>setTimeout(resolve,80)); return action(payload,message); }
|
||||
busy = true;
|
||||
try {
|
||||
const res = await fetch('/api/action', {method:'POST',headers:{...accessHeaders(),'Content-Type':'application/json','X-VoicePult-Token':token},body:JSON.stringify(payload),signal:AbortSignal.timeout(30000)});
|
||||
const result = await res.json(); if (!res.ok) throw new Error(result.error || 'Änderung fehlgeschlagen');
|
||||
render(result); if (message) toast(message); return true;
|
||||
} catch(error) { toast(error.message); lastRendered = ''; return false; }
|
||||
finally { busy = false; refresh(); }
|
||||
}
|
||||
function openChannel(id = null) {
|
||||
editing = id;
|
||||
$('dialog-title').textContent = id ? 'Kanal umbenennen' : 'Neuer virtueller Kanal';
|
||||
$('channel-submit').textContent = id ? 'Speichern' : 'Kanal erstellen';
|
||||
$('channel-name').value = id ? state.channels.find(c => c.id === id).name : '';
|
||||
$('channel-dialog').showModal(); $('channel-name').focus();
|
||||
}
|
||||
$('add-channel').onclick = () => openChannel(); $('add-channel-secondary').onclick = () => openChannel();
|
||||
$('help-button').onclick = $('help-inline').onclick = () => $('help-dialog').showModal();
|
||||
document.querySelectorAll('[data-close]').forEach(b => b.onclick = () => $(b.dataset.close).close());
|
||||
$('channel-form').onsubmit = async e => {
|
||||
e.preventDefault(); $('channel-submit').disabled = true;
|
||||
const ok = await action({action:editing?'channel':'create',id:editing,name:$('channel-name').value},editing?'Kanal umbenannt':'Kanal erstellt');
|
||||
$('channel-submit').disabled = false; if (ok) $('channel-dialog').close();
|
||||
};
|
||||
$('confirm-delete').onclick = async () => {
|
||||
$('confirm-delete').disabled = true;
|
||||
if (await action({action:'delete',id:deleting},'Kanal gelöscht')) $('delete-dialog').close();
|
||||
$('confirm-delete').disabled = false;
|
||||
};
|
||||
document.addEventListener('click', e => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
if (b.dataset.edit) openChannel(b.dataset.edit);
|
||||
if (b.dataset.delete) { deleting=b.dataset.delete; $('delete-message').textContent=`„${state.channels.find(c=>c.id===deleting).name}“ wird entfernt. Anwendungen wechseln zurück auf ihren Geräteausgang.`; $('delete-dialog').showModal(); }
|
||||
if (b.dataset.route) {
|
||||
const ch = state.channels.find(c => c.id === b.dataset.channel);
|
||||
action({action:'channel',id:ch.id,[b.dataset.route]:!ch[b.dataset.route]});
|
||||
}
|
||||
if (b.dataset.mute) { const ch=state.channels.find(c=>c.id===b.dataset.mute); action({action:'channel',id:ch.id,muted:!ch.muted}); }
|
||||
});
|
||||
document.addEventListener('input', e => { if(e.target.dataset.volume) e.target.nextElementSibling.textContent=e.target.value+'%'; });
|
||||
document.addEventListener('change', e => {
|
||||
const el=e.target;
|
||||
if(el.dataset.app) action({action:'assign',app:el.dataset.app,channel:el.value},'Verbindung gespeichert');
|
||||
if(el.dataset.volume) action({action:'channel',id:el.dataset.volume,volume:Number(el.value)});
|
||||
if(el.dataset.setting) action({action:'settings',[el.dataset.setting]:el.type==='checkbox'?el.checked:el.value});
|
||||
});
|
||||
refresh(); setInterval(refresh, 2500); document.addEventListener('visibilitychange', refresh);
|
||||
|
||||
|
||||
// Real PCM envelopes from the local audio server; no microphone browser permission.
|
||||
let scopeEnabled = true, scopeInFlight = false, scopeOptions = '', scopeController;
|
||||
function updateScopeControls(s) {
|
||||
scopeEnabled = s.scope_enabled ?? true;
|
||||
$('scope-toggle').textContent=scopeEnabled?'Anzeige pausieren':'Anzeige starten';
|
||||
$('scope-toggle').setAttribute('aria-pressed',String(scopeEnabled));
|
||||
if(!scopeEnabled) clearScope('Anzeige pausiert');
|
||||
const options = option('voicepult_microphone', 'Mikrofon-Mix', $('scope-source').value) + (s.targets||[]).map(t=>option('voicepult_target_'+t.id+'_mic','Mikrofon: '+t.name,$('scope-source').value)).join('') + s.channels.map(c => option('voicepult_'+c.id+'.monitor',c.name,$('scope-source').value)).join('');
|
||||
if (options !== scopeOptions) {
|
||||
const previous = $('scope-source').value;
|
||||
$('scope-source').innerHTML = options;
|
||||
if ([...$('scope-source').options].some(o => o.value === previous)) $('scope-source').value = previous;
|
||||
else clearScope('Quelle entfernt – Mikrofon-Mix gewählt.');
|
||||
scopeOptions = options;
|
||||
}
|
||||
if(s.scope_source && [...$('scope-source').options].some(o=>o.value===s.scope_source)) $('scope-source').value=s.scope_source;
|
||||
if (document.activeElement !== $('mix-volume')) {
|
||||
$('mix-volume').value = s.mix_volume ?? 100;
|
||||
$('mix-value').textContent = (s.mix_volume ?? 100) + '%';
|
||||
}
|
||||
$('mix-mute').setAttribute('aria-pressed', String(s.mix_muted ?? false));
|
||||
$('mix-mute').setAttribute('aria-label', s.mix_muted ? 'Mikrofon-Mix einschalten' : 'Mikrofon-Mix stummschalten');
|
||||
$('mix-mute').innerHTML = svg(s.mix_muted ? 'mute' : 'volume');
|
||||
}
|
||||
function drawScope(channels = []) {
|
||||
const canvas = $('scope-canvas'), width = canvas.clientWidth, height = 180;
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = height * scale;
|
||||
const ctx = canvas.getContext('2d'); ctx.scale(scale,scale);
|
||||
ctx.strokeStyle = document.documentElement.dataset.theme==='dark' ? '#33423a' : '#e1e8d7'; ctx.lineWidth = 1;
|
||||
for (let x = 0; x <= width; x += width / 10 || 1) { ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,height);ctx.stroke(); }
|
||||
[45,135].forEach(y=>{ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(width,y);ctx.stroke();});
|
||||
channels.forEach((channel,index) => {
|
||||
const mid = index ? 135 : 45, wave = channel.wave;
|
||||
ctx.strokeStyle = channel.clipping ? '#ec775d' : index ? '#a9c977' : '#7ea94f'; ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
wave.forEach((point,i) => { const x=(i+.5)*width/wave.length; ctx.moveTo(x,mid-Math.max(-1,Math.min(1,point[1]*Number($('wave-gain').value)))*40);ctx.lineTo(x,mid-Math.max(-1,Math.min(1,point[0]*Number($('wave-gain').value)))*40); });ctx.stroke();
|
||||
});
|
||||
}
|
||||
function clearScope(message) {
|
||||
drawScope(); $('scope-status').textContent=message; $('scope-clip').textContent='—';
|
||||
['left','right'].forEach(side=>{ $('meter-'+side).value=-60; $('level-'+side).textContent='—'; });
|
||||
}
|
||||
async function pollScope() {
|
||||
if ($('patchpanel').hidden || !scopeEnabled || document.hidden || !token || scopeInFlight) return;
|
||||
scopeInFlight=true; const source=$('scope-source').value;
|
||||
scopeController=new AbortController(); const timeout=setTimeout(()=>scopeController?.abort(),3000);
|
||||
try {
|
||||
const res=await fetch('/api/meter?source='+encodeURIComponent(source),{headers:{...accessHeaders(),'X-VoicePult-Token':token},signal:scopeController.signal});
|
||||
const data=await res.json();
|
||||
if (!scopeEnabled || source!==$('scope-source').value || document.hidden) return;
|
||||
if (!res.ok || !data.ready) { clearScope(data.error || 'Verbinde Audioanzeige…'); return; }
|
||||
drawScope(data.channels);
|
||||
$('scope-status').textContent='Live · '+$('scope-source').selectedOptions[0].textContent;
|
||||
data.channels.forEach((c,i)=>{ const side=i?'right':'left'; $('meter-'+side).value=c.db ?? -60; $('level-'+side).textContent=c.db===null?'−∞ dBFS':c.db.toFixed(1)+' dBFS'; });
|
||||
$('scope-clip').textContent=data.channels.some(c=>c.clipping)?'Übersteuerung erkannt':'Kein Clipping';
|
||||
} catch(e) { if(scopeEnabled && !document.hidden) clearScope('Keine Live-Audiodaten'); }
|
||||
finally {clearTimeout(timeout);scopeInFlight=false;scopeController=null;}
|
||||
}
|
||||
$('scope-toggle').onclick=()=>{
|
||||
scopeEnabled=!scopeEnabled; scopeController?.abort();
|
||||
$('scope-toggle').textContent=scopeEnabled?'Anzeige pausieren':'Anzeige starten';
|
||||
$('scope-toggle').setAttribute('aria-pressed',String(scopeEnabled));
|
||||
clearScope(scopeEnabled?'Verbinde Audioanzeige…':'Anzeige pausiert'); action({action:'preferences',scope_enabled:scopeEnabled}); if(scopeEnabled) pollScope();
|
||||
};
|
||||
$('scope-source').onchange=()=>{scopeController?.abort();clearScope('Verbinde Audioanzeige…');action({action:'preferences',scope_source:$('scope-source').value});};
|
||||
$('mix-volume').oninput=e=>$('mix-value').textContent=e.target.value+'%';
|
||||
$('mix-volume').onchange=()=>action({action:'mix',volume:Number($('mix-volume').value)});
|
||||
$('mix-mute').onclick=()=>{if(state) action({action:'mix',muted:!state.mix_muted});};
|
||||
document.addEventListener('visibilitychange',()=>{if(document.hidden){scopeController?.abort();clearScope('Anzeige pausiert');}else pollScope();});
|
||||
window.addEventListener('resize',()=>drawScope());
|
||||
setInterval(pollScope,100);
|
||||
|
||||
$('dark-mode').onchange=()=>action({action:'preferences',theme:$('dark-mode').checked?'dark':'light'});
|
||||
$('wave-gain').oninput=()=>{
|
||||
$('wave-gain-value').textContent='×'+$('wave-gain').value;
|
||||
$('wave-scale-label').textContent='Ansicht ×'+$('wave-gain').value+' · Pegel unverändert';
|
||||
};
|
||||
$('wave-gain').onchange=()=>action({action:'preferences',wave_gain:Number($('wave-gain').value)});
|
||||
$('reveal-code').onclick=()=>{ const show=$('network-code').type==='password';$('network-code').type=show?'text':'password';$('reveal-code').textContent=show?'Code verbergen':'Code anzeigen'; };
|
||||
$('access-dialog').addEventListener('cancel',e=>e.preventDefault());
|
||||
$('access-form').onsubmit=async e=>{
|
||||
e.preventDefault(); accessCode=$('access-code').value.trim();
|
||||
try {
|
||||
const res=await fetch('/api/state',{headers:accessHeaders()});
|
||||
if(!res.ok) throw new Error('Zugangscode nicht korrekt.');
|
||||
const s=await res.json(); token=s.token; localStorage.setItem('voicepult-access',accessCode);
|
||||
$('access-dialog').close();$('access-error').textContent='';render(s);
|
||||
} catch(error){$('access-error').textContent=error.message;}
|
||||
};
|
||||
document.addEventListener('change',e=>{
|
||||
const el=e.target;
|
||||
if(el.hasAttribute('data-app-route')) {
|
||||
const channels=[...el.closest('.app-card').querySelectorAll('[data-app-route]')].filter(c=>c.dataset.appRoute===el.dataset.appRoute && c.checked).map(c=>c.value);
|
||||
action({action:'assign',app:el.dataset.appRoute,channels},channels.length ? 'App-Zuordnung gespeichert' : 'App-Zuordnung entfernt');
|
||||
}
|
||||
if(el.hasAttribute('data-mic-route')) action({action:'mic_channels',channels:[...document.querySelectorAll('[data-mic-route]:checked')].map(c=>c.value)});
|
||||
});
|
||||
|
||||
function showWorkspace() {
|
||||
const saved = location.hash === '#saved-apps';
|
||||
$('patchpanel').hidden = saved;
|
||||
$('saved-apps').hidden = !saved;
|
||||
$('workspace-title').textContent = saved ? 'Gespeicherte Apps' : 'Patchpanel';
|
||||
document.querySelectorAll('[data-workspace]').forEach(link => {
|
||||
const selected = link.dataset.workspace === (saved ? 'saved-apps' : 'patchpanel');
|
||||
link.classList.toggle('selected', selected);
|
||||
if (selected) link.setAttribute('aria-current','page'); else link.removeAttribute('aria-current');
|
||||
});
|
||||
if (saved) scopeController?.abort(); else pollScope();
|
||||
}
|
||||
window.addEventListener('hashchange', showWorkspace);
|
||||
showWorkspace();
|
||||
|
||||
let deletingTarget = null;
|
||||
$('add-target').onclick=()=>{ $('target-name').value=''; $('target-dialog').showModal(); $('target-name').focus(); };
|
||||
$('target-form').onsubmit=async e=>{
|
||||
e.preventDefault(); $('target-submit').disabled=true;
|
||||
if(await action({action:'target_create',name:$('target-name').value},'Virtuelles Mikrofon erstellt')) $('target-dialog').close();
|
||||
$('target-submit').disabled=false;
|
||||
};
|
||||
$('target-delete-confirm').onclick=async()=>{
|
||||
$('target-delete-confirm').disabled=true;
|
||||
if(await action({action:'target_delete',id:deletingTarget},'Virtuelles Mikrofon gelöscht')) $('target-delete-dialog').close();
|
||||
$('target-delete-confirm').disabled=false;
|
||||
};
|
||||
document.addEventListener('click',e=>{
|
||||
const b=e.target.closest('button'); if(!b)return;
|
||||
if(b.dataset.targetDelete){deletingTarget=b.dataset.targetDelete;$('target-delete-message').textContent='„VoicePult '+state.targets.find(t=>t.id===deletingTarget).name+'“ und seine Kanalverbindungen werden entfernt.';$('target-delete-dialog').showModal();}
|
||||
if(b.dataset.targetMute){const t=state.targets.find(t=>t.id===b.dataset.targetMute);action({action:'target',id:t.id,muted:!t.muted});}
|
||||
});
|
||||
document.addEventListener('input',e=>{if(e.target.dataset.targetVolume)e.target.nextElementSibling.textContent=e.target.value+'%';});
|
||||
document.addEventListener('change',e=>{
|
||||
const el=e.target;
|
||||
if(el.dataset.targetVolume)action({action:'target',id:el.dataset.targetVolume,volume:Number(el.value)});
|
||||
if(el.dataset.targetRoute)action({action:'channel',id:el.dataset.targetRoute,targets:[...el.closest('.extra-routes').querySelectorAll('input:checked')].map(c=>c.value)});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="14" fill="#d7ef87"/><g stroke="#253329" stroke-width="4" stroke-linecap="round"><path d="M13 20v8m7-14v20m8-24v28m7-18v8"/></g></svg>
|
||||
|
After Width: | Height: | Size: 227 B |
|
|
@ -0,0 +1,33 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#f4f5ef"><title>VoicePult · Dein Audio, verbunden.</title><link rel="icon" href="/icon.svg"><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
|
||||
<body>
|
||||
<aside class="sidebar"><a class="brand" href="/" aria-label="VoicePult Startseite"><img src="/icon.svg" alt=""><span>voicepult<span class="brand-dot">.</span></span></a><div class="workspace"><span class="workspace-icon">V</span><div>Mein Studio<small>Lokaler Arbeitsbereich</small></div><span class="local-dot"></span></div><div class="nav-label">ARBEITSBEREICH</div><a class="nav-item selected" data-workspace="patchpanel" href="#patchpanel"><span data-icon="routing"></span>Patchpanel<span class="nav-count" id="nav-count">0</span></a><a class="nav-item" data-workspace="saved-apps" href="#saved-apps"><span data-icon="apps"></span>Gespeicherte Apps<span class="nav-count" id="saved-count">0</span></a><button class="nav-item" id="help-button"><span data-icon="book"></span>So funktioniert’s<span data-icon="arrow"></span></button><div class="sidebar-bottom"><div class="engine"><span class="status-dot" id="engine-dot"></span><div id="engine-label">Audio verbinden…</div></div><p>Ein Ort für deinen Sound.<br>Alles bleibt auf deinem Rechner.</p><span class="version">VOICEPULT / 01</span></div></aside>
|
||||
<div class="page"><header class="topbar"><div class="breadcrumb">Mein Studio <span>/</span> <strong id="workspace-title">Patchpanel</strong></div><label class="toggle-label theme-toggle" for="dark-mode">Darkmode<input id="dark-mode" type="checkbox" role="switch"></label><div class="local-badge"><span class="local-dot"></span> Lokal verbunden</div></header>
|
||||
<nav class="mobile-workspaces" aria-label="Arbeitsbereiche"><a data-workspace="patchpanel" href="#patchpanel">Patchpanel</a><a data-workspace="saved-apps" href="#saved-apps">Gespeicherte Apps</a></nav>
|
||||
<div class="error-banner workspace-error" id="error-banner" role="alert" hidden></div>
|
||||
<main id="patchpanel"><div class="page-heading"><div><div class="eyebrow">DEIN AUDIO. DEINE VERBINDUNGEN.</div><h1>Alles im richtigen Kanal<span>.</span></h1><p>Verbinde deine Anwendungen. Bestimme, wer was hört.</p></div><button class="button primary" id="add-channel"><span data-icon="plus"></span>Neuer Kanal</button></div>
|
||||
|
||||
<section class="overview" aria-label="Übersicht"><div><span class="overview-icon" data-icon="apps"></span><div><strong id="app-count">—</strong><span>Anwendungen</span></div><span class="stat-note">Audioquellen</span></div><div><span class="overview-icon" data-icon="sliders"></span><div><strong id="channel-count">0</strong><span>Virtuelle Kanäle</span></div><span class="stat-note">Dein Mix</span></div><div><span class="overview-icon" data-icon="routing"></span><div><strong id="route-count">0</strong><span>Verbindungen</span></div><span class="stat-note"><span class="local-dot"></span>Live-Routing</span></div></section>
|
||||
<section class="scope-panel" aria-label="Live-Wellenform und Mixer">
|
||||
<div class="scope-heading"><div><span class="eyebrow">LIVE AUDIO</span><h2>Deinen Sound im Blick.</h2></div><div class="scope-controls"><label for="scope-source">Signal</label><select id="scope-source"><option value="voicepult_microphone">Mikrofon-Mix</option></select><button class="button" id="scope-toggle" aria-pressed="true">Anzeige pausieren</button></div></div>
|
||||
<div class="wave-gain"><label for="wave-gain">Wellenform vergrößern</label><input id="wave-gain" type="range" min="1" max="32" value="8"><output id="wave-gain-value">×8</output><small>Nur die Darstellung wird verstärkt.</small></div><div class="scope-layout"><div class="scope-display"><div class="scope-meta"><span id="scope-status" role="status">Verbinde Audioanzeige…</span><span>50 ms · Stereo</span></div><canvas id="scope-canvas" height="180" aria-label="Live-Wellenform, linker und rechter Audiokanal"></canvas><div class="scope-scale"><span>L / R</span><span id="wave-scale-label">Ansicht ×8 · Pegel unverändert</span><span id="scope-clip">Kein Clipping</span></div></div><div class="scope-mixer"><h3>Mikrofon-Mix</h3><p>Gesamtlautstärke für Element, OBS & Co.</p><div class="meter-line"><label for="meter-left">L</label><meter id="meter-left" min="-60" max="0" low="-12" high="-3" optimum="-18" value="-60"></meter><output id="level-left">−∞ dBFS</output></div><div class="meter-line"><label for="meter-right">R</label><meter id="meter-right" min="-60" max="0" low="-12" high="-3" optimum="-18" value="-60"></meter><output id="level-right">−∞ dBFS</output></div><label class="field-label" for="mix-volume">MIX-LAUTSTÄRKE</label><div class="volume-row"><button class="icon-button" id="mix-mute" aria-label="Mikrofon-Mix stummschalten" aria-pressed="false" data-icon="volume"></button><input id="mix-volume" type="range" min="0" max="100" value="100"><output id="mix-value">100%</output></div><small>Die Pegel zeigen das oben gewählte Signal. Kanal-Lautstärken regelst du im Patchpanel.</small></div></div>
|
||||
</section>
|
||||
<div class="board-heading"><div><h2>Patchpanel</h2><span>Von der Quelle bis zum Ziel.</span></div><div class="live-label"><span class="local-dot"></span><span id="live-label">Wird verbunden</span></div></div>
|
||||
<section class="patch-grid" aria-label="Audio-Routing">
|
||||
<div class="patch-column"><div class="column-title"><span class="step">01</span><h3>Anwendungen</h3><span data-icon="arrow"></span></div><p class="column-description">Wähle einen Kanal für jeden Sound.</p><div id="microphone-routing"></div><div class="app-list" id="apps"></div><div class="source-hint"><span data-icon="info"></span><p>Eine Anwendung fehlt? Starte dort die Audiowiedergabe – sie erscheint automatisch. Bereits zugeordnete Apps findest du auch unter <a href="#saved-apps">Gespeicherte Apps</a>.</p></div></div>
|
||||
<div class="patch-column channels-column"><div class="column-title"><span class="step">02</span><h3>Virtuelle Kanäle</h3><span data-icon="arrow"></span></div><p class="column-description">Mischen, regeln und weiterleiten.</p><div class="channel-list" id="channels"></div><button class="add-card" id="add-channel-secondary"><span data-icon="plus"></span>Kanal hinzufügen</button></div>
|
||||
<div class="patch-column"><div class="column-title"><span class="step">03</span><h3>Ziele</h3><span class="terminal-dot"></span></div><p class="column-description">Zum Hören und als Mikrofon für Apps.</p><div id="outputs"></div><div id="extra-targets"></div><button class="add-card" id="add-target"><span data-icon="plus"></span>Virtuelles Mikrofon hinzufügen</button><div class="mic-explainer"><span class="mini-label">FÜR CALLS & STREAMS</span><h4>Dein Mix als Mikrofon.</h4><p>Wähle <strong>VoicePult Mikrofon</strong> als Eingabegerät in Discord, OBS oder deiner Konferenz-App.</p><button class="text-button" id="help-inline">Kurzanleitung <span data-icon="arrow"></span></button></div></div>
|
||||
</section><section class="network-panel" id="network-panel" hidden><div><span class="eyebrow">HANDY & TABLET</span><h3>Im selben Netzwerk verbinden</h3><p id="network-links"></p></div><div><label for="network-code">Zugangscode</label><input id="network-code" readonly type="password"><button class="button" id="reveal-code">Code anzeigen</button><p>Auf dem anderen Gerät die Adresse öffnen und den Code eingeben.</p></div></section><footer><span><span class="local-dot"></span> Änderungen werden automatisch gespeichert</span><span>Entwickelt für deinen Signalfluss.</span></footer></main>
|
||||
<main id="saved-apps" hidden><div class="page-heading"><div><div class="eyebrow">DEINE DAUERHAFTEN ZUORDNUNGEN</div><h1>Gespeicherte Apps<span>.</span></h1><p>Ändere deine Zielkanäle – auch wenn eine Anwendung pausiert oder geschlossen ist.</p></div><a class="button" href="#patchpanel">Zum Patchpanel</a></div>
|
||||
<div class="saved-info"><span data-icon="info"></span><p>Vorhandene Audiostreams werden sofort umgeleitet. Ohne Audiostream gilt die neue Auswahl bei der nächsten Wiedergabe. Wenn du alle Kanäle abwählst, wird die gespeicherte Zuordnung entfernt.</p></div>
|
||||
<section id="saved-app-list" class="saved-app-grid" aria-label="Gespeicherte App-Zuordnungen"></section>
|
||||
<footer><span><span class="local-dot"></span> Änderungen werden automatisch gespeichert</span></footer></main></div>
|
||||
<dialog id="target-dialog"><form id="target-form"><div class="dialog-header"><span class="eyebrow">EIN NEUES ZIEL FÜR DEINEN MIX</span><button class="icon-button" type="button" data-close="target-dialog" aria-label="Schließen" data-icon="close"></button></div><h2>Virtuelles Mikrofon erstellen</h2><p>Zum Beispiel „OBS“ oder „Gespräch“. Anschließend bei deinen Kanälen als Ziel aktivieren.</p><label for="target-name">Name</label><input id="target-name" maxlength="40" required autocomplete="off" placeholder="z. B. OBS"><div class="dialog-actions"><button type="button" class="button" data-close="target-dialog">Abbrechen</button><button type="submit" class="button primary" id="target-submit">Mikrofon erstellen</button></div></form></dialog>
|
||||
<dialog id="target-delete-dialog"><h2>Virtuelles Mikrofon löschen?</h2><p id="target-delete-message"></p><p>Anwendungen, die dieses Mikrofon verwenden, benötigen danach ein anderes Eingabegerät.</p><div class="dialog-actions"><button class="button" data-close="target-delete-dialog">Abbrechen</button><button class="button danger" id="target-delete-confirm">Mikrofon löschen</button></div></dialog>
|
||||
<dialog id="channel-dialog"><form id="channel-form"><div class="dialog-header"><span class="eyebrow" id="dialog-eyebrow">MEHR PLATZ FÜR DEINEN SOUND</span><button class="icon-button" type="button" data-close="channel-dialog" aria-label="Schließen" data-icon="close"></button></div><h2 id="dialog-title">Neuer virtueller Kanal</h2><p>Zum Beispiel Browser, Musik oder Spiel.</p><label for="channel-name">Kanalname</label><input id="channel-name" maxlength="40" required placeholder="z. B. Browser" autocomplete="off"><p class="form-note">Neue Kanäle spielen auf deinem gewählten Ausgang. Den Mikrofon-Mix schaltest du bei Bedarf dazu.</p><div class="dialog-actions"><button type="button" class="button" data-close="channel-dialog">Abbrechen</button><button type="submit" class="button primary" id="channel-submit">Kanal erstellen</button></div></form></dialog>
|
||||
<dialog id="help-dialog"><div class="dialog-header"><span class="eyebrow">IN DREI SCHRITTEN VERBUNDEN</span><button class="icon-button" data-close="help-dialog" aria-label="Schließen" data-icon="close"></button></div><h2>Firefox hören. Und teilen.</h2><ol class="help-steps"><li><strong>Erstelle einen Kanal</strong><p>Nenne ihn beispielsweise „Browser“ und wähle ihn bei Firefox aus. Firefox muss zuvor Audio abgespielt haben.</p></li><li><strong>Wähle deine Ziele</strong><p>Aktiviere am Kanal „Kopfhörer“ und „Mikrofon-Mix“. Wähle rechts den tatsächlichen Kopfhörer-Ausgang.</p></li><li><strong>Wähle das virtuelle Mikrofon</strong><p>Stelle in deiner Gesprächs-App „VoicePult Mikrofon“ als Eingabe ein. Mit „Stimme direkt in den Mix“ wird dein echtes Mikrofon beigemischt.</p></li></ol><div class="help-note">Leite die Gesprächs-App selbst nicht in den Mikrofon-Mix, damit Gesprächspartner sich nicht als Echo hören. Audiodaten der Live-Anzeige werden nur kurz im Arbeitsspeicher ausgewertet und nicht gespeichert.</div><button class="button primary full" data-close="help-dialog">Alles klar</button></dialog>
|
||||
<dialog id="delete-dialog"><h2>Kanal löschen?</h2><p id="delete-message"></p><div class="dialog-actions"><button class="button" data-close="delete-dialog">Abbrechen</button><button class="button danger" id="confirm-delete">Kanal löschen</button></div></dialog>
|
||||
<dialog id="access-dialog"><form id="access-form"><h2>Mit VoicePult verbinden</h2><p>Den Zugangscode findest du auf deinem Rechner unten in VoicePult unter „Handy & Tablet“.</p><label for="access-code">Zugangscode</label><input id="access-code" type="password" required autocomplete="current-password"><p id="access-error" role="alert"></p><button class="button primary" type="submit">Verbinden</button></form></dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body></html>
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user