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:
parent
48ddb4b4af
commit
71e5b92b50
3 changed files with 169 additions and 7 deletions
|
|
@ -21,6 +21,7 @@ from sip.engine import SipEngine
|
||||||
from utils.config_manager import ConfigManager
|
from utils.config_manager import ConfigManager
|
||||||
from utils.audio_manager import AudioManager
|
from utils.audio_manager import AudioManager
|
||||||
from utils.klingelton import KlingeltonPlayer
|
from utils.klingelton import KlingeltonPlayer
|
||||||
|
from utils.ton_generator import TonGenerator
|
||||||
from utils.benachrichtigung import AnrufBenachrichtigung
|
from utils.benachrichtigung import AnrufBenachrichtigung
|
||||||
from ui.waehlfeld import Waehlfeld
|
from ui.waehlfeld import Waehlfeld
|
||||||
from ui.anrufliste import AnruflisteWidget
|
from ui.anrufliste import AnruflisteWidget
|
||||||
|
|
@ -50,6 +51,7 @@ class HauptFenster(QMainWindow):
|
||||||
self._sip = SipEngine()
|
self._sip = SipEngine()
|
||||||
self._audio = AudioManager(self._sip)
|
self._audio = AudioManager(self._sip)
|
||||||
self._klingelton = KlingeltonPlayer(self)
|
self._klingelton = KlingeltonPlayer(self)
|
||||||
|
self._ton_generator = TonGenerator(self)
|
||||||
self._benachrichtigung = AnrufBenachrichtigung()
|
self._benachrichtigung = AnrufBenachrichtigung()
|
||||||
self._ist_stumm = False
|
self._ist_stumm = False
|
||||||
self._ist_gehalten = False
|
self._ist_gehalten = False
|
||||||
|
|
@ -394,6 +396,7 @@ class HauptFenster(QMainWindow):
|
||||||
def _auflegen(self):
|
def _auflegen(self):
|
||||||
"""Anruf beenden."""
|
"""Anruf beenden."""
|
||||||
self._klingelton.stoppen()
|
self._klingelton.stoppen()
|
||||||
|
self._ton_generator.stoppen()
|
||||||
self._benachrichtigung.schliessen()
|
self._benachrichtigung.schliessen()
|
||||||
self._sip.anruf_beenden()
|
self._sip.anruf_beenden()
|
||||||
|
|
||||||
|
|
@ -516,7 +519,8 @@ class HauptFenster(QMainWindow):
|
||||||
remote = self._uri_zu_nummer(daten.get("remoteUri", ""))
|
remote = self._uri_zu_nummer(daten.get("remoteUri", ""))
|
||||||
|
|
||||||
if state == pj.PJSIP_INV_STATE_CONFIRMED:
|
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.anruf_info_label.setText(f"Verbunden: {remote}")
|
||||||
self.annehmen_btn.hide()
|
self.annehmen_btn.hide()
|
||||||
self._anruf_ui_aktivieren()
|
self._anruf_ui_aktivieren()
|
||||||
|
|
@ -525,16 +529,25 @@ class HauptFenster(QMainWindow):
|
||||||
self.waehlfeld.set_im_gespraech(True)
|
self.waehlfeld.set_im_gespraech(True)
|
||||||
self.tray.setToolTip(f"SIP Softphone - Im Gespräch mit {remote}")
|
self.tray.setToolTip(f"SIP Softphone - Im Gespräch mit {remote}")
|
||||||
elif state == pj.PJSIP_INV_STATE_DISCONNECTED:
|
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()
|
self._anruf_ui_deaktivieren()
|
||||||
grund = daten.get("lastReason", "")
|
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.setText(f"Beendet: {remote} ({grund})")
|
||||||
self.anruf_info_label.show()
|
self.anruf_info_label.show()
|
||||||
QTimer.singleShot(3000, self.anruf_info_label.hide)
|
QTimer.singleShot(3000, self.anruf_info_label.hide)
|
||||||
self.tray.setToolTip("SIP Softphone - Online")
|
self.tray.setToolTip("SIP Softphone - Online")
|
||||||
elif state == pj.PJSIP_INV_STATE_CALLING:
|
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}")
|
self.anruf_info_label.setText(f"Rufe an: {remote}")
|
||||||
elif state == pj.PJSIP_INV_STATE_EARLY:
|
elif state == pj.PJSIP_INV_STATE_EARLY:
|
||||||
|
# Klingelt beim Gegenüber → Freizeichen läuft weiter
|
||||||
self.anruf_info_label.setText(f"Klingelt: {remote}")
|
self.anruf_info_label.setText(f"Klingelt: {remote}")
|
||||||
|
|
||||||
self.statusBar().showMessage(f"Anruf: {text}")
|
self.statusBar().showMessage(f"Anruf: {text}")
|
||||||
|
|
@ -579,6 +592,7 @@ class HauptFenster(QMainWindow):
|
||||||
def _anruf_ui_deaktivieren(self):
|
def _anruf_ui_deaktivieren(self):
|
||||||
"""UI nach Anrufende zurücksetzen."""
|
"""UI nach Anrufende zurücksetzen."""
|
||||||
self._klingelton.stoppen()
|
self._klingelton.stoppen()
|
||||||
|
self._ton_generator.stoppen()
|
||||||
self._benachrichtigung.schliessen()
|
self._benachrichtigung.schliessen()
|
||||||
self.anrufen_btn.show()
|
self.anrufen_btn.show()
|
||||||
self.annehmen_btn.hide()
|
self.annehmen_btn.hide()
|
||||||
|
|
@ -751,6 +765,7 @@ class HauptFenster(QMainWindow):
|
||||||
|
|
||||||
def _wirklich_beenden(self):
|
def _wirklich_beenden(self):
|
||||||
"""Anwendung komplett beenden."""
|
"""Anwendung komplett beenden."""
|
||||||
|
self._ton_generator.aufraumen()
|
||||||
self.blf_panel.aufraumen()
|
self.blf_panel.aufraumen()
|
||||||
self._sip.beenden()
|
self._sip.beenden()
|
||||||
self.tray.hide()
|
self.tray.hide()
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,14 @@ class KlingeltonPlayer(QObject):
|
||||||
self._loop_timer.start(5500)
|
self._loop_timer.start(5500)
|
||||||
|
|
||||||
def stoppen(self):
|
def stoppen(self):
|
||||||
"""Klingelton stoppen."""
|
"""Klingelton sofort stoppen."""
|
||||||
self._laeuft = False
|
self._laeuft = False
|
||||||
self._loop_timer.stop()
|
self._loop_timer.stop()
|
||||||
if self._prozess and self._prozess.poll() is None:
|
if self._prozess:
|
||||||
try:
|
try:
|
||||||
self._prozess.terminate()
|
self._prozess.kill() # SIGKILL für sofortiges Stoppen
|
||||||
except OSError:
|
self._prozess.wait(timeout=1)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
self._prozess = None
|
self._prozess = None
|
||||||
|
|
||||||
|
|
|
||||||
146
utils/ton_generator.py
Normal file
146
utils/ton_generator.py
Normal 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
|
||||||
Loading…
Reference in a new issue