Developer Hub

Cara Mengontrol HP Android dari Linux: Screen Mirroring, Rekam Layar, Reverse Internet, dan Audio DSP

Cara Mengontrol HP Android dari Linux: Screen Mirroring, Rekam Layar, Reverse Internet, dan Audio DSP

Apakah Anda mencari cara praktis untuk mengelola HP Android langsung dari PC/Laptop Linux? Script All-in-One berbasis Python GUI (Tkinter) ini menggabungkan keandalan Scrcpy, Gnirehtet, dan FFmpeg DSP Audio ke dalam satu tampilan ringkas dan intuitif.

Fitur Utama Aplikasi

  • Screen Mirroring & Recording: Tampilkan layar HP di PC dengan kontrol penuh (kustomisasi resolusi, bitrate, FPS) serta opsi rekam layar otomatis (.mp4/.mkv).
  • Reverse Internet (Gnirehtet): Alirkan koneksi internet PC ke HP via kabel USB tanpa root atau hotspot. Dilengkapi fitur Auto-Reconnect.
  • Audio DSP & Vocal Control: Equalizer audio real-time untuk Linux dengan 15 preset, penguat bass, serta fitur isolasi/penghilang vokal.

1. Persiapan Dependensi (Prasyarat)

Sebelum menjalankan script, pastikan sistem Linux Anda telah terpasang aplikasi pendukung berikut. Buka terminal dan jalankan:

# Update & install dependensi dasar pada Ubuntu/Debian/Linux Mint
sudo apt update
sudo apt install python3 python3-tk adb scrcpy ffmpeg pulseaudio-utils -y

Khusus untuk fitur Reverse Internet (Gnirehtet):

  1. Unduh binary gnirehtet-rust-linux-x86_64-v...zip dari halaman GitHub resmi Gnirehtet.
  2. Ekstrak dan letakkan file executable gnirehtet di folder Downloads, Desktop, atau dalam satu folder yang sama dengan script Python ini.

2. Langkah Pemasangan & Menjalankan Aplikasi

  1. Buat file baru bernama android_suite.py di komputer Anda.
  2. Salin seluruh kode Python di bawah ini lalu simpan ke dalam file tersebut:
#!/usr/bin/env python3

import os
import sys
import time
import shutil
import queue
import threading
import subprocess
import tkinter as tk
from tkinter import ttk, messagebox, filedialog

# ============================================================
# AUDIO DSP CONFIG & CONSTANTS
# ============================================================
RATE = 48000
CHANNELS = 2
FFMPEG_PCM_CODEC = "pcm_f32le"

INPUT_FRAME_BYTES = 512
INPUT_FRAGMENT_BYTES = 1024
OUTPUT_BUFFER_BYTES = 4096
OUTPUT_PREBUF_BYTES = 0
OUTPUT_MINREQ_BYTES = 512

PULSE_LATENCY_MSEC = 8
LIVE_UPDATE_DELAY_MSEC = 100


# ============================================================
# SYSTEM & AUDIO HELPERS
# ============================================================
def run_cmd(cmd, check=True):
    return subprocess.run(
        cmd,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=check
    )

def pactl(args, check=True):
    return run_cmd(["pactl"] + args, check=check)

def have_cmd(name):
    return shutil.which(name) is not None

def clamp(v, lo, hi):
    return max(lo, min(hi, float(v)))

def ffmpeg_has_pulse_io():
    demux = run_cmd(["ffmpeg", "-hide_banner", "-h", "demuxer=pulse"], check=False)
    mux = run_cmd(["ffmpeg", "-hide_banner", "-h", "muxer=pulse"], check=False)

    demux_text = ((demux.stdout or "") + (demux.stderr or "")).lower()
    mux_text = ((mux.stdout or "") + (mux.stderr or "")).lower()

    has_input = "demuxer pulse" in demux_text or "pulse audio input" in demux_text
    has_output = "muxer pulse" in mux_text or "pulse audio output" in mux_text

    return has_input and has_output

def get_default_sink():
    try:
        result = pactl(["get-default-sink"], check=False)
        output = result.stdout.strip()
        if output:
            return output
    except Exception:
        pass

    try:
        result = pactl(["info"], check=False)
        for line in result.stdout.splitlines():
            if line.lower().startswith("default sink:"):
                return line.split(":", 1)[1].strip()
    except Exception:
        pass

    return ""

def list_sinks():
    result = pactl(["list", "short", "sinks"], check=False)
    sinks = []
    if result and result.stdout:
        for line in result.stdout.splitlines():
            parts = line.split("\t")
            if len(parts) < 2:
                continue
            name = parts[1]
            if name.startswith("pybass_"):
                continue
            sinks.append(name)
    return sinks

def move_all_sink_inputs(target_sink):
    try:
        result = pactl(["list", "short", "sink-inputs"], check=False)
        if result and result.stdout:
            for line in result.stdout.splitlines():
                parts = line.split("\t")
                if not parts:
                    continue
                sink_input_id = parts[0].strip()
                if sink_input_id:
                    pactl(["move-sink-input", sink_input_id, target_sink], check=False)
    except Exception:
        pass

def cleanup_old_pybass(output_sink=None):
    try:
        if output_sink:
            pactl(["set-default-sink", output_sink], check=False)
            move_all_sink_inputs(output_sink)
            time.sleep(0.15)

        result = pactl(["list", "short", "modules"], check=False)
        modules_to_remove = []

        if result and result.stdout:
            for line in result.stdout.splitlines():
                parts = line.split("\t")
                if len(parts) < 3:
                    continue
                module_id, module_name, module_args = parts[0], parts[1], parts[2]
                if module_name == "module-null-sink" and "sink_name=pybass_" in module_args:
                    modules_to_remove.append(module_id)

        for module_id in modules_to_remove:
            pactl(["unload-module", module_id], check=False)
    except Exception:
        pass

def find_gnirehtet_binary():
    found = shutil.which("gnirehtet")
    if found:
        return found

    home = os.path.expanduser("~")
    script_dir = os.path.dirname(os.path.abspath(__file__))

    candidates = [
        os.path.join(script_dir, "gnirehtet"),
        os.path.join(home, "gnirehtet"),
        os.path.join(home, "Desktop", "gnirehtet"),
        os.path.join(home, "Downloads", "gnirehtet"),
        os.path.join(home, "Unduhan", "gnirehtet"),
        "/usr/bin/gnirehtet",
        "/usr/local/bin/gnirehtet",
        "/opt/gnirehtet/gnirehtet"
    ]

    for candidate in candidates:
        if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
            return candidate

    for search_dir in [script_dir, os.path.join(home, "Desktop"), os.path.join(home, "Downloads"), os.path.join(home, "Unduhan"), home]:
        if os.path.exists(search_dir):
            for root, _, files in os.walk(search_dir):
                if "gnirehtet" in files:
                    full_p = os.path.join(root, "gnirehtet")
                    if os.access(full_p, os.X_OK):
                        return full_p
                if root.count(os.path.sep) - search_dir.count(os.path.sep) >= 2:
                    break
    return ""

