diff --git a/ui/hauptfenster.py b/ui/hauptfenster.py index 46c42f1..751cc1d 100644 --- a/ui/hauptfenster.py +++ b/ui/hauptfenster.py @@ -21,6 +21,7 @@ from sip.engine import SipEngine from utils.config_manager import ConfigManager from utils.audio_manager import AudioManager from utils.klingelton import KlingeltonPlayer +from utils.ton_generator import TonGenerator from utils.benachrichtigung import AnrufBenachrichtigung from ui.waehlfeld import Waehlfeld from ui.anrufliste import AnruflisteWidget @@ -50,6 +51,7 @@ class HauptFenster(QMainWindow): self._sip = SipEngine() self._audio = AudioManager(self._sip) self._klingelton = KlingeltonPlayer(self) + self._ton_generator = TonGenerator(self) self._benachrichtigung = AnrufBenachrichtigung() self._ist_stumm = False self._ist_gehalten = False @@ -394,6 +396,7 @@ class HauptFenster(QMainWindow): def _auflegen(self): """Anruf beenden.""" self._klingelton.stoppen() + self._ton_generator.stoppen() self._benachrichtigung.schliessen() self._sip.anruf_beenden() @@ -516,7 +519,8 @@ class HauptFenster(QMainWindow): remote = self._uri_zu_nummer(daten.get("remoteUri", "")) if state == pj.PJSIP_INV_STATE_CONFIRMED: - # Gespräch verbunden + # Gespräch verbunden → Freizeichen stoppen + self._ton_generator.stoppen() self.anruf_info_label.setText(f"Verbunden: {remote}") self.annehmen_btn.hide() self._anruf_ui_aktivieren() @@ -525,16 +529,25 @@ class HauptFenster(QMainWindow): self.waehlfeld.set_im_gespraech(True) self.tray.setToolTip(f"SIP Softphone - Im Gespräch mit {remote}") elif state == pj.PJSIP_INV_STATE_DISCONNECTED: - # Anruf beendet/abgebrochen - UI immer zurücksetzen + # Anruf beendet/abgebrochen → Töne stoppen, UI zurücksetzen + self._ton_generator.stoppen() self._anruf_ui_deaktivieren() grund = daten.get("lastReason", "") + code = daten.get("lastStatusCode", 0) + # Besetztzeichen bei Besetzt-Antwort (486 Busy Here) + if code == 486: + self._ton_generator.besetztzeichen_starten() + QTimer.singleShot(3000, self._ton_generator.stoppen) self.anruf_info_label.setText(f"Beendet: {remote} ({grund})") self.anruf_info_label.show() QTimer.singleShot(3000, self.anruf_info_label.hide) self.tray.setToolTip("SIP Softphone - Online") elif state == pj.PJSIP_INV_STATE_CALLING: + # Ausgehender Anruf → Freizeichen starten + self._ton_generator.freizeichen_starten() self.anruf_info_label.setText(f"Rufe an: {remote}") elif state == pj.PJSIP_INV_STATE_EARLY: + # Klingelt beim Gegenüber → Freizeichen läuft weiter self.anruf_info_label.setText(f"Klingelt: {remote}") self.statusBar().showMessage(f"Anruf: {text}") @@ -579,6 +592,7 @@ class HauptFenster(QMainWindow): def _anruf_ui_deaktivieren(self): """UI nach Anrufende zurücksetzen.""" self._klingelton.stoppen() + self._ton_generator.stoppen() self._benachrichtigung.schliessen() self.anrufen_btn.show() self.annehmen_btn.hide() @@ -751,6 +765,7 @@ class HauptFenster(QMainWindow): def _wirklich_beenden(self): """Anwendung komplett beenden.""" + self._ton_generator.aufraumen() self.blf_panel.aufraumen() self._sip.beenden() self.tray.hide() diff --git a/utils/klingelton.py b/utils/klingelton.py index d4d3754..eca8184 100644 --- a/utils/klingelton.py +++ b/utils/klingelton.py @@ -57,15 +57,16 @@ class KlingeltonPlayer(QObject): self._loop_timer.start(5500) def stoppen(self): - """Klingelton stoppen.""" + """Klingelton sofort stoppen.""" self._laeuft = False self._loop_timer.stop() - if self._prozess and self._prozess.poll() is None: + if self._prozess: try: - self._prozess.terminate() - except OSError: + self._prozess.kill() # SIGKILL für sofortiges Stoppen + self._prozess.wait(timeout=1) + except (OSError, subprocess.TimeoutExpired): pass - self._prozess = None + self._prozess = None def test_abspielen(self, datei_pfad="", device_pw_name=""): """Einmal abspielen zum Testen (kein Loop).""" diff --git a/utils/ton_generator.py b/utils/ton_generator.py new file mode 100644 index 0000000..e0e3770 --- /dev/null +++ b/utils/ton_generator.py @@ -0,0 +1,146 @@ +"""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