Freizeichen/Besetztzeichen + Klingelton-Fix

- TonGenerator: Lokales Freizeichen (425 Hz, 1s/4s) und Besetztzeichen
  (425 Hz, 480ms/480ms) über generierte WAV-Dateien + paplay.
  Kein PJSUA2-Conference-Bridge-Eingriff mehr.
- KlingeltonPlayer: kill() statt terminate() für sofortiges Stoppen
- Hauptfenster: Freizeichen bei ausgehenden Anrufen, Besetztzeichen
  bei 486 Busy, automatisches Stoppen bei Verbindung/Auflegen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-18 13:40:57 +01:00
parent 48ddb4b4af
commit 71e5b92b50
3 changed files with 169 additions and 7 deletions

View file

@ -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()

View file

@ -57,13 +57,14 @@ 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

146
utils/ton_generator.py Normal file
View file

@ -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