def build_ffmpeg_filter(p):
    preamp = clamp(p["preamp_db"], -24, -4)
    bass_db = clamp(p["bass_db"], 0, 20)
    bass_freq = clamp(p["bass_freq"], 55, 120)
    chest_db = clamp(p["chest_db"], 0, 12)
    chest_freq = clamp(p["chest_freq"], 65, 90)
    kick_db = clamp(p["kick_db"], 0, 8)
    kick_freq = clamp(p["kick_freq"], 100, 130)
    mud_cut = clamp(p["mud_cut_db"], -9, 0)
    rumble = clamp(p["rumble_freq"], 25, 45)
    clarity = clamp(p["clarity_db"], 0, 4)
    output = clamp(p["output_db"], -6, 3)

    filters = []

    vocal_mode = p.get("vocal_mode", "normal")
    vocal_strength = clamp(p.get("vocal_strength", 1.0), 0.0, 1.0)

    if vocal_mode == "remove":
        filters.append(f"extrastereo=m={vocal_strength * 2.5}:c=0")
        filters.append(f"pan=stereo|c0=c0-{vocal_strength}*c1|c1=c1-{vocal_strength}*c0")
        filters.append(f"volume={1.5 + vocal_strength * 0.5}")
    elif vocal_mode == "isolate":
        filters.append(f"pan=stereo|c0=c0+{vocal_strength}*c1|c1=c0+{vocal_strength}*c1")
        filters.append("bandpass=f=800:width_type=h:width=2500")
        filters.append(f"volume={2.0 + vocal_strength}")
    elif vocal_mode == "enhance":
        filters.append(f"equalizer=f=1200:t=q:w=1.5:g={3 + vocal_strength * 4}")
        filters.append(f"equalizer=f=3500:t=q:w=1.2:g={2 + vocal_strength * 3}")
        filters.append("compand=attacks=0.1:decays=0.2:points=-80/-80|-45/-30|-27/-20|0/-10")

    if vocal_mode != "isolate":
        filters.append(f"volume={preamp:.2f}dB")
        filters.append(f"highpass=f={rumble:.1f}")
        filters.append(f"bass=g={bass_db:.2f}:f={bass_freq:.1f}:t=q:w=0.70")
        filters.append(f"equalizer=f={chest_freq:.1f}:t=q:w=1.05:g={chest_db:.2f}")
        filters.append(f"equalizer=f={kick_freq:.1f}:t=q:w=1.20:g={kick_db:.2f}")
        filters.append(f"equalizer=f=245:t=q:w=0.85:g={mud_cut:.2f}")
        if clarity > 0.01:
            filters.append(f"treble=g={clarity:.2f}:f=6500:t=q:w=0.70")

    filters.append(f"volume={output:.2f}dB")

    if p["limiter"]:
        filters.append("alimiter=limit=0.93:attack=1:release=30")

    return ",".join(filters)


# ============================================================
# WORKER THREAD FOR AUDIO PIPELINE
# ============================================================
class PipelineWorker(threading.Thread):
    def __init__(self, virtual_sink, output_sink, ff_filter, msg_queue):
        super().__init__(daemon=True)
        self.virtual_sink = virtual_sink
        self.output_sink = output_sink
        self.ff_filter = ff_filter
        self.msg_queue = msg_queue
        self.stop_event = threading.Event()
        self.reload_event = threading.Event()
        self.filter_lock = threading.Lock()
        self.fx_proc = None

    def update_filter(self, new_filter):
        with self.filter_lock:
            self.ff_filter = new_filter
        self.reload_event.set()

    def stop(self):
        self.stop_event.set()
        self._kill_process()

    def _kill_process(self):
        proc = self.fx_proc
        if not proc:
            return
        if proc.poll() is None:
            try:
                proc.terminate()
                proc.wait(timeout=0.30)
            except subprocess.TimeoutExpired:
                try:
                    proc.kill()
                    proc.wait(timeout=0.30)
                except Exception:
                    pass
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
        self.fx_proc = None

    def _read_error(self, proc):
        if not proc or not proc.stderr:
            return ""
        try:
            return proc.stderr.read().decode(errors="ignore").strip()
        except Exception:
            return ""

    def _start_pipeline(self):
        with self.filter_lock:
            current_filter = self.ff_filter

        monitor_source = f"{self.virtual_sink}.monitor"
        env = os.environ.copy()
        env["PULSE_LATENCY_MSEC"] = str(PULSE_LATENCY_MSEC)
        env["PIPEWIRE_LATENCY"] = "128/48000"

        command = [
            "ffmpeg", "-nostdin", "-hide_banner", "-nostats", "-loglevel", "error",
            "-filter_threads", "1", "-fflags", "nobuffer", "-probesize", "32",
            "-analyzeduration", "0", "-thread_queue_size", "64", "-f", "pulse",
            "-sample_rate", str(RATE), "-channels", str(CHANNELS),
            "-frame_size", str(INPUT_FRAME_BYTES), "-fragment_size", str(INPUT_FRAGMENT_BYTES),
            "-i", monitor_source, "-map", "0:a:0", "-vn", "-sn", "-dn",
            "-af", current_filter, "-ar", str(RATE), "-ac", str(CHANNELS),
            "-c:a", FFMPEG_PCM_CODEC, "-flush_packets", "1", "-f", "pulse",
            "-device", self.output_sink, "-buffer_size", str(OUTPUT_BUFFER_BYTES),
            "-prebuf", str(OUTPUT_PREBUF_BYTES), "-minreq", str(OUTPUT_MINREQ_BYTES),
            "-stream_name", "PyBass Direct DSP", "PyBass_Direct_DSP"
        ]

        self.fx_proc = subprocess.Popen(
            command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE, bufsize=0, env=env
        )

    def run(self):
        try:
            self._start_pipeline()
            time.sleep(0.15)
            if self.fx_proc and self.fx_proc.poll() is not None:
                error = self._read_error(self.fx_proc) or "FFmpeg direct DSP gagal dijalankan."
                raise RuntimeError(error)

            self.msg_queue.put(("status", "DSP direct aktif - mode low latency."))

            while not self.stop_event.is_set():
                if self.reload_event.is_set():
                    self.reload_event.clear()
                    self.msg_queue.put(("status", "Memperbarui filter..."))
                    self._kill_process()

                    if self.stop_event.is_set():
                        break

                    time.sleep(0.02)
                    self._start_pipeline()
                    time.sleep(0.08)

                    if self.fx_proc and self.fx_proc.poll() is not None:
                        error = self._read_error(self.fx_proc) or "FFmpeg berhenti saat update."
                        raise RuntimeError(error)

                    self.msg_queue.put(("status", "Filter berhasil diperbarui."))
                    continue

                if self.fx_proc and self.fx_proc.poll() is not None:
                    error = self._read_error(self.fx_proc) or "FFmpeg berhenti tanpa pesan error."
                    raise RuntimeError(error)

                time.sleep(0.05)

        except Exception as e:
            if not self.stop_event.is_set():
                self.msg_queue.put(("error", str(e)))
        finally:
            self._kill_process()
            if self.stop_event.is_set():
                self.msg_queue.put(("status", "DSP berhenti."))


