Я решил уйти от монолитной сруктуры в угоду модульности.
This commit is contained in:
parent
a5a7155c52
commit
ec504574f2
|
|
@ -40,7 +40,6 @@ import threading
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
import socketio
|
|
||||||
|
|
||||||
from prompt_toolkit.application import Application
|
from prompt_toolkit.application import Application
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
|
|
@ -54,19 +53,14 @@ from prompt_toolkit.filters import has_focus, Condition
|
||||||
from prompt_toolkit.data_structures import Point
|
from prompt_toolkit.data_structures import Point
|
||||||
from prompt_toolkit.completion import Completer, Completion
|
from prompt_toolkit.completion import Completer, Completion
|
||||||
|
|
||||||
BASE_FREQ = 600
|
# Import decoupled local modules
|
||||||
FREQ_STEP = 25
|
import protocol
|
||||||
TONE_DURATION = 0.1
|
import socket_manager
|
||||||
SAMPLE_RATE = 44100
|
|
||||||
CONFIG_FILE = "settings.json"
|
CONFIG_FILE = "settings.json"
|
||||||
WIKI_URL = "https://git.idkmail.ru/lohrrrr/TyChat-Client/wiki"
|
WIKI_URL = "https://git.idkmail.ru/lohrrrr/TyChat-Client/wiki"
|
||||||
DEV_SIGNATURE = "For you, from IDKMail Dev Group"
|
DEV_SIGNATURE = "For you, from IDKMail Dev Group"
|
||||||
|
|
||||||
FREQ_QUERY = 1200
|
|
||||||
FREQ_ALERT = 1400
|
|
||||||
FREQ_RESP_YES = 1600
|
|
||||||
FREQ_RESP_NO = 1800
|
|
||||||
|
|
||||||
ASCII_ART = r"""
|
ASCII_ART = r"""
|
||||||
_______ _____ _ _
|
_______ _____ _ _
|
||||||
|__ __| / ____| | | |
|
|__ __| / ____| | | |
|
||||||
|
|
@ -78,8 +72,6 @@ ASCII_ART = r"""
|
||||||
|___/
|
|___/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
sio = socketio.Client()
|
|
||||||
|
|
||||||
class CommandCompleter(Completer):
|
class CommandCompleter(Completer):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.completions = {
|
self.completions = {
|
||||||
|
|
@ -159,77 +151,21 @@ class TyClient:
|
||||||
def play_dial_tones(self, target_uin):
|
def play_dial_tones(self, target_uin):
|
||||||
self.dialing_uin = target_uin
|
self.dialing_uin = target_uin
|
||||||
while self.dialing_uin == target_uin and self.loop_running:
|
while self.dialing_uin == target_uin and self.loop_running:
|
||||||
t = np.linspace(0, 1.0, int(SAMPLE_RATE * 1.0), False)
|
t = np.linspace(0, 1.0, int(protocol.SAMPLE_RATE * 1.0), False)
|
||||||
tone = np.sin(425 * t * 2 * np.pi) * 0.5
|
tone = np.sin(425 * t * 2 * np.pi) * 0.5
|
||||||
sd.play(tone, SAMPLE_RATE)
|
sd.play(tone, protocol.SAMPLE_RATE)
|
||||||
sd.wait()
|
sd.wait()
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
if self.dialing_uin != target_uin or not self.loop_running:
|
if self.dialing_uin != target_uin or not self.loop_running:
|
||||||
break
|
break
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
def generate_service_tone(self, frequency, duration=0.4):
|
|
||||||
t = np.linspace(0, duration, int(SAMPLE_RATE * duration), False)
|
|
||||||
tone = np.sin(frequency * t * 2 * np.pi) * 0.7
|
|
||||||
envelope = np.ones_like(tone)
|
|
||||||
fade_len = int(SAMPLE_RATE * 0.05)
|
|
||||||
envelope[:fade_len] = np.linspace(0, 1, fade_len)
|
|
||||||
envelope[-fade_len:] = np.linspace(1, 0, fade_len)
|
|
||||||
return (tone * envelope).astype(np.float32).tobytes()
|
|
||||||
|
|
||||||
def text_to_audio(self, text):
|
|
||||||
audio_signals = []
|
|
||||||
t_start = np.linspace(0, 0.3, int(SAMPLE_RATE * 0.3), False)
|
|
||||||
audio_signals.append(np.sin(1000 * t_start * 2 * np.pi))
|
|
||||||
for char in text:
|
|
||||||
freq = BASE_FREQ + ord(char) * FREQ_STEP
|
|
||||||
t = np.linspace(0, TONE_DURATION, int(SAMPLE_RATE * TONE_DURATION), False)
|
|
||||||
audio_signals.append(np.sin(freq * t * 2 * np.pi))
|
|
||||||
full_audio = np.concatenate(audio_signals).astype(np.float32)
|
|
||||||
if np.max(np.abs(full_audio)) > 0:
|
|
||||||
full_audio = full_audio / np.max(np.abs(full_audio))
|
|
||||||
return full_audio.tobytes()
|
|
||||||
|
|
||||||
def detect_service_tone(self, audio_data):
|
|
||||||
try:
|
|
||||||
window_data = np.hanning(len(audio_data))
|
|
||||||
fft_data = np.abs(np.fft.rfft(audio_data * window_data))
|
|
||||||
frequencies = np.fft.rfftfreq(len(audio_data), d=1/SAMPLE_RATE)
|
|
||||||
detected_freq = frequencies[np.argmax(fft_data)]
|
|
||||||
for target in [FREQ_QUERY, FREQ_ALERT, FREQ_RESP_YES, FREQ_RESP_NO]:
|
|
||||||
if abs(detected_freq - target) <= 15:
|
|
||||||
return target
|
|
||||||
return None
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def fast_decode(self, audio_data):
|
|
||||||
try:
|
|
||||||
samples_per_tone = int(SAMPLE_RATE * TONE_DURATION)
|
|
||||||
current_sample = int(SAMPLE_RATE * 0.3)
|
|
||||||
text = ""
|
|
||||||
while current_sample < len(audio_data):
|
|
||||||
chunk = audio_data[current_sample : current_sample + samples_per_tone]
|
|
||||||
if len(chunk) < samples_per_tone:
|
|
||||||
break
|
|
||||||
window_data = np.hanning(len(chunk))
|
|
||||||
fft_data = np.abs(np.fft.rfft(chunk * window_data))
|
|
||||||
frequencies = np.fft.rfftfreq(len(chunk), d=1/SAMPLE_RATE)
|
|
||||||
detected_freq = frequencies[np.argmax(fft_data)]
|
|
||||||
ascii_code = int(round((detected_freq - BASE_FREQ) / FREQ_STEP))
|
|
||||||
if 0 <= ascii_code < 65535:
|
|
||||||
text += chr(ascii_code)
|
|
||||||
current_sample += samples_per_tone
|
|
||||||
return text
|
|
||||||
except:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def play_and_decode(self, audio_bytes, sender_uin):
|
def play_and_decode(self, audio_bytes, sender_uin):
|
||||||
audio_data = np.frombuffer(audio_bytes, dtype=np.float32)
|
audio_data = np.frombuffer(audio_bytes, dtype=np.float32)
|
||||||
sd.play(audio_data, SAMPLE_RATE)
|
sd.play(audio_data, protocol.SAMPLE_RATE)
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
samples_per_tone = int(SAMPLE_RATE * TONE_DURATION)
|
samples_per_tone = int(protocol.SAMPLE_RATE * protocol.TONE_DURATION)
|
||||||
current_sample = int(SAMPLE_RATE * 0.3)
|
current_sample = int(protocol.SAMPLE_RATE * 0.3)
|
||||||
|
|
||||||
sender_name = self.username if sender_uin == self.uin else f"UIN {sender_uin}"
|
sender_name = self.username if sender_uin == self.uin else f"UIN {sender_uin}"
|
||||||
msg_buffer = f"[{sender_name}]: "
|
msg_buffer = f"[{sender_name}]: "
|
||||||
|
|
@ -245,9 +181,9 @@ class TyClient:
|
||||||
break
|
break
|
||||||
window_data = np.hanning(len(chunk))
|
window_data = np.hanning(len(chunk))
|
||||||
fft_data = np.abs(np.fft.rfft(chunk * window_data))
|
fft_data = np.abs(np.fft.rfft(chunk * window_data))
|
||||||
frequencies = np.fft.rfftfreq(len(chunk), d=1/SAMPLE_RATE)
|
frequencies = np.fft.rfftfreq(len(chunk), d=1/protocol.SAMPLE_RATE)
|
||||||
detected_freq = frequencies[np.argmax(fft_data)]
|
detected_freq = frequencies[np.argmax(fft_data)]
|
||||||
ascii_code = int(round((detected_freq - BASE_FREQ) / FREQ_STEP))
|
ascii_code = int(round((detected_freq - protocol.BASE_FREQ) / protocol.FREQ_STEP))
|
||||||
if 0 <= ascii_code < 65535:
|
if 0 <= ascii_code < 65535:
|
||||||
try:
|
try:
|
||||||
char = chr(ascii_code)
|
char = chr(ascii_code)
|
||||||
|
|
@ -255,7 +191,7 @@ class TyClient:
|
||||||
self.refresh_ui()
|
self.refresh_ui()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
time.sleep(TONE_DURATION)
|
time.sleep(protocol.TONE_DURATION)
|
||||||
current_sample += samples_per_tone
|
current_sample += samples_per_tone
|
||||||
sd.wait()
|
sd.wait()
|
||||||
self.save_config()
|
self.save_config()
|
||||||
|
|
@ -268,12 +204,12 @@ class TyClient:
|
||||||
self.save_config()
|
self.save_config()
|
||||||
|
|
||||||
def send_sys_packet(self, to_uin, cmd):
|
def send_sys_packet(self, to_uin, cmd):
|
||||||
audio_b = self.text_to_audio(f"SYS:{cmd}")
|
audio_b = protocol.text_to_audio(f"SYS:{cmd}")
|
||||||
sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')})
|
socket_manager.sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')})
|
||||||
|
|
||||||
def send_service_tone(self, to_uin, freq):
|
def send_service_tone(self, to_uin, freq):
|
||||||
audio_b = self.generate_service_tone(freq)
|
audio_b = protocol.generate_service_tone(freq)
|
||||||
sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')})
|
socket_manager.sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')})
|
||||||
|
|
||||||
def refresh_ui(self):
|
def refresh_ui(self):
|
||||||
if self.app:
|
if self.app:
|
||||||
|
|
@ -281,112 +217,17 @@ class TyClient:
|
||||||
|
|
||||||
client = TyClient()
|
client = TyClient()
|
||||||
|
|
||||||
@sio.event
|
|
||||||
def incoming_packet(data):
|
|
||||||
from_uin = data["from_uin"]
|
|
||||||
payload_base64 = data["payload"]
|
|
||||||
try:
|
|
||||||
audio_bytes = base64.b64decode(payload_base64.encode('utf-8'))
|
|
||||||
audio_data = np.frombuffer(audio_bytes, dtype=np.float32)
|
|
||||||
|
|
||||||
srv_tone = client.detect_service_tone(audio_data)
|
|
||||||
if srv_tone:
|
|
||||||
sd.play(audio_data, SAMPLE_RATE)
|
|
||||||
if from_uin not in client.contacts:
|
|
||||||
return
|
|
||||||
if srv_tone == FREQ_QUERY:
|
|
||||||
if client.is_busy:
|
|
||||||
client.send_service_tone(from_uin, FREQ_RESP_YES)
|
|
||||||
else:
|
|
||||||
client.pending_windows.append({"type": "query", "uin": from_uin})
|
|
||||||
client.refresh_ui()
|
|
||||||
return
|
|
||||||
elif srv_tone == FREQ_ALERT:
|
|
||||||
client.pending_windows.append({"type": "alert", "uin": from_uin})
|
|
||||||
client.refresh_ui()
|
|
||||||
return
|
|
||||||
elif srv_tone == FREQ_RESP_YES:
|
|
||||||
client.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> Yes, I'm busy")
|
|
||||||
return
|
|
||||||
elif srv_tone == FREQ_RESP_NO:
|
|
||||||
client.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> No, go on")
|
|
||||||
return
|
|
||||||
|
|
||||||
fast_text = client.fast_decode(audio_data)
|
|
||||||
if fast_text.startswith("SYS:"):
|
|
||||||
cmd = fast_text[4:]
|
|
||||||
if cmd.startswith("ROOM_MSG:"):
|
|
||||||
parts = cmd.split(":", 3)
|
|
||||||
if len(parts) >= 4:
|
|
||||||
room_id = parts[1]
|
|
||||||
sender_uin = parts[2]
|
|
||||||
actual_msg = parts[3]
|
|
||||||
if room_id in client.groups:
|
|
||||||
client.add_to_history(room_id, f"[UIN {sender_uin}]: {actual_msg}")
|
|
||||||
return
|
|
||||||
elif cmd == "REQ_ADD":
|
|
||||||
if from_uin in client.blocklist:
|
|
||||||
client.send_sys_packet(from_uin, "RES_DEC")
|
|
||||||
elif client.is_busy:
|
|
||||||
client.send_sys_packet(from_uin, "RES_BSY")
|
|
||||||
else:
|
|
||||||
if from_uin not in client.pending_requests and from_uin not in client.contacts:
|
|
||||||
client.pending_requests.append(from_uin)
|
|
||||||
client.refresh_ui()
|
|
||||||
return
|
|
||||||
elif cmd == "RES_ACC":
|
|
||||||
if client.dialing_uin == from_uin:
|
|
||||||
client.dialing_uin = None
|
|
||||||
if from_uin not in client.contacts:
|
|
||||||
client.contacts[from_uin] = {"status": "online", "unread": 0, "attention": False}
|
|
||||||
client.history[from_uin] = ["[SYSTEM]: Handshake accepted. Contact added."]
|
|
||||||
client.save_config()
|
|
||||||
client.active_chat = from_uin
|
|
||||||
client.refresh_ui()
|
|
||||||
return
|
|
||||||
elif cmd == "RES_DEC" or cmd == "RES_BSY":
|
|
||||||
if client.dialing_uin == from_uin:
|
|
||||||
client.dialing_uin = None
|
|
||||||
return
|
|
||||||
|
|
||||||
if from_uin not in client.contacts:
|
|
||||||
return
|
|
||||||
if client.active_chat != from_uin:
|
|
||||||
client.contacts[from_uin]["unread"] += 1
|
|
||||||
|
|
||||||
threading.Thread(target=client.play_and_decode, args=(audio_bytes, from_uin), daemon=True).start()
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@sio.event
|
|
||||||
def online_statuses_response(data):
|
|
||||||
for uin, status in data.items():
|
|
||||||
if uin in client.contacts:
|
|
||||||
client.contacts[uin]["status"] = status
|
|
||||||
client.refresh_ui()
|
|
||||||
|
|
||||||
@sio.event
|
|
||||||
def error(data):
|
|
||||||
msg = data.get("message")
|
|
||||||
target = data.get("target_uin")
|
|
||||||
if msg == "offline" and target:
|
|
||||||
if client.dialing_uin == target:
|
|
||||||
client.dialing_uin = None
|
|
||||||
if target in client.contacts:
|
|
||||||
client.contacts[target]["status"] = "offline"
|
|
||||||
client.add_to_history(target, f"[SYSTEM]: They went offline. Outgoing stack will be preserved.")
|
|
||||||
|
|
||||||
def status_checker_thread():
|
def status_checker_thread():
|
||||||
while client.loop_running:
|
while client.loop_running:
|
||||||
if sio.connected and client.contacts:
|
if socket_manager.sio.connected and client.contacts:
|
||||||
sio.emit("check_online_statuses", list(client.contacts.keys()))
|
socket_manager.sio.emit("check_online_statuses", list(client.contacts.keys()))
|
||||||
for uin in list(client.contacts.keys()):
|
for uin in list(client.contacts.keys()):
|
||||||
if client.contacts[uin]["status"] == "online" and client.preserved.get(uin):
|
if client.contacts[uin]["status"] == "online" and client.preserved.get(uin):
|
||||||
while client.preserved[uin]:
|
while client.preserved[uin]:
|
||||||
p_text = client.preserved[uin].pop(0)
|
p_text = client.preserved[uin].pop(0)
|
||||||
audio_b = client.text_to_audio(p_text)
|
audio_b = protocol.text_to_audio(p_text)
|
||||||
p_b64 = base64.b64encode(audio_b).decode('utf-8')
|
p_b64 = base64.b64encode(audio_b).decode('utf-8')
|
||||||
sio.emit("relay_packet", {"to_uin": uin, "payload": p_b64})
|
socket_manager.sio.emit("relay_packet", {"to_uin": uin, "payload": p_b64})
|
||||||
client.add_to_history(uin, f"[You]: {p_text}")
|
client.add_to_history(uin, f"[You]: {p_text}")
|
||||||
client.save_config()
|
client.save_config()
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
|
|
@ -477,7 +318,7 @@ def make_layout():
|
||||||
|
|
||||||
if text.lower() == "/exit":
|
if text.lower() == "/exit":
|
||||||
client.loop_running = False
|
client.loop_running = False
|
||||||
sio.disconnect()
|
socket_manager.sio.disconnect()
|
||||||
get_app().exit()
|
get_app().exit()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -549,25 +390,25 @@ def make_layout():
|
||||||
client.add_to_history(client.active_chat, "[SYSTEM]: CANNOT SEND. Owner of this room is offline for sync!")
|
client.add_to_history(client.active_chat, "[SYSTEM]: CANNOT SEND. Owner of this room is offline for sync!")
|
||||||
input_field.text = ""
|
input_field.text = ""
|
||||||
return
|
return
|
||||||
audio_b = client.text_to_audio(f"SYS:ROOM_MSG:{client.active_chat}:{client.uin}:{text}")
|
audio_b = protocol.text_to_audio(f"SYS:ROOM_MSG:{client.active_chat}:{client.uin}:{text}")
|
||||||
p_b64 = base64.b64encode(audio_b).decode('utf-8')
|
p_b64 = base64.b64encode(audio_b).decode('utf-8')
|
||||||
if owner_uin == client.uin:
|
if owner_uin == client.uin:
|
||||||
for m in group["members"]:
|
for m in group["members"]:
|
||||||
if m != client.uin and client.contacts.get(m, {}).get("status") == "online":
|
if m != client.uin and client.contacts.get(m, {}).get("status") == "online":
|
||||||
sio.emit("relay_packet", {"to_uin": m, "payload": p_b64})
|
socket_manager.sio.emit("relay_packet", {"to_uin": m, "payload": p_b64})
|
||||||
else:
|
else:
|
||||||
sio.emit("relay_packet", {"to_uin": owner_uin, "payload": p_b64})
|
socket_manager.sio.emit("relay_packet", {"to_uin": owner_uin, "payload": p_b64})
|
||||||
client.add_to_history(client.active_chat, f"[You]: {text}")
|
client.add_to_history(client.active_chat, f"[You]: {text}")
|
||||||
input_field.text = ""
|
input_field.text = ""
|
||||||
return
|
return
|
||||||
|
|
||||||
if text.lower() == "/query":
|
if text.lower() == "/query":
|
||||||
client.send_service_tone(client.active_chat, FREQ_QUERY)
|
client.send_service_tone(client.active_chat, protocol.FREQ_QUERY)
|
||||||
client.add_to_history(client.active_chat, "[SYSTEM]: Sent friendly status query...")
|
client.add_to_history(client.active_chat, "[SYSTEM]: Sent friendly status query...")
|
||||||
input_field.text = ""
|
input_field.text = ""
|
||||||
return
|
return
|
||||||
if text.lower() == "/alert":
|
if text.lower() == "/alert":
|
||||||
client.send_service_tone(client.active_chat, FREQ_ALERT)
|
client.send_service_tone(client.active_chat, protocol.FREQ_ALERT)
|
||||||
client.add_to_history(client.active_chat, "[SYSTEM]: Sent URGENT alert signal (breaks DND)...")
|
client.add_to_history(client.active_chat, "[SYSTEM]: Sent URGENT alert signal (breaks DND)...")
|
||||||
input_field.text = ""
|
input_field.text = ""
|
||||||
return
|
return
|
||||||
|
|
@ -576,10 +417,10 @@ def make_layout():
|
||||||
input_field.text = ""
|
input_field.text = ""
|
||||||
return
|
return
|
||||||
|
|
||||||
audio_b = client.text_to_audio(text)
|
audio_b = protocol.text_to_audio(text)
|
||||||
p_b64 = base64.b64encode(audio_b).decode('utf-8')
|
p_b64 = base64.b64encode(audio_b).decode('utf-8')
|
||||||
if client.contacts[client.active_chat]["status"] == "online":
|
if client.contacts[client.active_chat]["status"] == "online":
|
||||||
sio.emit("relay_packet", {"to_uin": client.active_chat, "payload": p_b64})
|
socket_manager.sio.emit("relay_packet", {"to_uin": client.active_chat, "payload": p_b64})
|
||||||
client.add_to_history(client.active_chat, f"[You]: {text}")
|
client.add_to_history(client.active_chat, f"[You]: {text}")
|
||||||
else:
|
else:
|
||||||
if client.active_chat not in client.preserved: client.preserved[client.active_chat] = []
|
if client.active_chat not in client.preserved: client.preserved[client.active_chat] = []
|
||||||
|
|
@ -701,7 +542,7 @@ def make_layout():
|
||||||
win_info = client.pending_windows.pop(0)
|
win_info = client.pending_windows.pop(0)
|
||||||
target_uin = win_info["uin"]
|
target_uin = win_info["uin"]
|
||||||
client.is_busy = True
|
client.is_busy = True
|
||||||
client.send_service_tone(target_uin, FREQ_RESP_YES)
|
client.send_service_tone(target_uin, protocol.FREQ_RESP_YES)
|
||||||
client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> Yes, I'm busy.")
|
client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> Yes, I'm busy.")
|
||||||
client.refresh_ui()
|
client.refresh_ui()
|
||||||
|
|
||||||
|
|
@ -710,7 +551,7 @@ def make_layout():
|
||||||
def _(event):
|
def _(event):
|
||||||
win_info = client.pending_windows.pop(0)
|
win_info = client.pending_windows.pop(0)
|
||||||
target_uin = win_info["uin"]
|
target_uin = win_info["uin"]
|
||||||
client.send_service_tone(target_uin, FREQ_RESP_NO)
|
client.send_service_tone(target_uin, protocol.FREQ_RESP_NO)
|
||||||
client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> No, go on.")
|
client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> No, go on.")
|
||||||
client.refresh_ui()
|
client.refresh_ui()
|
||||||
|
|
||||||
|
|
@ -735,7 +576,7 @@ def make_layout():
|
||||||
@kb.add('c-c')
|
@kb.add('c-c')
|
||||||
def _(event):
|
def _(event):
|
||||||
client.loop_running = False
|
client.loop_running = False
|
||||||
sio.disconnect()
|
socket_manager.sio.disconnect()
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
return Layout(root_container, focused_element=input_field), kb
|
return Layout(root_container, focused_element=input_field), kb
|
||||||
|
|
@ -770,6 +611,9 @@ def main():
|
||||||
|
|
||||||
is_new_registration = False
|
is_new_registration = False
|
||||||
|
|
||||||
|
# Initialize the cross-module link so network callbacks can manipulate local state
|
||||||
|
socket_manager.init_network(client)
|
||||||
|
|
||||||
if not client.load_config():
|
if not client.load_config():
|
||||||
print("!" * 60)
|
print("!" * 60)
|
||||||
print("WARNING: All data is local.")
|
print("WARNING: All data is local.")
|
||||||
|
|
@ -779,7 +623,7 @@ def main():
|
||||||
if not client.server_url.startswith("http://") and not client.server_url.startswith("https://"):
|
if not client.server_url.startswith("http://") and not client.server_url.startswith("https://"):
|
||||||
client.server_url = "http://" + client.server_url
|
client.server_url = "http://" + client.server_url
|
||||||
|
|
||||||
try: sio.connect(client.server_url, transports=['websocket'])
|
try: socket_manager.sio.connect(client.server_url, transports=['websocket'])
|
||||||
except Exception as e: return
|
except Exception as e: return
|
||||||
|
|
||||||
print("\n1. Register\n2. Login")
|
print("\n1. Register\n2. Login")
|
||||||
|
|
@ -789,7 +633,7 @@ def main():
|
||||||
|
|
||||||
event_wait = threading.Event()
|
event_wait = threading.Event()
|
||||||
|
|
||||||
@sio.event
|
@socket_manager.sio.event
|
||||||
def register_response(data):
|
def register_response(data):
|
||||||
nonlocal is_new_registration
|
nonlocal is_new_registration
|
||||||
if data["status"] == "success":
|
if data["status"] == "success":
|
||||||
|
|
@ -799,7 +643,7 @@ def main():
|
||||||
is_new_registration = True
|
is_new_registration = True
|
||||||
event_wait.set()
|
event_wait.set()
|
||||||
|
|
||||||
@sio.event
|
@socket_manager.sio.event
|
||||||
def login_response(data):
|
def login_response(data):
|
||||||
if data["status"] == "success":
|
if data["status"] == "success":
|
||||||
client.uin = username_or_uin
|
client.uin = username_or_uin
|
||||||
|
|
@ -807,8 +651,8 @@ def main():
|
||||||
client.password = password
|
client.password = password
|
||||||
event_wait.set()
|
event_wait.set()
|
||||||
|
|
||||||
if mode == "1": sio.emit("register", {"username": username_or_uin, "password": password})
|
if mode == "1": socket_manager.sio.emit("register", {"username": username_or_uin, "password": password})
|
||||||
else: sio.emit("login", {"uin": username_or_uin, "password": password})
|
else: socket_manager.sio.emit("login", {"uin": username_or_uin, "password": password})
|
||||||
event_wait.wait()
|
event_wait.wait()
|
||||||
|
|
||||||
if not client.uin: return
|
if not client.uin: return
|
||||||
|
|
@ -816,8 +660,8 @@ def main():
|
||||||
input("Press Enter to open TUI...")
|
input("Press Enter to open TUI...")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
sio.connect(client.server_url, transports=['websocket'])
|
socket_manager.sio.connect(client.server_url, transports=['websocket'])
|
||||||
sio.emit("login", {"uin": client.uin, "password": client.password})
|
socket_manager.sio.emit("login", {"uin": client.uin, "password": client.password})
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
if is_new_registration:
|
if is_new_registration:
|
||||||
|
|
@ -836,4 +680,4 @@ def main():
|
||||||
client.app.run()
|
client.app.run()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
40
compile.py
Normal file
40
compile.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import platform
|
||||||
|
|
||||||
|
REPO_URL = "https://git.idkmail.ru/lohrrrr/TyChat-Client.git"
|
||||||
|
PROJECT_DIR = "TyChat-Client-Build"
|
||||||
|
VENV_DIR = os.path.join(PROJECT_DIR, "venv")
|
||||||
|
ENTRY_POINT = "TUI.py"
|
||||||
|
|
||||||
|
def run_cmd(cmd, cwd=None):
|
||||||
|
subprocess.run(cmd, cwd=cwd, check=True)
|
||||||
|
|
||||||
|
def setup_build():
|
||||||
|
if os.path.exists(PROJECT_DIR):
|
||||||
|
shutil.rmtree(PROJECT_DIR)
|
||||||
|
print(f"[*] Cloning repository...")
|
||||||
|
run_cmd(["git", "clone", REPO_URL, PROJECT_DIR])
|
||||||
|
|
||||||
|
print(f"[*] Creating virtual environment...")
|
||||||
|
run_cmd([sys.executable, "-m", "venv", VENV_DIR])
|
||||||
|
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
pip_path = os.path.join(VENV_DIR, "Scripts", "pip")
|
||||||
|
pyinstaller_path = os.path.join(VENV_DIR, "Scripts", "pyinstaller")
|
||||||
|
else:
|
||||||
|
pip_path = os.path.join(VENV_DIR, "bin", "pip")
|
||||||
|
pyinstaller_path = os.path.join(VENV_DIR, "bin", "pyinstaller")
|
||||||
|
|
||||||
|
print(f"[*] Installing dependencies...")
|
||||||
|
run_cmd([pip_path, "install", "pyinstaller", "socketio", "websocket-client", "numpy", "sounddevice", "prompt_toolkit"])
|
||||||
|
|
||||||
|
print(f"[*] Building standalone binary...")
|
||||||
|
run_cmd([pyinstaller_path, "--onefile", "--name", "TyChat", ENTRY_POINT], cwd=PROJECT_DIR)
|
||||||
|
|
||||||
|
print(f"\n[!] Build Complete! Find your binary in: {os.path.join(PROJECT_DIR, 'dist')}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
setup_build()
|
||||||
67
protocol.py
Normal file
67
protocol.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
BASE_FREQ = 600
|
||||||
|
FREQ_STEP = 25
|
||||||
|
TONE_DURATION = 0.1
|
||||||
|
SAMPLE_RATE = 44100
|
||||||
|
|
||||||
|
FREQ_QUERY = 1200
|
||||||
|
FREQ_ALERT = 1400
|
||||||
|
FREQ_RESP_YES = 1600
|
||||||
|
FREQ_RESP_NO = 1800
|
||||||
|
|
||||||
|
def generate_service_tone(frequency, duration=0.4):
|
||||||
|
t = np.linspace(0, duration, int(SAMPLE_RATE * duration), False)
|
||||||
|
tone = np.sin(frequency * t * 2 * np.pi) * 0.7
|
||||||
|
envelope = np.ones_like(tone)
|
||||||
|
fade_len = int(SAMPLE_RATE * 0.05)
|
||||||
|
envelope[:fade_len] = np.linspace(0, 1, fade_len)
|
||||||
|
envelope[-fade_len:] = np.linspace(1, 0, fade_len)
|
||||||
|
return (tone * envelope).astype(np.float32).tobytes()
|
||||||
|
|
||||||
|
def text_to_audio(text):
|
||||||
|
audio_signals = []
|
||||||
|
t_start = np.linspace(0, 0.3, int(SAMPLE_RATE * 0.3), False)
|
||||||
|
audio_signals.append(np.sin(1000 * t_start * 2 * np.pi))
|
||||||
|
for char in text:
|
||||||
|
freq = BASE_FREQ + ord(char) * FREQ_STEP
|
||||||
|
t = np.linspace(0, TONE_DURATION, int(SAMPLE_RATE * TONE_DURATION), False)
|
||||||
|
audio_signals.append(np.sin(freq * t * 2 * np.pi))
|
||||||
|
full_audio = np.concatenate(audio_signals).astype(np.float32)
|
||||||
|
if np.max(np.abs(full_audio)) > 0:
|
||||||
|
full_audio = full_audio / np.max(np.abs(full_audio))
|
||||||
|
return full_audio.tobytes()
|
||||||
|
|
||||||
|
def detect_service_tone(audio_data):
|
||||||
|
try:
|
||||||
|
window_data = np.hanning(len(audio_data))
|
||||||
|
fft_data = np.abs(np.fft.rfft(audio_data * window_data))
|
||||||
|
frequencies = np.fft.rfftfreq(len(audio_data), d=1/SAMPLE_RATE)
|
||||||
|
detected_freq = frequencies[np.argmax(fft_data)]
|
||||||
|
for target in [FREQ_QUERY, FREQ_ALERT, FREQ_RESP_YES, FREQ_RESP_NO]:
|
||||||
|
if abs(detected_freq - target) <= 15:
|
||||||
|
return target
|
||||||
|
return None
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fast_decode(audio_data):
|
||||||
|
try:
|
||||||
|
samples_per_tone = int(SAMPLE_RATE * TONE_DURATION)
|
||||||
|
current_sample = int(SAMPLE_RATE * 0.3)
|
||||||
|
text = ""
|
||||||
|
while current_sample < len(audio_data):
|
||||||
|
chunk = audio_data[current_sample : current_sample + samples_per_tone]
|
||||||
|
if len(chunk) < samples_per_tone:
|
||||||
|
break
|
||||||
|
window_data = np.hanning(len(chunk))
|
||||||
|
fft_data = np.abs(np.fft.rfft(chunk * window_data))
|
||||||
|
frequencies = np.fft.rfftfreq(len(chunk), d=1/SAMPLE_RATE)
|
||||||
|
detected_freq = frequencies[np.argmax(fft_data)]
|
||||||
|
ascii_code = int(round((detected_freq - BASE_FREQ) / FREQ_STEP))
|
||||||
|
if 0 <= ascii_code < 65535:
|
||||||
|
text += chr(ascii_code)
|
||||||
|
current_sample += samples_per_tone
|
||||||
|
return text
|
||||||
|
except:
|
||||||
|
return ""
|
||||||
112
socket_manager.py
Normal file
112
socket_manager.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import socketio
|
||||||
|
import base64
|
||||||
|
import numpy as np
|
||||||
|
import threading
|
||||||
|
import sounddevice as sd
|
||||||
|
import protocol
|
||||||
|
|
||||||
|
sio = socketio.Client()
|
||||||
|
_client_instance = None
|
||||||
|
|
||||||
|
def init_network(client_instance):
|
||||||
|
"""Binds the network events to the active TUI client instance state."""
|
||||||
|
global _client_instance
|
||||||
|
_client_instance = client_instance
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def incoming_packet(data):
|
||||||
|
if not _client_instance: return
|
||||||
|
from_uin = data["from_uin"]
|
||||||
|
payload_base64 = data["payload"]
|
||||||
|
try:
|
||||||
|
audio_bytes = base64.b64decode(payload_base64.encode('utf-8'))
|
||||||
|
audio_data = np.frombuffer(audio_bytes, dtype=np.float32)
|
||||||
|
|
||||||
|
srv_tone = protocol.detect_service_tone(audio_data)
|
||||||
|
if srv_tone:
|
||||||
|
sd.play(audio_data, protocol.SAMPLE_RATE)
|
||||||
|
if from_uin not in _client_instance.contacts:
|
||||||
|
return
|
||||||
|
if srv_tone == protocol.FREQ_QUERY:
|
||||||
|
if _client_instance.is_busy:
|
||||||
|
_client_instance.send_service_tone(from_uin, protocol.FREQ_RESP_YES)
|
||||||
|
else:
|
||||||
|
_client_instance.pending_windows.append({"type": "query", "uin": from_uin})
|
||||||
|
_client_instance.refresh_ui()
|
||||||
|
return
|
||||||
|
elif srv_tone == protocol.FREQ_ALERT:
|
||||||
|
_client_instance.pending_windows.append({"type": "alert", "uin": from_uin})
|
||||||
|
_client_instance.refresh_ui()
|
||||||
|
return
|
||||||
|
elif srv_tone == protocol.FREQ_RESP_YES:
|
||||||
|
_client_instance.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> Yes, I'm busy")
|
||||||
|
return
|
||||||
|
elif srv_tone == protocol.FREQ_RESP_NO:
|
||||||
|
_client_instance.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> No, go on")
|
||||||
|
return
|
||||||
|
|
||||||
|
fast_text = protocol.fast_decode(audio_data)
|
||||||
|
if fast_text.startswith("SYS:"):
|
||||||
|
cmd = fast_text[4:]
|
||||||
|
if cmd.startswith("ROOM_MSG:"):
|
||||||
|
parts = cmd.split(":", 3)
|
||||||
|
if len(parts) >= 4:
|
||||||
|
room_id = parts[1]
|
||||||
|
sender_uin = parts[2]
|
||||||
|
actual_msg = parts[3]
|
||||||
|
if room_id in _client_instance.groups:
|
||||||
|
_client_instance.add_to_history(room_id, f"[UIN {sender_uin}]: {actual_msg}")
|
||||||
|
return
|
||||||
|
elif cmd == "REQ_ADD":
|
||||||
|
if from_uin in _client_instance.blocklist:
|
||||||
|
_client_instance.send_sys_packet(from_uin, "RES_DEC")
|
||||||
|
elif _client_instance.is_busy:
|
||||||
|
_client_instance.send_sys_packet(from_uin, "RES_BSY")
|
||||||
|
else:
|
||||||
|
if from_uin not in _client_instance.pending_requests and from_uin not in _client_instance.contacts:
|
||||||
|
_client_instance.pending_requests.append(from_uin)
|
||||||
|
_client_instance.refresh_ui()
|
||||||
|
return
|
||||||
|
elif cmd == "RES_ACC":
|
||||||
|
if _client_instance.dialing_uin == from_uin:
|
||||||
|
_client_instance.dialing_uin = None
|
||||||
|
if from_uin not in _client_instance.contacts:
|
||||||
|
_client_instance.contacts[from_uin] = {"status": "online", "unread": 0, "attention": False}
|
||||||
|
_client_instance.history[from_uin] = ["[SYSTEM]: Handshake accepted. Contact added."]
|
||||||
|
_client_instance.save_config()
|
||||||
|
_client_instance.active_chat = from_uin
|
||||||
|
_client_instance.refresh_ui()
|
||||||
|
return
|
||||||
|
elif cmd == "RES_DEC" or cmd == "RES_BSY":
|
||||||
|
if _client_instance.dialing_uin == from_uin:
|
||||||
|
_client_instance.dialing_uin = None
|
||||||
|
return
|
||||||
|
|
||||||
|
if from_uin not in _client_instance.contacts:
|
||||||
|
return
|
||||||
|
if _client_instance.active_chat != from_uin:
|
||||||
|
_client_instance.contacts[from_uin]["unread"] += 1
|
||||||
|
|
||||||
|
threading.Thread(target=_client_instance.play_and_decode, args=(audio_bytes, from_uin), daemon=True).start()
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def online_statuses_response(data):
|
||||||
|
if not _client_instance: return
|
||||||
|
for uin, status in data.items():
|
||||||
|
if uin in _client_instance.contacts:
|
||||||
|
_client_instance.contacts[uin]["status"] = status
|
||||||
|
_client_instance.refresh_ui()
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
def error(data):
|
||||||
|
if not _client_instance: return
|
||||||
|
msg = data.get("message")
|
||||||
|
target = data.get("target_uin")
|
||||||
|
if msg == "offline" and target:
|
||||||
|
if _client_instance.dialing_uin == target:
|
||||||
|
_client_instance.dialing_uin = None
|
||||||
|
if target in _client_instance.contacts:
|
||||||
|
_client_instance.contacts[target]["status"] = "offline"
|
||||||
|
_client_instance.add_to_history(target, f"[SYSTEM]: They went offline. Outgoing stack will be preserved.")
|
||||||
Reference in a new issue