"""Ton-Generator - Lokale Freizeichen/Besetztzeichen über WAV + paplay. Erzeugt WAV-Dateien mit Sinustönen und spielt sie über paplay ab. Nutzt NICHT die PJSUA2 Conference Bridge, um Konflikte mit dem aktiven Anruf zu vermeiden. """ import math import struct import subprocess import tempfile import wave from pathlib import Path from PySide6.QtCore import QObject, QTimer class TonGenerator(QObject): """Erzeugt lokale Signaltöne über generierte WAV-Dateien + paplay. Deutsche Standard-Töne: - Freizeichen: 425 Hz, 1s an / 4s aus - Besetztzeichen: 425 Hz, 480ms an / 480ms aus """ def __init__(self, parent=None): super().__init__(parent) self._prozess = None self._loop_timer = QTimer(self) self._loop_timer.timeout.connect(self._erneut_abspielen) self._aktuelle_datei = "" self._laeuft = False # Temp-Verzeichnis für generierte WAV-Dateien self._temp_dir = Path(tempfile.mkdtemp(prefix="sipwebapp_toene_")) # WAV-Dateien vorgenerieren self._freizeichen_wav = str(self._temp_dir / "freizeichen.wav") self._besetztzeichen_wav = str(self._temp_dir / "besetztzeichen.wav") self._wav_generieren( self._freizeichen_wav, freq=425, on_ms=1000, off_ms=4000, zyklen=1, ) self._wav_generieren( self._besetztzeichen_wav, freq=425, on_ms=480, off_ms=480, zyklen=3, ) @staticmethod def _wav_generieren(pfad, freq, on_ms, off_ms, zyklen): """WAV-Datei mit Sinuston-Muster generieren. Args: pfad: Ziel-Dateipfad freq: Frequenz in Hz (425 Hz für Deutschland) on_ms: Ton-Dauer in Millisekunden off_ms: Stille-Dauer in Millisekunden zyklen: Anzahl der Wiederholungen im WAV """ sample_rate = 8000 amplitude = 16000 # Moderate Lautstärke (max 32767) samples = [] for _ in range(zyklen): # Ton an on_samples = int(sample_rate * on_ms / 1000) for i in range(on_samples): wert = int(amplitude * math.sin( 2 * math.pi * freq * i / sample_rate)) samples.append(wert) # Stille off_samples = int(sample_rate * off_ms / 1000) samples.extend([0] * off_samples) with wave.open(pfad, "w") as wf: wf.setnchannels(1) wf.setsampwidth(2) # 16-bit wf.setframerate(sample_rate) wf.writeframes(struct.pack(f"<{len(samples)}h", *samples)) def freizeichen_starten(self): """Deutsches Freizeichen abspielen (425 Hz, 1s an / 4s aus).""" # 1 Zyklus = 5000ms, Loop-Timer etwas länger self._abspielen(self._freizeichen_wav, loop_ms=5200) def besetztzeichen_starten(self): """Deutsches Besetztzeichen abspielen (425 Hz, 480ms an / 480ms aus).""" # 3 Zyklen = 2880ms, Loop-Timer etwas länger self._abspielen(self._besetztzeichen_wav, loop_ms=3100) def _abspielen(self, datei, loop_ms): """WAV-Datei mit paplay abspielen und loopen.""" self.stoppen() self._aktuelle_datei = datei self._laeuft = True self._paplay_starten() self._loop_timer.start(loop_ms) def _paplay_starten(self): """paplay Subprocess starten.""" if not self._laeuft: return # Alten Prozess aufräumen if self._prozess and self._prozess.poll() is not None: self._prozess = None try: self._prozess = subprocess.Popen( ["paplay", self._aktuelle_datei], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except (FileNotFoundError, OSError): self._laeuft = False def _erneut_abspielen(self): """Timer-Callback: Ton in Schleife abspielen.""" if not self._laeuft: self._loop_timer.stop() return self._paplay_starten() def stoppen(self): """Ton sofort stoppen.""" self._laeuft = False self._loop_timer.stop() if self._prozess: try: self._prozess.kill() # SIGKILL für sofortiges Stoppen self._prozess.wait(timeout=1) except (OSError, subprocess.TimeoutExpired): pass self._prozess = None def aufraumen(self): """Temp-Dateien löschen und aufräumen.""" self.stoppen() import shutil try: shutil.rmtree(self._temp_dir, ignore_errors=True) except Exception: pass @property def laeuft(self): """True wenn gerade ein Ton abgespielt wird.""" return self._laeuft