# ============================================================
# TAB 1: SCREEN MIRRORING & RECORDING (SCRCPY)
# ============================================================
class MirrorTab(ttk.Frame):
    def __init__(self, parent):
        super().__init__(parent)
        self.auto_connect = False
        self.auto_thread = None
        self.running_scrcpy_procs = []
        self.build_ui()

    def build_ui(self):
        frame_dev = ttk.LabelFrame(self, text="Perangkat Terhubung")
        frame_dev.pack(fill="x", padx=10, pady=5)

        self.device_combo = ttk.Combobox(frame_dev, state="readonly")
        self.device_combo.pack(side="left", fill="x", expand=True, padx=5, pady=5)

        btn_refresh = ttk.Button(frame_dev, text="Refresh Perangkat", command=self.refresh_devices)
        btn_refresh.pack(side="right", padx=5, pady=5)

        # --- MENU KONEKSI SCRCPY VIA WIFI ---
        frame_wifi = ttk.LabelFrame(self, text="Koneksi Scrcpy via WiFi (ADB Wireless)")
        frame_wifi.pack(fill="x", padx=10, pady=5)

        wifi_inner = ttk.Frame(frame_wifi)
        wifi_inner.pack(fill="x", padx=5, pady=5)

        ttk.Label(wifi_inner, text="IP:Port HP:").pack(side="left", padx=(0, 5))
        self.wifi_ip_var = tk.StringVar(value="192.168.1.X:5555")
        ttk.Entry(wifi_inner, textvariable=self.wifi_ip_var, width=18).pack(side="left", padx=5)

        ttk.Button(wifi_inner, text="Connect WiFi", command=self.connect_wifi).pack(side="left", padx=5)
        ttk.Button(wifi_inner, text="Disconnect WiFi", command=self.disconnect_wifi).pack(side="left", padx=5)

        # --- PENGATURAN REKAM LAYAR ---
        frame_rec = ttk.LabelFrame(self, text="Pengaturan Perekaman Layar (Screen Recording)")
        frame_rec.pack(fill="x", padx=10, pady=5)

        self.enable_record_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(frame_rec, text="Aktifkan Perekaman Layar Saat Mirroring (-r)", variable=self.enable_record_var).pack(anchor="w", padx=5, pady=4)

        rec_dir_frame = ttk.Frame(frame_rec)
        rec_dir_frame.pack(fill="x", padx=5, pady=4)

        ttk.Label(rec_dir_frame, text="Folder Simpan:").pack(side="left")
        default_dir = os.path.expanduser("~/Videos") if os.path.exists(os.path.expanduser("~/Videos")) else os.path.expanduser("~")
        self.rec_dir_var = tk.StringVar(value=default_dir)
        ttk.Entry(rec_dir_frame, textvariable=self.rec_dir_var).pack(side="left", fill="x", expand=True, padx=5)
        ttk.Button(rec_dir_frame, text="Pilih Folder", command=self.browse_rec_dir).pack(side="right")

        rec_fmt_frame = ttk.Frame(frame_rec)
        rec_fmt_frame.pack(fill="x", padx=5, pady=4)

        ttk.Label(rec_fmt_frame, text="Format File Rekaman:").pack(side="left")
        self.rec_format_var = tk.StringVar(value="mp4")
        self.rec_fmt_combo = ttk.Combobox(rec_fmt_frame, textvariable=self.rec_format_var, values=["mp4", "mkv"], width=8, state="readonly")
        self.rec_fmt_combo.pack(side="left", padx=5)

        # --- PENGATURAN SCRCPY LENGKAP ---
        frame_opts = ttk.LabelFrame(self, text="Pengaturan Display & Performa")
        frame_opts.pack(fill="x", padx=10, pady=5)
        frame_opts.columnconfigure(1, weight=1)

        ttk.Label(frame_opts, text="Resolusi Maksimal:").grid(row=0, column=0, sticky="w", padx=5, pady=5)
        self.size_var = tk.StringVar(value="1024")
        self.size_combo = ttk.Combobox(frame_opts, textvariable=self.size_var, values=[
            "Asli / Max (Original)", "1920 (Full HD)", "1600 (2K Scaling)",
            "1440", "1280 (HD 720p)", "1024 (Standard)", "800 (Low)", "720"
        ])
        self.size_combo.grid(row=0, column=1, sticky="ew", padx=5, pady=5)

        ttk.Label(frame_opts, text="Bitrate Video:").grid(row=1, column=0, sticky="w", padx=5, pady=5)
        self.bitrate_var = tk.StringVar(value="8 Mbps")
        self.bitrate_combo = ttk.Combobox(frame_opts, textvariable=self.bitrate_var, values=[
            "2 Mbps", "4 Mbps", "8 Mbps", "12 Mbps", "16 Mbps", "24 Mbps", "32 Mbps"
        ])
        self.bitrate_combo.grid(row=1, column=1, sticky="ew", padx=5, pady=5)

        ttk.Label(frame_opts, text="Max FPS:").grid(row=2, column=0, sticky="w", padx=5, pady=5)
        self.fps_var = tk.StringVar(value="60")
        self.fps_combo = ttk.Combobox(frame_opts, textvariable=self.fps_var, values=[
            "30", "60", "90", "120", "Tanpa Batas (Uncapped)"
        ])
        self.fps_combo.grid(row=2, column=1, sticky="ew", padx=5, pady=5)

        frame_checks = ttk.Frame(frame_opts)
        frame_checks.grid(row=3, column=0, columnspan=2, sticky="w", padx=5, pady=5)

        self.stay_awake_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(frame_checks, text="Layar Tetap Menyala (-w)", variable=self.stay_awake_var).pack(anchor="w", pady=2)

        self.turn_off_screen_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(frame_checks, text="Matikan Layar HP Saat Mirroring (-S)", variable=self.turn_off_screen_var).pack(anchor="w", pady=2)

        self.show_touches_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(frame_checks, text="Tampilkan Sentuhan Layar (--show-touches)", variable=self.show_touches_var).pack(anchor="w", pady=2)

        self.always_on_top_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(frame_checks, text="Jendela Selalu di Atas (--always-on-top)", variable=self.always_on_top_var).pack(anchor="w", pady=2)

        self.fullscreen_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(frame_checks, text="Mode Layar Penuh / Fullscreen (-f)", variable=self.fullscreen_var).pack(anchor="w", pady=2)

        self.no_audio_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(frame_checks, text="Matikan Audio Transmit (--no-audio)", variable=self.no_audio_var).pack(anchor="w", pady=2)

        frame_act = ttk.Frame(self)
        frame_act.pack(fill="x", padx=10, pady=10)

        btn_start = ttk.Button(frame_act, text="MIRROR / REKAM LAYAR SEKARANG", command=self.start_scrcpy)
        btn_start.pack(fill="x", pady=2)

        self.btn_auto = ttk.Button(frame_act, text="START AUTO CONNECT MIRROR", command=self.toggle_auto_connect)
        self.btn_auto.pack(fill="x", pady=2)

        self.refresh_devices()

    def connect_wifi(self):
        ip_port = self.wifi_ip_var.get().strip()
        if not ip_port:
            messagebox.showwarning("Peringatan", "Masukkan alamat IP & Port perangkat terlebih dahulu!")
            return
        try:
            res = subprocess.run(["adb", "connect", ip_port], capture_output=True, text=True)
            output = res.stdout.strip()
            if "connected" in output.lower():
                messagebox.showinfo("Berhasil", f"Berhasil terhubung ke {ip_port}")
                self.refresh_devices()
            else:
                messagebox.showerror("Gagal", f"Gagal terhubung:\n{output}")
        except Exception as e:
            messagebox.showerror("Error", f"Terjadi kesalahan:\n{e}")

    def disconnect_wifi(self):
        ip_port = self.wifi_ip_var.get().strip()
        if not ip_port:
            return
        try:
            subprocess.run(["adb", "disconnect", ip_port], capture_output=True, text=True)
            messagebox.showinfo("Informasi", f"Koneksi ke {ip_port} diputus.")
            self.refresh_devices()
        except Exception as e:
            messagebox.showerror("Error", f"Terjadi kesalahan:\n{e}")

    def browse_rec_dir(self):
        selected = filedialog.askdirectory(title="Pilih Folder Penyimpanan Rekaman")
        if selected:
            self.rec_dir_var.set(selected)

    def build_cmd(self, device=None):
        cmd = ["scrcpy"]
        if device:
            cmd.extend(["-s", device])

        size_raw = self.size_var.get().split()[0]
        if size_raw.isdigit():
            cmd.extend(["-m", size_raw])

        bitrate_raw = self.bitrate_var.get().split()[0]
        if bitrate_raw.isdigit():
            cmd.extend(["-b", f"{bitrate_raw}M"])

        fps_raw = self.fps_var.get().split()[0]
        if fps_raw.isdigit():
            cmd.extend(["--max-fps", fps_raw])

        if self.stay_awake_var.get():
            cmd.append("-w")
        if self.turn_off_screen_var.get():
            cmd.append("-S")
        if self.show_touches_var.get():
            cmd.append("--show-touches")
        if self.always_on_top_var.get():
            cmd.append("--always-on-top")
        if self.fullscreen_var.get():
            cmd.append("-f")
        if self.no_audio_var.get():
            cmd.append("--no-audio")

        if self.enable_record_var.get():
            save_dir = self.rec_dir_var.get().strip()
            if not save_dir:
                save_dir = os.path.expanduser("~")
            os.makedirs(save_dir, exist_ok=True)

            timestamp = time.strftime("%Y%m%d_%H%M%S")
            fmt = self.rec_format_var.get().strip().lower()
            rec_filename = os.path.join(save_dir, f"scrcpy_rec_{timestamp}.{fmt}")
            cmd.extend(["-r", rec_filename])

        return cmd

    def refresh_devices(self):
        devices = []
        try:
            res = subprocess.run(["adb", "devices"], capture_output=True, text=True)
            for line in res.stdout.splitlines()[1:]:
                if "\tdevice" in line:
                    devices.append(line.split("\t")[0])
        except Exception:
            pass

        self.device_combo["values"] = devices
        if devices:
            self.device_combo.current(0)
        else:
            self.device_combo.set("")

    def start_scrcpy(self):
        if not have_cmd("scrcpy"):
            messagebox.showerror("Error", "Perintah 'scrcpy' tidak terinstall atau tidak ditemukan di sistem!")
            return
        device = self.device_combo.get()
        cmd = self.build_cmd(device)
        try:
            p = subprocess.Popen(cmd)
            self.running_scrcpy_procs.append(p)
        except Exception as e:
            messagebox.showerror("Error", f"Gagal menjalankan Scrcpy:\n{e}")

    def toggle_auto_connect(self):
        self.auto_connect = not self.auto_connect
        if self.auto_connect:
            self.btn_auto.config(text="STOP AUTO CONNECT MIRROR")
            self.auto_thread = threading.Thread(target=self._auto_connect_loop, daemon=True)
            self.auto_thread.start()
        else:
            self.btn_auto.config(text="START AUTO CONNECT MIRROR")

    def _auto_connect_loop(self):
        connected = set()
        while self.auto_connect:
            try:
                res = subprocess.run(["adb", "devices"], capture_output=True, text=True)
                current = set()
                for line in res.stdout.splitlines()[1:]:
                    if "\tdevice" in line:
                        current.add(line.split("\t")[0])

                new_devs = current - connected
                for dev in new_devs:
                    cmd = self.build_cmd(dev)
                    subprocess.Popen(cmd)

                connected = current
            except Exception:
                pass
            time.sleep(2)


# ============================================================
# TAB 2: REVERSE INTERNET (GNIREHTET WITH AUTO-RECONNECT)
# ============================================================
class ReverseNetTab(ttk.Frame):
    def __init__(self, parent):
        super().__init__(parent)
        self.process = None
        self.auto_reconnect = False
        self.auto_thread = None
        self.gnirehtet_path = find_gnirehtet_binary()
        self.build_ui()

    def build_ui(self):
        frame_bin = ttk.LabelFrame(self, text="Lokasi Executable Gnirehtet")
        frame_bin.pack(fill="x", padx=10, pady=5)

        self.path_var = tk.StringVar(value=self.gnirehtet_path)
        ttk.Entry(frame_bin, textvariable=self.path_var).pack(side="left", fill="x", expand=True, padx=5, pady=5)

        btn_box = ttk.Frame(frame_bin)
        btn_box.pack(side="right", padx=5, pady=5)
        ttk.Button(btn_box, text="Deteksi Ulang", command=self.auto_detect).pack(side="left", padx=2)
        ttk.Button(btn_box, text="Cari Manual", command=self.browse_binary).pack(side="left", padx=2)

        frame_ctrl = ttk.LabelFrame(self, text="Koneksi Internet HP via PC")
        frame_ctrl.pack(fill="x", padx=10, pady=10)

        self.btn_toggle = ttk.Button(frame_ctrl, text="Mulai Internet Manual (Gnirehtet Run)", command=self.toggle_internet)
        self.btn_toggle.pack(fill="x", padx=10, pady=5)

        self.btn_auto = ttk.Button(frame_ctrl, text="AKTIFKAN AUTO RECONNECT (Autorun Kabel USB)", command=self.toggle_auto_reconnect)
        self.btn_auto.pack(fill="x", padx=10, pady=5)

        self.status_label = ttk.Label(frame_ctrl, text="Status: Berhenti", foreground="red")
        self.status_label.pack(pady=5)

    def auto_detect(self):
        found = find_gnirehtet_binary()
        if found:
            self.path_var.set(found)
            self.gnirehtet_path = found
            messagebox.showinfo("Deteksi Berhasil", f"Executable ditemukan di:\n{found}")
        else:
            messagebox.showwarning("Peringatan", "File 'gnirehtet' tidak ditemukan secara otomatis. Silakan tentukan lokasi file secara manual.")

    def browse_binary(self):
        filename = filedialog.askopenfilename(title="Pilih Executable Gnirehtet")
        if filename:
            self.path_var.set(filename)
            self.gnirehtet_path = filename

    def toggle_internet(self):
        path = self.path_var.get()
        if not path or not os.path.exists(path):
            messagebox.showerror("Error", "File executable 'gnirehtet' tidak ditemukan!")
            return

        if self.process is None:
            try:
                self.process = subprocess.Popen([path, "run"])
                self.status_label.config(text="Status: Berjalan (Mengalirkan Internet)", foreground="green")
                self.btn_toggle.config(text="Hentikan Internet Manual")
            except Exception as e:
                messagebox.showerror("Error", f"Gagal menjalankan Gnirehtet:\n{e}")
        else:
            self.stop_internet()

    def toggle_auto_reconnect(self):
        path = self.path_var.get()
        if not path or not os.path.exists(path):
            messagebox.showerror("Error", "File executable 'gnirehtet' tidak ditemukan!")
            return

        self.auto_reconnect = not self.auto_reconnect
        if self.auto_reconnect:
            self.btn_auto.config(text="MATIKAN AUTO RECONNECT")
            self.status_label.config(text="Status: Auto Reconnect Aktif (Menunggu Perangkat)", foreground="blue")
            self.auto_thread = threading.Thread(target=self._auto_reconnect_loop, daemon=True)
            self.auto_thread.start()
        else:
            self.btn_auto.config(text="AKTIFKAN AUTO RECONNECT (Autorun Kabel USB)")
            self.stop_internet()

    def _auto_reconnect_loop(self):
        path = self.path_var.get()
        while self.auto_reconnect:
            if self.process is None or self.process.poll() is not None:
                try:
                    res = subprocess.run(["adb", "devices"], capture_output=True, text=True)
                    has_device = any("\tdevice" in l for l in res.stdout.splitlines()[1:])
                    if has_device:
                        self.process = subprocess.Popen([path, "autorun"])
                        self.root_update_status("Status: Auto Reconnect Aktif (Internet Terhubung)", "green")
                except Exception:
                    pass
            else:
                try:
                    res = subprocess.run(["adb", "devices"], capture_output=True, text=True)
                    has_device = any("\tdevice" in l for l in res.stdout.splitlines()[1:])
                    if not has_device:
                        self.stop_internet()
                        self.root_update_status("Status: Perangkat Terputus (Menunggu Sambungan USB)", "orange")
                except Exception:
                    pass
            time.sleep(2)

    def root_update_status(self, text, color):
        try:
            self.after(0, lambda: self.status_label.config(text=text, foreground=color))
        except Exception:
            pass

    def stop_internet(self):
        if self.process:
            try:
                self.process.terminate()
                self.process.wait(timeout=0.5)
            except Exception:
                try:
                    self.process.kill()
                except Exception:
                    pass
            self.process = None
        self.status_label.config(text="Status: Berhenti", foreground="red")
        self.btn_toggle.config(text="Mulai Internet Manual (Gnirehtet Run)")


# ============================================================
# TAB 3: AUDIO EQUALIZER & VOCAL CONTROL (PYBASS)
# ============================================================
class AudioDSPTab(ttk.Frame):
    def __init__(self, parent):
        super().__init__(parent)

        self.msg_queue = queue.Queue()
        self.worker = None
        self.module_id = None
        self.virtual_sink = None
        self.output_sink = None
        self.running = False
        self.update_timer = None

        self.params = {
            "preamp_db": -16.0, "bass_db": 15.0, "bass_freq": 82.0,
            "chest_db": 8.0, "chest_freq": 76.0, "kick_db": 4.0,
            "kick_freq": 116.0, "mud_cut_db": -5.0, "rumble_freq": 32.0,
            "clarity_db": 1.0, "output_db": 0.0, "limiter": True,
            "vocal_mode": "normal", "vocal_strength": 1.0,
        }

        self.vars = {}
        self.labels = {}
        self.units = {}
        self.output_var = tk.StringVar()
        self.status_var = tk.StringVar(value="Siap. Pilih output asli lalu Start.")

        self.build_ui()
        self.refresh_sinks()

        self.after(200, self.poll_messages)

    def build_ui(self):
        canvas = tk.Canvas(self, highlightthickness=0)
        scrollbar = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
        scrollable_frame = ttk.Frame(canvas)

        canvas_window = canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")

        scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
        canvas.bind("<Configure>", lambda e: canvas.itemconfigure(canvas_window, width=e.width))
        canvas.configure(yscrollcommand=scrollbar.set)

        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        pad = {"padx": 10, "pady": 4}

        # --- OUTPUT SINK ---
        frame_out = ttk.LabelFrame(scrollable_frame, text="Output Speaker / Headset")
        frame_out.pack(fill="x", **pad)
        out_inner = ttk.Frame(frame_out)
        out_inner.pack(fill="x", padx=8, pady=6)

        self.sink_combo = ttk.Combobox(out_inner, textvariable=self.output_var, state="readonly", width=50)
        self.sink_combo.pack(side="left", fill="x", expand=True, padx=(0, 5))
        ttk.Button(out_inner, text="Refresh", command=self.refresh_sinks, width=12).pack(side="right")

        # --- VOCAL CONTROL ---
        vocal_frame = ttk.LabelFrame(scrollable_frame, text="Vocal Control")
        vocal_frame.pack(fill="x", **pad)

        mode_frame = ttk.Frame(vocal_frame)
        mode_frame.pack(fill="x", padx=8, pady=6)
        ttk.Label(mode_frame, text="Mode:", width=10, font=("Sans", 9, "bold")).pack(side="left")

        self.vocal_mode_var = tk.StringVar(value="normal")
        modes = [("Normal", "normal"), ("Remove", "remove"), ("Isolate", "isolate"), ("Enhance", "enhance")]
        for label, val in modes:
            ttk.Radiobutton(mode_frame, text=label, variable=self.vocal_mode_var, value=val, command=self.on_vocal_mode_change).pack(side="left", padx=5)

        strength_frame = ttk.Frame(vocal_frame)
        strength_frame.pack(fill="x", padx=8, pady=(0, 6))
        ttk.Label(strength_frame, text="Strength:", width=10, font=("Sans", 9, "bold")).pack(side="left")

        self.vocal_strength_var = tk.DoubleVar(value=1.0)
        self.vocal_strength_label = ttk.Label(strength_frame, width=8, text="100%")
        self.vocal_strength_label.pack(side="right")

        def on_strength_change(v):
            st = float(v)
            self.params["vocal_strength"] = st
            self.vocal_strength_label.config(text=f"{int(st * 100)}%")
            self.schedule_live_update()

        tk.Scale(
            strength_frame, from_=0.0, to=1.0, orient="horizontal", resolution=0.05,
            variable=self.vocal_strength_var, showvalue=False, command=on_strength_change
        ).pack(side="left", fill="x", expand=True, padx=5)

        # --- BASS & EQ CONTROL ---
        bass_frame = ttk.LabelFrame(scrollable_frame, text="Bass & EQ Control")
        bass_frame.pack(fill="x", **pad)

        self.add_slider(bass_frame, "Preamp", "preamp_db", -24.0, -4.0, 0.5, "dB")
        self.add_slider(bass_frame, "Bass Brutal", "bass_db", 0.0, 20.0, 0.5, "dB")
        self.add_slider(bass_frame, "Bass Freq", "bass_freq", 55.0, 120.0, 1.0, "Hz")
        self.add_slider(bass_frame, "Chest Punch", "chest_db", 0.0, 12.0, 0.5, "dB")
        self.add_slider(bass_frame, "Chest Freq", "chest_freq", 65.0, 90.0, 1.0, "Hz")
        self.add_slider(bass_frame, "Kick Attack", "kick_db", 0.0, 8.0, 0.5, "dB")
        self.add_slider(bass_frame, "Kick Freq", "kick_freq", 100.0, 130.0, 1.0, "Hz")
        self.add_slider(bass_frame, "Mud Cut", "mud_cut_db", -9.0, 0.0, 0.5, "dB")
        self.add_slider(bass_frame, "Rumble Cut", "rumble_freq", 25.0, 45.0, 1.0, "Hz")
        self.add_slider(bass_frame, "Clarity", "clarity_db", 0.0, 4.0, 0.5, "dB")
        self.add_slider(bass_frame, "Output", "output_db", -6.0, 3.0, 0.5, "dB")

        self.limiter_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(bass_frame, text="Limiter Anti-Clipping", variable=self.limiter_var, command=self.schedule_live_update).pack(anchor="w", padx=10, pady=4)

        # --- PRESETS ---
        preset_frame = ttk.LabelFrame(scrollable_frame, text="15 Presets")
        preset_frame.pack(fill="x", **pad)

        r1 = ttk.Frame(preset_frame)
        r1.pack(fill="x", padx=5, pady=2)
        for lbl, name in [("Flat", "flat"), ("Clean", "clean"), ("Nendang", "nendang"), ("Brutal", "brutal"), ("Max", "max")]:
            ttk.Button(r1, text=lbl, command=lambda n=name: self.apply_preset(n)).pack(side="left", expand=True, fill="x", padx=2)

        r2 = ttk.Frame(preset_frame)
        r2.pack(fill="x", padx=5, pady=2)
        for lbl, name in [("EDM", "edm"), ("Hip-Hop", "hiphop"), ("Rock", "rock"), ("Cinema", "cinema"), ("Gaming", "gaming")]:
            ttk.Button(r2, text=lbl, command=lambda n=name: self.apply_preset(n)).pack(side="left", expand=True, fill="x", padx=2)

        r3 = ttk.Frame(preset_frame)
        r3.pack(fill="x", padx=5, pady=2)
        for lbl, name in [("Jazz", "jazz"), ("Classical", "classical"), ("Podcast", "podcast"), ("Bass Test", "basstest"), ("Acoustic", "acoustic")]:
            ttk.Button(r3, text=lbl, command=lambda n=name: self.apply_preset(n)).pack(side="left", expand=True, fill="x", padx=2)

        # --- BOTTOM CONTROLS ---
        btn_box = ttk.Frame(scrollable_frame)
        btn_box.pack(fill="x", padx=10, pady=10)

        self.start_btn = ttk.Button(btn_box, text="START DSP", command=self.start)
        self.start_btn.pack(side="left", expand=True, fill="x", padx=(0, 5), ipady=5)

        self.stop_btn = ttk.Button(btn_box, text="STOP DSP", command=self.stop, state="disabled")
        self.stop_btn.pack(side="left", expand=True, fill="x", ipady=5)

        ttk.Label(scrollable_frame, textvariable=self.status_var, relief="sunken", anchor="w").pack(fill="x", padx=10, pady=(0, 10))

    def add_slider(self, parent, text, key, minimum, maximum, resolution, unit):
        row = ttk.Frame(parent)
        row.pack(fill="x", padx=8, pady=2)

        ttk.Label(row, text=text, width=12).pack(side="left")
        variable = tk.DoubleVar(value=self.params[key])
        self.vars[key] = variable
        self.units[key] = unit

        val_label = ttk.Label(row, width=8)
        val_label.pack(side="right")
        self.labels[key] = val_label

        def on_change(val):
            self.params[key] = float(val)
            self.update_label(key)
            self.schedule_live_update()

        tk.Scale(row, from_=minimum, to=maximum, orient="horizontal", resolution=resolution, variable=variable, showvalue=False, command=on_change).pack(side="left", fill="x", expand=True, padx=5)
        self.update_label(key)

    def update_label(self, key):
        val = self.params[key]
        unit = self.units[key]
        text = f"{val:.0f} Hz" if unit == "Hz" else f"{val:.1f} dB"
        self.labels[key].config(text=text)

    def on_vocal_mode_change(self):
        mode = self.vocal_mode_var.get()
        self.params["vocal_mode"] = mode
        if self.running:
            self.schedule_live_update()

    def schedule_live_update(self):
        self.params["limiter"] = bool(self.limiter_var.get())
        if not self.running:
            return
        if self.update_timer:
            try:
                self.after_cancel(self.update_timer)
            except Exception:
                pass
        self.update_timer = self.after(LIVE_UPDATE_DELAY_MSEC, self.apply_live_update)

    def apply_live_update(self):
        self.update_timer = None
        if not self.running or not self.worker:
            return
        try:
            new_filter = build_ffmpeg_filter(self.params)
            self.worker.update_filter(new_filter)
        except Exception as e:
            self.status_var.set(f"Gagal update filter: {e}")

    def apply_preset(self, name):
        presets = {
            "flat": {"preamp_db": -4.0, "bass_db": 0.0, "bass_freq": 85.0, "chest_db": 0.0, "chest_freq": 78.0, "kick_db": 0.0, "kick_freq": 115.0, "mud_cut_db": 0.0, "rumble_freq": 25.0, "clarity_db": 0.0, "output_db": 0.0, "limiter": True},
            "clean": {"preamp_db": -9.0, "bass_db": 7.0, "bass_freq": 88.0, "chest_db": 4.0, "chest_freq": 78.0, "kick_db": 2.0, "kick_freq": 115.0, "mud_cut_db": -3.5, "rumble_freq": 32.0, "clarity_db": 1.0, "output_db": 0.0, "limiter": True},
            "nendang": {"preamp_db": -13.0, "bass_db": 12.0, "bass_freq": 84.0, "chest_db": 6.5, "chest_freq": 77.0, "kick_db": 3.0, "kick_freq": 116.0, "mud_cut_db": -4.5, "rumble_freq": 32.0, "clarity_db": 1.0, "output_db": 0.0, "limiter": True},
            "brutal": {"preamp_db": -16.0, "bass_db": 15.0, "bass_freq": 82.0, "chest_db": 8.0, "chest_freq": 76.0, "kick_db": 4.0, "kick_freq": 116.0, "mud_cut_db": -5.0, "rumble_freq": 32.0, "clarity_db": 1.0, "output_db": 0.0, "limiter": True},
            "max": {"preamp_db": -19.0, "bass_db": 18.0, "bass_freq": 80.0, "chest_db": 10.0, "chest_freq": 75.0, "kick_db": 5.0, "kick_freq": 118.0, "mud_cut_db": -6.0, "rumble_freq": 35.0, "clarity_db": 1.5, "output_db": 0.0, "limiter": True},
            "edm": {"preamp_db": -17.0, "bass_db": 16.0, "bass_freq": 65.0, "chest_db": 9.0, "chest_freq": 75.0, "kick_db": 5.5, "kick_freq": 120.0, "mud_cut_db": -6.5, "rumble_freq": 28.0, "clarity_db": 2.5, "output_db": 0.0, "limiter": True},
            "hiphop": {"preamp_db": -15.0, "bass_db": 14.0, "bass_freq": 70.0, "chest_db": 10.0, "chest_freq": 85.0, "kick_db": 3.5, "kick_freq": 110.0, "mud_cut_db": -5.5, "rumble_freq": 30.0, "clarity_db": 2.0, "output_db": 0.0, "limiter": True},
            "rock": {"preamp_db": -12.0, "bass_db": 10.0, "bass_freq": 95.0, "chest_db": 7.0, "chest_freq": 78.0, "kick_db": 6.0, "kick_freq": 115.0, "mud_cut_db": -4.0, "rumble_freq": 35.0, "clarity_db": 3.0, "output_db": 0.0, "limiter": True},
            "cinema": {"preamp_db": -11.0, "bass_db": 11.0, "bass_freq": 75.0, "chest_db": 5.0, "chest_freq": 85.0, "kick_db": 4.5, "kick_freq": 110.0, "mud_cut_db": -3.0, "rumble_freq": 25.0, "clarity_db": 2.5, "output_db": 0.5, "limiter": True},
            "gaming": {"preamp_db": -14.0, "bass_db": 13.0, "bass_freq": 80.0, "chest_db": 6.0, "chest_freq": 90.0, "kick_db": 5.0, "kick_freq": 120.0, "mud_cut_db": -4.5, "rumble_freq": 32.0, "clarity_db": 3.5, "output_db": 0.0, "limiter": True},
            "jazz": {"preamp_db": -8.0, "bass_db": 6.0, "bass_freq": 92.0, "chest_db": 5.0, "chest_freq": 80.0, "kick_db": 1.5, "kick_freq": 110.0, "mud_cut_db": -2.5, "rumble_freq": 35.0, "clarity_db": 2.5, "output_db": 0.0, "limiter": True},
            "classical": {"preamp_db": -6.0, "bass_db": 4.0, "bass_freq": 90.0, "chest_db": 2.0, "chest_freq": 85.0, "kick_db": 0.5, "kick_freq": 105.0, "mud_cut_db": -1.5, "rumble_freq": 30.0, "clarity_db": 3.0, "output_db": 0.0, "limiter": True},
            "podcast": {"preamp_db": -7.0, "bass_db": 2.0, "bass_freq": 100.0, "chest_db": 8.0, "chest_freq": 88.0, "kick_db": 0.0, "kick_freq": 115.0, "mud_cut_db": -6.0, "rumble_freq": 40.0, "clarity_db": 3.5, "output_db": 1.0, "limiter": True},
            "basstest": {"preamp_db": -21.0, "bass_db": 20.0, "bass_freq": 60.0, "chest_db": 12.0, "chest_freq": 70.0, "kick_db": 8.0, "kick_freq": 125.0, "mud_cut_db": -7.0, "rumble_freq": 25.0, "clarity_db": 0.5, "output_db": -1.0, "limiter": True},
            "acoustic": {"preamp_db": -8.5, "bass_db": 5.0, "bass_freq": 95.0, "chest_db": 3.5, "chest_freq": 82.0, "kick_db": 1.5, "kick_freq": 112.0, "mud_cut_db": -2.0, "rumble_freq": 32.0, "clarity_db": 3.5, "output_db": 0.0, "limiter": True},
        }
        if name not in presets:
            return
        data = presets[name]
        for key, value in data.items():
            self.params[key] = value
            if key == "limiter":
                self.limiter_var.set(bool(value))
            elif key in self.vars:
                self.vars[key].set(float(value))
                self.update_label(key)

        if self.running:
            self.schedule_live_update()
            self.status_var.set(f"Preset {name.upper()} diterapkan.")
        else:
            self.status_var.set(f"Preset {name.upper()} aktif.")

    def refresh_sinks(self):
        try:
            sinks = list_sinks()
            self.sink_combo["values"] = sinks
            default_sink = get_default_sink()
            if default_sink in sinks:
                self.output_var.set(default_sink)
            elif sinks:
                self.output_var.set(sinks[0])
            else:
                self.output_var.set("")
            self.status_var.set(f"{len(sinks)} output ditemukan.")
        except Exception as e:
            messagebox.showerror("Error Audio", str(e))

    def check_requirements(self):
        missing = [cmd for cmd in ["pactl", "ffmpeg"] if not have_cmd(cmd)]
        if missing:
            raise RuntimeError(f"Command tidak ditemukan: {', '.join(missing)}")
        if not ffmpeg_has_pulse_io():
            raise RuntimeError("FFmpeg tidak memiliki dukungan PulseAudio input/output.")

    def start(self):
        if self.running:
            return
        try:
            self.check_requirements()
            output_sink = self.output_var.get().strip()
            if not output_sink:
                raise RuntimeError("Pilih output speaker/headset asli.")
            if output_sink.startswith("pybass_"):
                raise RuntimeError("Jangan pilih virtual sink PyBass sebagai output.")

            self.output_sink = output_sink
            cleanup_old_pybass(self.output_sink)

            self.virtual_sink = f"pybass_direct_{os.getpid()}"
            load_result = pactl([
                "load-module", "module-null-sink", f"sink_name={self.virtual_sink}",
                f"rate={RATE}", f"channels={CHANNELS}", "channel_map=front-left,front-right",
                "sink_properties=device.description=PyBass_Direct_LowLatency"
            ])

            self.module_id = load_result.stdout.strip()
            if not self.module_id:
                raise RuntimeError("Gagal membuat virtual sink PyBass.")

            time.sleep(0.20)
            pactl(["set-default-sink", self.virtual_sink])
            move_all_sink_inputs(self.virtual_sink)
            time.sleep(0.08)

            ff_filter = build_ffmpeg_filter(self.params)
            self.worker = PipelineWorker(self.virtual_sink, self.output_sink, ff_filter, self.msg_queue)
            self.worker.start()

            self.running = True
            self.start_btn.config(state="disabled")
            self.stop_btn.config(state="normal")
            self.status_var.set("Memulai DSP direct low latency...")
        except Exception as e:
            self.cleanup_audio()
            self.running = False
            self.start_btn.config(state="normal")
            self.stop_btn.config(state="disabled")
            messagebox.showerror("Gagal Start Audio DSP", str(e))

    def stop(self):
        if not self.running and not self.module_id:
            return

        if self.update_timer:
            try:
                self.after_cancel(self.update_timer)
            except Exception:
                pass
            self.update_timer = None

        if self.worker:
            self.worker.stop()
            self.worker.join(timeout=2.0)
            self.worker = None

        self.cleanup_audio()
        self.running = False
        self.start_btn.config(state="normal")
        self.stop_btn.config(state="disabled")
        self.status_var.set("Stop. Audio dikembalikan ke output asli.")

    def cleanup_audio(self):
        if self.output_sink:
            try:
                pactl(["set-default-sink", self.output_sink], check=False)
                move_all_sink_inputs(self.output_sink)
                time.sleep(0.05)
            except Exception:
                pass

        if self.module_id:
            try:
                pactl(["unload-module", self.module_id], check=False)
            except Exception:
                pass

        self.module_id = None
        self.virtual_sink = None

    def poll_messages(self):
        try:
            while True:
                msg_type, msg = self.msg_queue.get_nowait()
                if msg_type == "status":
                    self.status_var.set(msg)
                elif msg_type == "error":
                    messagebox.showerror("DSP Error", msg)
                    self.stop()
        except queue.Empty:
            pass
        self.after(200, self.poll_messages)


# ============================================================
# MAIN APPLICATION WINDOW
# ============================================================
class UnifiedUtilityApp:
    def __init__(self, root):
        self.root = root
        self.root.title("System & Android Utility Suite By Fi'i")
        self.root.geometry("850x850")
        self.root.minsize(750, 600)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill="both", expand=True)

        self.tab_mirror = MirrorTab(notebook)
        notebook.add(self.tab_mirror, text=" Screen Mirror & Record ")

        self.tab_reverse = ReverseNetTab(notebook)
        notebook.add(self.tab_reverse, text=" Reverse Internet ")

        self.tab_audio = AudioDSPTab(notebook)
        notebook.add(self.tab_audio, text=" Audio DSP & Vocal ")

        self.root.protocol("WM_DELETE_WINDOW", self.on_close)

    def on_close(self):
        self.tab_audio.stop()
        self.tab_reverse.stop_internet()
        self.root.destroy()


def main():
    root = tk.Tk()
    UnifiedUtilityApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
  1. Buka terminal di folder tempat file berada, lalu jalankan perintah:
chmod +x android_suite.py
python3 android_suite.py

3. Panduan Penggunaan Fitur

  • Persiapan Awal: Aktifkan USB Debugging di menu Opsi Pengembang (Developer Options) pada HP Android Anda, lalu sambungkan ke PC via kabel USB.
  • Tab Screen Mirror & Record: Pilih ID perangkat pada menu drop-down. Jika ingin merekam video layar saat mirroring, centang opsi Aktifkan Perekaman Layar, tentukan folder simpan, lalu klik MIRROR / REKAM LAYAR SEKARANG.
  • Tab Reverse Internet: Klik Deteksi Ulang untuk memastikan lokasi binary gnirehtet terbaca. Tekan tombol AKTIFKAN AUTO RECONNECT agar internet PC langsung mengalir ke HP setiap kali kabel USB dicolok.
  • Tab Audio DSP & Vocal: Pilih perangkat Output Speaker/Headset Anda, tentukan mode vokal atau preset equalizer yang diinginkan (misal: EDM, Brutal, atau Podcast), lalu tekan START DSP.

Tips Tambahan untuk Blog: Tambahkan tangkapan layar (screenshot) dari tiap tab aplikasi saat dipublikasikan di Blogger agar artikel Anda makin menarik dan mudah dipahami pembaca!

DOWNLOAD FILE JADI Download file terbaru + browser DOWNLOAD
Developer
Roffi
Tutorial Linux & tempat download Aplikasi Android open-source premium. Deployed via Nero Engine.

User Reviews & Discussion

Tidak ada komentar:

Posting Komentar