terminalphone/terminalphone.sh
2026-02-19 17:47:10 +00:00

2180 lines
75 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# TerminalPhone — Encrypted Push-to-Talk Voice over Tor
# A walkie-talkie style voice chat using Tor hidden services
# License: MIT
set -euo pipefail
#=============================================================================
# CONFIGURATION
#=============================================================================
APP_NAME="TerminalPhone"
VERSION="1.0.7"
BASE_DIR="$(dirname "$(readlink -f "$0")")"
DATA_DIR="$BASE_DIR/.terminalphone"
TOR_DIR="$DATA_DIR/tor_data"
TOR_CONF="$DATA_DIR/torrc"
ONION_FILE="$TOR_DIR/hidden_service/hostname"
SECRET_FILE="$DATA_DIR/shared_secret"
CONFIG_FILE="$DATA_DIR/config"
AUDIO_DIR="$DATA_DIR/audio"
PID_DIR="$DATA_DIR/pids"
PTT_FLAG="$DATA_DIR/run/ptt_$$"
CONNECTED_FLAG="$DATA_DIR/run/connected_$$"
RECV_PIPE="$DATA_DIR/run/recv_$$"
SEND_PIPE="$DATA_DIR/run/send_$$"
CIPHER_RUNTIME_FILE="$DATA_DIR/run/cipher_$$"
AUTO_LISTEN_FLAG="$DATA_DIR/run/autolisten_$$"
AUTO_LISTEN_PID=""
# Defaults
LISTEN_PORT=7777
TOR_SOCKS_PORT=9050
OPUS_BITRATE=16 # kbps — good balance of quality and bandwidth for Tor
OPUS_FRAMESIZE=60 # ms
SAMPLE_RATE=8000 # Hz
PTT_KEY=" " # spacebar
CHUNK_DURATION=1 # seconds per audio chunk
CIPHER="aes-256-cbc" # OpenSSL cipher for encryption
SNOWFLAKE_ENABLED=0 # Snowflake bridge (off by default)
AUTO_LISTEN=0 # Auto-listen after Tor starts (off by default)
VOICE_EFFECT="none" # Voice effect (none, deep, high, robot, echo, whisper, custom)
# Custom voice effect parameters (used when VOICE_EFFECT=custom)
VOICE_PITCH=0 # Pitch shift in cents (-600 to +600, 0=off)
VOICE_OVERDRIVE=0 # Overdrive gain (0=off, 5-20)
VOICE_FLANGER=0 # Flanger (0=off, 1=on)
VOICE_ECHO_DELAY=0 # Echo delay in ms (0=off, 20-200)
VOICE_ECHO_DECAY=5 # Echo decay (1-9 → 0.1-0.9)
VOICE_HIGHPASS=0 # Highpass filter freq in Hz (0=off, 300-2000)
VOICE_TREMOLO=0 # Tremolo speed in Hz (0=off, 5-40)
# ANSI Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
WHITE='\033[1;37m'
DIM='\033[2m'
BOLD='\033[1m'
BLINK='\033[5m'
NC='\033[0m' # No Color
BG_RED='\033[41m'
BG_GREEN='\033[42m'
TOR_PURPLE='\033[38;2;125;70;152m'
# Platform detection
IS_TERMUX=0
if [ -n "${TERMUX_VERSION:-}" ] || { [ -n "${PREFIX:-}" ] && [[ "${PREFIX:-}" == *"com.termux"* ]]; }; then
IS_TERMUX=1
fi
# State
TOR_PID=""
CALL_ACTIVE=0
ORIGINAL_STTY=""
#=============================================================================
# HELPERS
#=============================================================================
cleanup() {
# Restore terminal
if [ -n "$ORIGINAL_STTY" ]; then
stty "$ORIGINAL_STTY" 2>/dev/null || true
fi
stty sane 2>/dev/null || true
# Kill background processes
kill_bg_processes
# Remove temp files
rm -f "$PTT_FLAG" "$CONNECTED_FLAG" "$RECV_PIPE" "$SEND_PIPE"
rm -rf "$AUDIO_DIR" 2>/dev/null || true
echo -e "\n${GREEN}${APP_NAME} shut down cleanly.${NC}"
}
kill_bg_processes() {
# Kill any child processes
local pids
pids=$(jobs -p 2>/dev/null) || true
if [ -n "$pids" ]; then
kill $pids 2>/dev/null || true
wait $pids 2>/dev/null || true
fi
# Kill stored PIDs
if [ -d "$PID_DIR" ]; then
for pidfile in "$PID_DIR"/*.pid; do
[ -f "$pidfile" ] || continue
local pid
pid=$(cat "$pidfile" 2>/dev/null) || continue
kill "$pid" 2>/dev/null || true
done
rm -f "$PID_DIR"/*.pid 2>/dev/null || true
fi
# Kill our Tor instance if running
if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then
kill "$TOR_PID" 2>/dev/null || true
fi
}
save_pid() {
local name="$1" pid="$2"
mkdir -p "$PID_DIR"
echo "$pid" > "$PID_DIR/${name}.pid"
}
log_info() {
echo -e "${CYAN}[INFO]${NC} $1"
}
log_ok() {
echo -e "${GREEN}[ OK]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_err() {
echo -e "${RED}[FAIL]${NC} $1"
}
uid() {
echo "$(date +%s%N 2>/dev/null || date +%s)_${RANDOM}"
}
load_config() {
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
fi
if [ -f "$SECRET_FILE" ]; then
SHARED_SECRET=$(cat "$SECRET_FILE")
else
SHARED_SECRET=""
fi
}
save_config() {
mkdir -p "$DATA_DIR"
cat > "$CONFIG_FILE" << EOF
LISTEN_PORT=$LISTEN_PORT
TOR_SOCKS_PORT=$TOR_SOCKS_PORT
OPUS_BITRATE=$OPUS_BITRATE
OPUS_FRAMESIZE=$OPUS_FRAMESIZE
PTT_KEY="$PTT_KEY"
CIPHER="$CIPHER"
SNOWFLAKE_ENABLED=$SNOWFLAKE_ENABLED
AUTO_LISTEN=$AUTO_LISTEN
VOICE_EFFECT="$VOICE_EFFECT"
VOICE_PITCH=$VOICE_PITCH
VOICE_OVERDRIVE=$VOICE_OVERDRIVE
VOICE_FLANGER=$VOICE_FLANGER
VOICE_ECHO_DELAY=$VOICE_ECHO_DELAY
VOICE_ECHO_DECAY=$VOICE_ECHO_DECAY
VOICE_HIGHPASS=$VOICE_HIGHPASS
VOICE_TREMOLO=$VOICE_TREMOLO
EOF
}
#=============================================================================
# DEPENDENCY INSTALLER
#=============================================================================
check_dep() {
command -v "$1" &>/dev/null
}
install_deps() {
echo -e "\n${BOLD}${CYAN}═══ Dependency Installer ═══${NC}\n"
local deps_needed=()
local all_deps
local pkg_names_apt="tor opus-tools sox socat openssl alsa-utils"
local pkg_names_dnf="tor opus-tools sox socat openssl alsa-utils"
local pkg_names_pacman="tor opus-tools sox socat openssl alsa-utils"
local pkg_names_pkg="tor opus-tools sox socat openssl-tool ffmpeg termux-api"
# Shared deps + platform-specific
if [ $IS_TERMUX -eq 1 ]; then
all_deps=(tor opusenc opusdec sox socat openssl ffmpeg termux-microphone-record)
else
all_deps=(tor opusenc opusdec sox socat openssl arecord aplay)
fi
# Check which deps are missing
for dep in "${all_deps[@]}"; do
if check_dep "$dep"; then
log_ok "$dep found"
else
deps_needed+=("$dep")
log_warn "$dep NOT found"
fi
done
if [ ${#deps_needed[@]} -eq 0 ]; then
echo -e "\n${GREEN}All dependencies are installed!${NC}"
return 0
fi
echo -e "\n${YELLOW}Missing dependencies: ${deps_needed[*]}${NC}"
echo -e "Attempting to install...\n"
# Use sudo only if available and not on Termux
local SUDO="sudo"
if [ $IS_TERMUX -eq 1 ]; then
SUDO=""
log_info "Termux detected — installing without sudo"
elif ! check_dep sudo; then
SUDO=""
fi
# Detect package manager and install
if [ $IS_TERMUX -eq 1 ]; then
log_info "Detected Termux"
log_info "Upgrading existing packages first..."
pkg upgrade -y
pkg install -y $pkg_names_pkg
echo -e "\n${YELLOW}${BOLD}NOTE:${NC} You must also install the ${BOLD}Termux:API${NC} app from F-Droid"
echo -e " for microphone access.\n"
elif check_dep apt-get; then
log_info "Detected apt package manager"
$SUDO apt-get update -qq
$SUDO apt-get install -y $pkg_names_apt
elif check_dep dnf; then
log_info "Detected dnf package manager"
$SUDO dnf install -y $pkg_names_dnf
elif check_dep pacman; then
log_info "Detected pacman package manager"
$SUDO pacman -S --noconfirm $pkg_names_pacman
else
log_err "No supported package manager found!"
log_err "Please install manually: tor, opus-tools, sox, socat, openssl, alsa-utils"
return 1
fi
# Verify
echo -e "\n${BOLD}Verifying installation...${NC}"
local failed=0
for dep in "${all_deps[@]}"; do
if check_dep "$dep"; then
log_ok "$dep"
else
log_err "$dep still missing!"
failed=1
fi
done
if [ $failed -eq 0 ]; then
echo -e "\n${GREEN}${BOLD}All dependencies installed successfully!${NC}"
else
echo -e "\n${RED}Some dependencies could not be installed.${NC}"
return 1
fi
}
#=============================================================================
# TOR HIDDEN SERVICE
#=============================================================================
setup_tor() {
mkdir -p "$TOR_DIR/hidden_service"
chmod 700 "$TOR_DIR/hidden_service"
cat > "$TOR_CONF" << EOF
SocksPort $TOR_SOCKS_PORT
DataDirectory $TOR_DIR/data
HiddenServiceDir $TOR_DIR/hidden_service
HiddenServicePort $LISTEN_PORT 127.0.0.1:$LISTEN_PORT
Log notice file $TOR_DIR/tor.log
EOF
# Append snowflake bridge config if enabled
if [ "$SNOWFLAKE_ENABLED" -eq 1 ] && check_dep snowflake-client; then
local sf_bin
sf_bin=$(command -v snowflake-client)
cat >> "$TOR_CONF" << EOF
UseBridges 1
ClientTransportPlugin snowflake exec $sf_bin
Bridge snowflake 192.0.2.3:80 2B280B23E1107BB62ABFC40DDCC8824814F80A72 fingerprint=2B280B23E1107BB62ABFC40DDCC8824814F80A72 url=https://snowflake-broker.torproject.net/ ice=stun:stun.l.google.com:19302,stun:stun.voip.blackberry.com:3478
EOF
log_info "Snowflake bridge enabled in torrc"
elif [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
log_warn "Snowflake enabled but snowflake-client not found — skipping bridge"
fi
chmod 600 "$TOR_CONF"
}
install_snowflake() {
if check_dep snowflake-client; then
log_ok "snowflake-client already installed"
return 0
fi
log_info "Installing snowflake-client..."
local SUDO="sudo"
if [ $IS_TERMUX -eq 1 ]; then
SUDO=""
elif ! check_dep sudo; then
SUDO=""
fi
if [ $IS_TERMUX -eq 1 ]; then
pkg install -y snowflake-client
elif check_dep apt-get; then
$SUDO apt-get update -qq
$SUDO apt-get install -y snowflake-client
elif check_dep dnf; then
$SUDO dnf install -y snowflake-client
elif check_dep pacman; then
$SUDO pacman -S --noconfirm snowflake-pt-client
else
log_err "No supported package manager found. Install snowflake-client manually."
return 1
fi
if check_dep snowflake-client; then
log_ok "snowflake-client installed successfully"
else
log_err "snowflake-client installation failed"
return 1
fi
}
start_tor() {
if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then
log_info "Tor is already running (PID $TOR_PID)"
return 0
fi
setup_tor
# Clear old log so we only see fresh output
local tor_log="$TOR_DIR/tor.log"
> "$tor_log"
log_info "Starting Tor..."
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
echo -e " ${DIM}Snowflake bridge enabled — this may take longer than usual, please be patient.${NC}"
fi
tor -f "$TOR_CONF" &>/dev/null &
TOR_PID=$!
save_pid "tor" "$TOR_PID"
# Monitor bootstrap progress from the log
local waited=0
local timeout=120
local last_pct=""
while [ $waited -lt $timeout ]; do
# Check if Tor is still running
if ! kill -0 "$TOR_PID" 2>/dev/null; then
echo ""
log_err "Tor process died! Check $tor_log"
[ -f "$tor_log" ] && tail -5 "$tor_log" 2>/dev/null
return 1
fi
# Parse latest bootstrap line from log
local bootstrap_line=""
bootstrap_line=$(grep -o "Bootstrapped [0-9]*%.*" "$tor_log" 2>/dev/null | tail -1 || true)
if [ -n "$bootstrap_line" ]; then
local pct=""
pct=$(echo "$bootstrap_line" | grep -o '[0-9]*%' || true)
if [ -n "$pct" ] && [ "$pct" != "$last_pct" ]; then
echo -ne "\r ${DIM}${bootstrap_line}${NC} "
last_pct="$pct"
fi
# Check for 100%
if [[ "$bootstrap_line" == *"100%"* ]]; then
echo ""
break
fi
else
# No bootstrap line yet, show waiting indicator
echo -ne "\r ${DIM}Waiting for Tor to start...${NC} "
fi
sleep 1
waited=$((waited + 1))
done
if [ $waited -ge $timeout ]; then
echo ""
log_err "Timed out waiting for Tor to bootstrap ($timeout seconds)"
return 1
fi
# Wait for onion address file (should appear quickly after 100%)
local addr_wait=0
while [ ! -f "$ONION_FILE" ] && [ $addr_wait -lt 15 ]; do
sleep 1
addr_wait=$((addr_wait + 1))
done
if [ -f "$ONION_FILE" ]; then
local onion
onion=$(cat "$ONION_FILE")
log_ok "Tor hidden service active"
echo -e " ${BOLD}${GREEN}Your address: ${WHITE}${onion}${NC}"
return 0
else
log_err "Tor bootstrapped but hidden service address not found"
return 1
fi
}
stop_tor() {
stop_auto_listener
if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then
kill "$TOR_PID" 2>/dev/null || true
wait "$TOR_PID" 2>/dev/null || true
TOR_PID=""
log_ok "Tor stopped"
else
log_info "Tor is not running"
fi
}
get_onion() {
if [ -f "$ONION_FILE" ]; then
cat "$ONION_FILE"
else
echo ""
fi
}
rotate_onion() {
echo -e "\n${BOLD}${CYAN}═══ Rotate Onion Address ═══${NC}\n"
local old_onion
old_onion=$(get_onion)
if [ -n "$old_onion" ]; then
echo -e " ${DIM}Current: ${old_onion}${NC}"
fi
echo -e " ${YELLOW}This will generate a new .onion address.${NC}"
echo -e " ${YELLOW}The old address will stop working.${NC}\n"
echo -ne " ${BOLD}Continue? [y/N]: ${NC}"
read -r confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
log_info "Cancelled"
return
fi
stop_tor
rm -rf "$TOR_DIR/hidden_service"
log_info "Old hidden service keys deleted"
start_tor
}
#=============================================================================
# ENCRYPTION
#=============================================================================
set_shared_secret() {
echo -e "\n${BOLD}${CYAN}═══ Set Shared Secret ═══${NC}\n"
echo -e "${DIM}Both parties must use the same secret for the call to work.${NC}"
echo -e "${DIM}Share this secret securely (in person, via encrypted message, etc.)${NC}\n"
if [ -n "$SHARED_SECRET" ]; then
echo -e "Current secret: ${DIM}(set)${NC}"
else
echo -e "Current secret: ${DIM}(none)${NC}"
fi
echo -ne "\n${BOLD}Enter shared secret: ${NC}"
read -r new_secret
if [ -z "$new_secret" ]; then
log_warn "Secret not changed"
return
fi
SHARED_SECRET="$new_secret"
mkdir -p "$DATA_DIR"
echo "$SHARED_SECRET" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
log_ok "Shared secret saved"
}
# Encrypt a file
encrypt_file() {
local infile="$1" outfile="$2"
local c="$CIPHER"
[ -f "$CIPHER_RUNTIME_FILE" ] && c=$(cat "$CIPHER_RUNTIME_FILE")
openssl enc -"${c}" -pbkdf2 -iter 10000 -pass "pass:${SHARED_SECRET}" \
-in "$infile" -out "$outfile" 2>/dev/null
}
# Decrypt a file
decrypt_file() {
local infile="$1" outfile="$2"
local c="$CIPHER"
[ -f "$CIPHER_RUNTIME_FILE" ] && c=$(cat "$CIPHER_RUNTIME_FILE")
openssl enc -d -"${c}" -pbkdf2 -iter 10000 -pass "pass:${SHARED_SECRET}" \
-in "$infile" -out "$outfile" 2>/dev/null
}
#=============================================================================
# AUDIO PIPELINE
#=============================================================================
# Record a timed chunk of raw audio (used by audio test)
audio_record() {
local outfile="$1"
local duration="${2:-$CHUNK_DURATION}"
if [ $IS_TERMUX -eq 1 ]; then
local tmp_rec="$AUDIO_DIR/tmrec_$(uid).m4a"
rm -f "$tmp_rec"
termux-microphone-record -l "$((duration + 1))" -f "$tmp_rec" &>/dev/null
sleep "$duration"
termux-microphone-record -q &>/dev/null || true
sleep 0.5
if [ -s "$tmp_rec" ]; then
ffmpeg -y -i "$tmp_rec" -f s16le -ar "$SAMPLE_RATE" -ac 1 \
"$outfile" &>/dev/null || log_warn "ffmpeg conversion failed"
fi
rm -f "$tmp_rec"
else
arecord -f S16_LE -r "$SAMPLE_RATE" -c 1 -t raw -d "$duration" \
-q "$outfile" 2>/dev/null
fi
}
# Start continuous recording in background (returns immediately)
# Sets REC_PID and REC_FILE globals
start_recording() {
local _id=$(uid)
if [ $IS_TERMUX -eq 1 ]; then
REC_FILE="$AUDIO_DIR/msg_${_id}.m4a"
rm -f "$REC_FILE"
termux-microphone-record -l 120 -f "$REC_FILE" &>/dev/null &
REC_PID=$!
else
REC_FILE="$AUDIO_DIR/msg_${_id}.raw"
arecord -f S16_LE -r "$SAMPLE_RATE" -c 1 -t raw -q "$REC_FILE" 2>/dev/null &
REC_PID=$!
fi
}
# Apply voice effect to raw PCM using sox
apply_voice_effect() {
local infile="$1"
local outfile="$2"
local fmt="-t raw -r $SAMPLE_RATE -e signed -b 16 -c 1"
case "$VOICE_EFFECT" in
deep)
sox $fmt "$infile" $fmt "$outfile" pitch -400 2>/dev/null
;;
high)
sox $fmt "$infile" $fmt "$outfile" pitch 500 2>/dev/null
;;
robot)
sox $fmt "$infile" $fmt "$outfile" overdrive 10 flanger 2>/dev/null
;;
echo)
sox $fmt "$infile" $fmt "$outfile" echo 0.8 0.88 60 0.4 2>/dev/null
;;
whisper)
sox $fmt "$infile" $fmt "$outfile" highpass 1000 tremolo 20 2>/dev/null
;;
custom)
# Build sox effects chain from individual parameters
local effects=""
[ "$VOICE_PITCH" -ne 0 ] 2>/dev/null && effects="$effects pitch $VOICE_PITCH"
[ "$VOICE_OVERDRIVE" -gt 0 ] 2>/dev/null && effects="$effects overdrive $VOICE_OVERDRIVE"
[ "$VOICE_FLANGER" -eq 1 ] 2>/dev/null && effects="$effects flanger"
[ "$VOICE_ECHO_DELAY" -gt 0 ] 2>/dev/null && effects="$effects echo 0.8 0.88 $VOICE_ECHO_DELAY 0.${VOICE_ECHO_DECAY}"
[ "$VOICE_HIGHPASS" -gt 0 ] 2>/dev/null && effects="$effects highpass $VOICE_HIGHPASS"
[ "$VOICE_TREMOLO" -gt 0 ] 2>/dev/null && effects="$effects tremolo $VOICE_TREMOLO"
if [ -n "$effects" ]; then
sox $fmt "$infile" $fmt "$outfile" $effects 2>/dev/null
else
return 1 # no effects configured
fi
;;
*)
return 1 # no effect
;;
esac
}
# Stop recording and send the message
# Encodes, encrypts, base64-encodes, and writes to fd 4
stop_and_send() {
local _id=$(uid)
local raw_file="$AUDIO_DIR/tx_${_id}.raw"
local opus_file="$AUDIO_DIR/tx_${_id}.opus"
local enc_file="$AUDIO_DIR/tx_enc_${_id}.opus"
# Stop the recording
if [ $IS_TERMUX -eq 1 ]; then
termux-microphone-record -q &>/dev/null || true
kill "$REC_PID" 2>/dev/null || true
wait "$REC_PID" 2>/dev/null || true
sleep 0.3 # let file flush
# Convert m4a → raw PCM
if [ -s "$REC_FILE" ]; then
ffmpeg -y -i "$REC_FILE" -f s16le -ar "$SAMPLE_RATE" -ac 1 \
"$raw_file" &>/dev/null || true
fi
rm -f "$REC_FILE"
else
kill "$REC_PID" 2>/dev/null || true
wait "$REC_PID" 2>/dev/null || true
raw_file="$REC_FILE" # already in raw format
fi
REC_PID=""
REC_FILE=""
# Apply voice effect if set
if [ -s "$raw_file" ] && [ "$VOICE_EFFECT" != "none" ]; then
local fx_file="$AUDIO_DIR/tx_fx_${_id}.raw"
if apply_voice_effect "$raw_file" "$fx_file"; then
mv "$fx_file" "$raw_file"
else
rm -f "$fx_file" 2>/dev/null
fi
fi
# Encode → encrypt → send
if [ -s "$raw_file" ]; then
opusenc --raw --raw-rate "$SAMPLE_RATE" --raw-chan 1 \
--bitrate "$OPUS_BITRATE" --framesize "$OPUS_FRAMESIZE" \
--speech --quiet \
"$raw_file" "$opus_file" 2>/dev/null
if [ -s "$opus_file" ]; then
encrypt_file "$opus_file" "$enc_file" 2>/dev/null
if [ -s "$enc_file" ]; then
local enc_size
enc_size=$(stat -c%s "$enc_file" 2>/dev/null || echo 0)
local size_kb=$(( enc_size * 10 / 1024 ))
local size_whole=$(( size_kb / 10 ))
local size_frac=$(( size_kb % 10 ))
local b64
b64=$(base64 -w 0 "$enc_file" 2>/dev/null)
echo "AUDIO:${b64}" >&4 2>/dev/null || true
LAST_SENT_INFO="${size_whole}.${size_frac}KB"
fi
fi
fi
rm -f "$raw_file" "$opus_file" "$enc_file" 2>/dev/null
}
# Play audio (platform-aware)
audio_play() {
local infile="$1"
local rate="${2:-48000}"
if [ $IS_TERMUX -eq 1 ]; then
# Termux: use Android's native media player
termux-media-player play "$infile" &>/dev/null || true
# Wait for playback to finish
while termux-media-player info 2>/dev/null | grep -q "playing"; do
sleep 0.5
done
else
# Linux: use ALSA aplay
aplay -f S16_LE -r "$rate" -c 1 -q "$infile" 2>/dev/null
fi
}
# Play an opus file
play_chunk() {
local opus_file="$1"
if [ $IS_TERMUX -eq 1 ]; then
# Termux: decode to wav, play via Android media player
local wav_file="$AUDIO_DIR/play_$(uid).wav"
opusdec --quiet "$opus_file" "$wav_file" 2>/dev/null || true
if [ -s "$wav_file" ]; then
audio_play "$wav_file"
fi
rm -f "$wav_file"
else
# Linux: pipe decode directly to aplay
opusdec --quiet --rate 48000 "$opus_file" - 2>/dev/null | \
aplay -f S16_LE -r 48000 -c 1 -q 2>/dev/null || true
fi
}
#=============================================================================
# CALL CLEANUP — RESET EVERYTHING TO FRESH STATE
#=============================================================================
cleanup_call() {
# Restore terminal to sane state
if [ -n "$ORIGINAL_STTY" ]; then
stty "$ORIGINAL_STTY" 2>/dev/null || true
fi
stty sane 2>/dev/null || true
ORIGINAL_STTY=""
# Close pipe file descriptors to unblock any blocking reads
# NOTE: must use { } group so 2>/dev/null doesn't permanently redirect stderr
{ exec 3<&-; } 2>/dev/null || true
{ exec 4>&-; } 2>/dev/null || true
# Kill all call-related processes by PID files
for pidfile in "$PID_DIR"/socat.pid "$PID_DIR"/socat_call.pid "$PID_DIR"/recv_loop.pid; do
if [ -f "$pidfile" ]; then
local pid
pid=$(cat "$pidfile" 2>/dev/null)
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null || true
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pidfile"
fi
done
# Kill recording process if active
if [ -n "$REC_PID" ]; then
kill "$REC_PID" 2>/dev/null || true
kill -9 "$REC_PID" 2>/dev/null || true
REC_PID=""
fi
# Wait briefly for processes to die
sleep 0.2
# Remove all runtime files for this PID
rm -f "$PTT_FLAG" "$CONNECTED_FLAG"
rm -f "$RECV_PIPE" "$SEND_PIPE"
rm -f "$CIPHER_RUNTIME_FILE"
rm -f "$DATA_DIR/run/remote_id_$$"
rm -f "$DATA_DIR/run/remote_cipher_$$"
rm -f "$DATA_DIR/run/incoming_$$"
# Clean temp audio files
rm -f "$AUDIO_DIR"/*.opus "$AUDIO_DIR"/*.bin "$AUDIO_DIR"/*.txt "$AUDIO_DIR"/*.wav 2>/dev/null || true
# Reset state variables
CALL_ACTIVE=0
REC_PID=""
}
#=============================================================================
# AUTO-LISTEN (BACKGROUND LISTENER)
#=============================================================================
start_auto_listener() {
# Only start if auto-listen is enabled and Tor is running
if [ "$AUTO_LISTEN" -ne 1 ]; then return 0; fi
if [ -z "$SHARED_SECRET" ]; then return 0; fi
if [ -z "$TOR_PID" ] || ! kill -0 "$TOR_PID" 2>/dev/null; then return 0; fi
# Stop any existing listener first
stop_auto_listener
mkdir -p "$AUDIO_DIR" "$DATA_DIR/run"
rm -f "$RECV_PIPE" "$SEND_PIPE" "$AUTO_LISTEN_FLAG"
mkfifo "$RECV_PIPE" "$SEND_PIPE"
socat "TCP-LISTEN:$LISTEN_PORT,reuseaddr" \
"SYSTEM:touch $AUTO_LISTEN_FLAG; cat $SEND_PIPE & cat > $RECV_PIPE" &
AUTO_LISTEN_PID=$!
save_pid "socat" "$AUTO_LISTEN_PID"
}
stop_auto_listener() {
if [ -n "$AUTO_LISTEN_PID" ]; then
kill "$AUTO_LISTEN_PID" 2>/dev/null || true
kill -9 "$AUTO_LISTEN_PID" 2>/dev/null || true
AUTO_LISTEN_PID=""
fi
rm -f "$AUTO_LISTEN_FLAG" "$RECV_PIPE" "$SEND_PIPE"
}
# Check if an incoming call arrived on the background listener
check_auto_listen() {
if [ -f "$AUTO_LISTEN_FLAG" ]; then
rm -f "$AUTO_LISTEN_FLAG"
touch "$CONNECTED_FLAG"
echo -e "\n ${GREEN}${BOLD}Incoming call detected!${NC}" >&2
sleep 0.5
in_call_session "$RECV_PIPE" "$SEND_PIPE" ""
cleanup_call
# Restart listener for next call
start_auto_listener
return 0
fi
return 1
}
# Start listening for incoming calls (manual, blocking)
listen_for_call() {
if [ -z "$SHARED_SECRET" ]; then
log_err "No shared secret set! Use option 4 first."
return 1
fi
start_tor || return 1
# Stop auto-listener if running (we'll do manual listen)
stop_auto_listener
local onion
onion=$(get_onion)
echo -e "\n${BOLD}${CYAN}═══ Listening for Calls ═══${NC}\n"
echo -e " ${GREEN}Your address:${NC} ${BOLD}${WHITE}$onion${NC}"
echo -e " ${GREEN}Listening on:${NC} port $LISTEN_PORT"
echo -e "\n ${DIM}Share your .onion address with the caller.${NC}"
echo -e " ${DIM}Press Ctrl+C to stop listening.${NC}\n"
mkdir -p "$AUDIO_DIR"
log_info "Waiting for incoming connection..."
rm -f "$RECV_PIPE" "$SEND_PIPE"
mkfifo "$RECV_PIPE" "$SEND_PIPE"
local incoming_flag="$DATA_DIR/run/incoming_$$"
rm -f "$incoming_flag"
socat "TCP-LISTEN:$LISTEN_PORT,reuseaddr" \
"SYSTEM:touch $incoming_flag; cat $SEND_PIPE & cat > $RECV_PIPE" &
local socat_pid=$!
save_pid "socat" "$socat_pid"
while [ ! -f "$incoming_flag" ]; do
if ! kill -0 "$socat_pid" 2>/dev/null; then
log_err "Listener stopped unexpectedly"
rm -f "$RECV_PIPE" "$SEND_PIPE" "$incoming_flag"
# Restart auto-listener if enabled
start_auto_listener
return 1
fi
sleep 0.5
done
touch "$CONNECTED_FLAG"
log_ok "Call connected!"
in_call_session "$RECV_PIPE" "$SEND_PIPE" ""
cleanup_call
# Restart auto-listener if enabled
start_auto_listener
}
# Call a remote .onion address
call_remote() {
if [ -z "$SHARED_SECRET" ]; then
log_err "No shared secret set! Use option 4 first."
return 1
fi
echo -e "\n${BOLD}${CYAN}═══ Make a Call ═══${NC}\n"
echo -ne " ${BOLD}Enter .onion address: ${NC}"
read -r remote_onion
if [ -z "$remote_onion" ]; then
log_warn "No address entered"
return 1
fi
# Append .onion if not present
if [[ "$remote_onion" != *.onion ]]; then
remote_onion="${remote_onion}.onion"
fi
start_tor || return 1
echo -e "\n ${DIM}Connecting to ${remote_onion}:${LISTEN_PORT} via Tor...${NC}"
mkdir -p "$AUDIO_DIR"
touch "$CONNECTED_FLAG"
# Create named pipes
rm -f "$RECV_PIPE" "$SEND_PIPE"
mkfifo "$RECV_PIPE" "$SEND_PIPE"
# Connect via Tor SOCKS proxy using socat
socat "SOCKS4A:127.0.0.1:${remote_onion}:${LISTEN_PORT},socksport=${TOR_SOCKS_PORT}" \
"SYSTEM:cat $SEND_PIPE & cat > $RECV_PIPE" &
local socat_pid=$!
save_pid "socat_call" "$socat_pid"
# Animated connecting indicator while socat establishes connection
(
local dots=""
while true; do
for dots in "." ".." "..." " "; do
echo -ne "\r ${CYAN}${BOLD}Connecting${dots}${NC} " >&2
sleep 0.3
done
done
) &
local spinner_pid=$!
# Give socat a moment to connect
sleep 2
if kill -0 "$socat_pid" 2>/dev/null; then
in_call_session "$RECV_PIPE" "$SEND_PIPE" "$remote_onion" "$spinner_pid"
else
# Kill spinner and show error
kill "$spinner_pid" 2>/dev/null || true
wait "$spinner_pid" 2>/dev/null || true
echo -ne "\r " >&2
echo "" >&2
log_err "Failed to connect. Check the address and ensure Tor is running."
fi
# Full cleanup after call ends
cleanup_call
}
#=============================================================================
# IN-CALL SESSION — PTT VOICE LOOP
#=============================================================================
# Draw the call header (reusable for redraw after settings)
draw_call_header() {
local _remote="${1:-}"
local _rcipher="${2:-}"
clear >&2
# Row counter for ANSI cursor positioning (clear sets cursor to row 1)
local _r=1
if [ -n "$_remote" ]; then
echo -e "\n${BOLD}${BG_GREEN}${WHITE} CALL CONNECTED ${NC} ${CYAN}${_remote}${NC}\n" >&2
else
echo -e "\n${BOLD}${BG_GREEN}${WHITE} CALL CONNECTED ${NC}\n" >&2
fi
_r=4 # \n(row1) + header(row2) + \n(row3) + echo-newline → cursor at row 4
# Cipher info
CIPHER_ROW=$_r
local cipher_upper="${CIPHER^^}"
if [ -n "$_rcipher" ]; then
local rcipher_upper="${_rcipher^^}"
if [ "$_rcipher" = "$CIPHER" ]; then
echo -e " ${GREEN}${NC} Local cipher: ${WHITE}${cipher_upper}${NC}" >&2
echo -e " ${GREEN}${NC} Remote cipher: ${WHITE}${rcipher_upper}${NC}" >&2
else
echo -e " ${RED}${NC} Local cipher: ${WHITE}${cipher_upper}${NC}" >&2
echo -e " ${RED}${NC} Remote cipher: ${WHITE}${rcipher_upper}${NC}" >&2
fi
else
echo -e " ${GREEN}${NC} Local cipher: ${WHITE}${cipher_upper}${NC}" >&2
echo -e " ${DIM}${NC} Remote cipher: ${DIM}waiting...${NC}" >&2
fi
_r=$((_r + 2))
# Snowflake bridge info
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
local tor_log="$TOR_DIR/tor.log"
echo "" >&2; _r=$((_r + 1))
echo -e " ${TOR_PURPLE}${NC} ${BOLD}Snowflake bridge${NC}" >&2; _r=$((_r + 1))
if [ -f "$tor_log" ]; then
local bridge_line=""
bridge_line=$(grep "new bridge descriptor" "$tor_log" 2>/dev/null | tail -1 || true)
if [ -n "$bridge_line" ]; then
local bridge_name=""
bridge_name=$(echo "$bridge_line" | sed -n "s/.*new bridge descriptor '\([^']*\)'.*/\1/p" || true)
local bridge_fp=""
bridge_fp=$(echo "$bridge_line" | sed -n 's/.*(\(fresh\|stale\)): \(.*\)/\2/p' || true)
if [ -n "$bridge_name" ]; then
local fp_display="$bridge_fp"
if [ ${#fp_display} -gt 40 ]; then
fp_display="${fp_display:0:40}..."
fi
echo -e " ${DIM}descriptor:${NC} ${WHITE}${bridge_name}${NC} ${DIM}${fp_display}${NC}" >&2
_r=$((_r + 1))
fi
fi
local proxy_line=""
proxy_line=$(grep 'Managed proxy.*snowflake' "$tor_log" 2>/dev/null | tail -1 || true)
if [ -n "$proxy_line" ]; then
if echo "$proxy_line" | grep -q "connected"; then
echo -e " ${DIM}transport:${NC} ${GREEN}connected${NC}" >&2
else
echo -e " ${DIM}transport:${NC} ${YELLOW}connecting...${NC}" >&2
fi
_r=$((_r + 1))
fi
fi
fi
echo "" >&2; _r=$((_r + 1))
# Static placeholders — updated in-place via ANSI positioning
echo -e " ${DIM}Last sent: --${NC}" >&2
SENT_INFO_ROW=$_r
_r=$((_r + 1))
echo -e " ${DIM}Last recv: --${NC}" >&2
RECV_INFO_ROW=$_r
_r=$((_r + 1))
echo "" >&2; _r=$((_r + 1))
# Static status bar
if [ $IS_TERMUX -eq 1 ]; then
echo -ne " ${GREEN}${BOLD} Ready ${NC} ${DIM}[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&2
else
echo -ne " ${GREEN}${BOLD} Ready ${NC} ${DIM}[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&2
fi
STATUS_ROW=$_r
echo "" >&2
echo "" >&2
}
in_call_session() {
local recv_pipe="$1"
local send_pipe="$2"
local known_remote="${3:-}"
local spinner_pid="${4:-}"
CALL_ACTIVE=1
rm -f "$PTT_FLAG"
mkdir -p "$AUDIO_DIR"
# Write cipher to runtime file so subshells can track changes
echo "$CIPHER" > "$CIPHER_RUNTIME_FILE"
# Open persistent file descriptors for the pipes
exec 3< "$recv_pipe" # fd 3 = read from remote
exec 4> "$send_pipe" # fd 4 = write to remote
# Send our onion address and cipher for handshake
local my_onion
my_onion=$(get_onion)
if [ -n "$my_onion" ]; then
echo "ID:${my_onion}" >&4 2>/dev/null || true
fi
echo "CIPHER:${CIPHER}" >&4 2>/dev/null || true
# Remote address and cipher (populated by handshake / receive loop)
local remote_id_file="$DATA_DIR/run/remote_id_$$"
local remote_cipher_file="$DATA_DIR/run/remote_cipher_$$"
rm -f "$remote_id_file" "$remote_cipher_file"
# If we don't know the remote address yet (listener), wait briefly for handshake
local remote_display="$known_remote"
local remote_cipher=""
if [ -z "$remote_display" ]; then
# Read first line — should be ID
local first_line=""
if read -r -t 3 first_line <&3 2>/dev/null; then
if [[ "$first_line" == ID:* ]]; then
remote_display="${first_line#ID:}"
echo "$remote_display" > "$remote_id_file"
elif [[ "$first_line" == CIPHER:* ]]; then
remote_cipher="${first_line#CIPHER:}"
fi
fi
fi
# Try to read CIPHER: line (quick, non-blocking)
if [ -z "$remote_cipher" ]; then
local cline=""
if read -r -t 1 cline <&3 2>/dev/null; then
if [[ "$cline" == CIPHER:* ]]; then
remote_cipher="${cline#CIPHER:}"
fi
fi
fi
# Save remote cipher for later redraws
if [ -n "$remote_cipher" ]; then
echo "$remote_cipher" > "$remote_cipher_file"
fi
# Kill connecting spinner and draw call header
if [ -n "$spinner_pid" ]; then
kill "$spinner_pid" 2>/dev/null || true
wait "$spinner_pid" 2>/dev/null || true
fi
draw_call_header "$remote_display" "$remote_cipher"
# Start receive handler in background
# Protocol: ID:<onion>, PTT_START, PTT_STOP, PING,
# or "AUDIO:<base64_encoded_encrypted_opus>"
(
while [ -f "$CONNECTED_FLAG" ]; do
local line=""
if read -r line <&3 2>/dev/null; then
case "$line" in
PTT_START)
printf '\033[s' >&2
printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2
printf ' \033[42;1;37m REMOTE TALKING \033[0m ' >&2
printf '\033[u' >&2
;;
PTT_STOP)
# silent — Last recv row provides feedback
;;
PING)
# silent — no display update
;;
ID:*)
# Caller ID received (save but don't print — already in header)
local remote_addr="${line#ID:}"
echo "$remote_addr" > "$remote_id_file"
;;
CIPHER:*)
# Remote side sent/changed their cipher — save and update display
local rc="${line#CIPHER:}"
echo "$rc" > "$remote_cipher_file" 2>/dev/null || true
# Read current local cipher from runtime file
local _cur_cipher="$CIPHER"
[ -f "$CIPHER_RUNTIME_FILE" ] && _cur_cipher=$(cat "$CIPHER_RUNTIME_FILE")
local _cu="${_cur_cipher^^}"
local _ru="${rc^^}"
# Update cipher lines in-place using ANSI cursor positioning (rows 4-5)
local _dot_color
if [ "$rc" = "$_cur_cipher" ]; then
_dot_color="$GREEN"
else
_dot_color="$RED"
fi
printf '\033[s' >&2
printf '\033[%d;1H\033[K' "$CIPHER_ROW" >&2
printf ' %b●%b Local cipher: %b%s%b\r\n' "$_dot_color" "$NC" "$WHITE" "$_cu" "$NC" >&2
printf '\033[K' >&2
printf ' %b●%b Remote cipher: %b%s%b' "$_dot_color" "$NC" "$WHITE" "$_ru" "$NC" >&2
printf '\033[u' >&2
;;
MSG:*)
# Encrypted text message received
local msg_b64="${line#MSG:}"
local _mid=$(uid)
local msg_enc="$AUDIO_DIR/msg_enc_${_mid}.bin"
local msg_dec="$AUDIO_DIR/msg_dec_${_mid}.txt"
echo "$msg_b64" | base64 -d > "$msg_enc" 2>/dev/null || true
if [ -s "$msg_enc" ]; then
if decrypt_file "$msg_enc" "$msg_dec" 2>/dev/null; then
local msg_text
msg_text=$(cat "$msg_dec" 2>/dev/null)
echo -e "\n ${MAGENTA}${BOLD}[MSG]${NC} ${WHITE}${msg_text}${NC}" >&2
fi
fi
rm -f "$msg_enc" "$msg_dec" 2>/dev/null
;;
AUDIO:*)
# Extract base64 data, decode, decrypt, play
local b64_data="${line#AUDIO:}"
local _rid=$(uid)
local enc_file="$AUDIO_DIR/recv_enc_${_rid}.opus"
local dec_file="$AUDIO_DIR/recv_dec_${_rid}.opus"
echo "$b64_data" | base64 -d > "$enc_file" 2>/dev/null || true
if [ -s "$enc_file" ]; then
if decrypt_file "$enc_file" "$dec_file" 2>/dev/null; then
# Calculate recv size
local _enc_sz=0
_enc_sz=$(stat -c%s "$enc_file" 2>/dev/null || echo 0)
local _sz_kb=$(( _enc_sz * 10 / 1024 ))
local _sz_w=$(( _sz_kb / 10 ))
local _sz_f=$(( _sz_kb % 10 ))
local _recv_info="${_sz_w}.${_sz_f}KB"
# Update static "Last recv" row via ANSI positioning
printf '\033[s' >&2
printf '\033[%d;1H\033[K' "$RECV_INFO_ROW" >&2
printf ' \033[2mLast recv: \033[0m\033[1;37m%s\033[0m' "$_recv_info" >&2
printf '\033[u' >&2
play_chunk "$dec_file" 2>/dev/null || true
fi
fi
rm -f "$enc_file" "$dec_file" 2>/dev/null
;;
HANGUP)
# Remote party hung up
echo -e "\r\n\r\n ${YELLOW}${BOLD}Remote party hung up.${NC}" >&2
rm -f "$CONNECTED_FLAG"
break
;;
esac
else
# Pipe closed or error — connection lost
echo -e "\r\n\r\n ${RED}${BOLD}Connection lost.${NC}" >&2
rm -f "$CONNECTED_FLAG"
break
fi
done
) &
local recv_pid=$!
save_pid "recv_loop" "$recv_pid"
# Main PTT input loop
ORIGINAL_STTY=$(stty -g)
stty raw -echo -icanon min 0 time 1
REC_PID=""
REC_FILE=""
LAST_SENT_INFO=""
local ptt_active=0
# Status bar is already drawn by draw_call_header
while [ -f "$CONNECTED_FLAG" ]; do
local key=""
key=$(dd bs=1 count=1 2>/dev/null) || true
if [ "$key" = "$PTT_KEY" ]; then
if [ $IS_TERMUX -eq 1 ]; then
# TERMUX: Toggle mode
if [ $ptt_active -eq 0 ]; then
ptt_active=1
printf '\033[s' >&2; printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2
printf ' \033[41;1;37m \u25cf RECORDING \033[0m \033[2m[SPACE]=Send\033[0m ' >&2
printf '\033[u' >&2
start_recording
else
ptt_active=0
stop_and_send
echo "PTT_STOP" >&4 2>/dev/null || true
# Update Last sent + status bar
printf '\033[s' >&2
printf '\033[%d;1H\033[K' "$SENT_INFO_ROW" >&2
printf ' \033[2mLast sent: \033[0m\033[1;37m%s\033[0m' "$LAST_SENT_INFO" >&2
printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2
printf ' \033[1;32m Sent! \033[0m \033[2m[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2
printf '\033[u' >&2
fi
else
# LINUX: Hold-to-talk
if [ $ptt_active -eq 0 ]; then
ptt_active=1
printf '\033[s' >&2; printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2
printf ' \033[41;1;37;5m \u25cf RECORDING \033[0m ' >&2
printf '\033[u' >&2
stty time 5 # longer timeout to span keyboard repeat delay
start_recording
fi
fi
elif [ "$key" = "q" ] || [ "$key" = "Q" ]; then
# If recording, cancel it
if [ $ptt_active -eq 1 ] && [ -n "$REC_PID" ]; then
if [ $IS_TERMUX -eq 1 ]; then
termux-microphone-record -q &>/dev/null || true
fi
kill "$REC_PID" 2>/dev/null || true
wait "$REC_PID" 2>/dev/null || true
rm -f "$REC_FILE" 2>/dev/null
REC_PID=""
REC_FILE=""
fi
echo -e "\r\n${YELLOW}Hanging up...${NC}" >&2
echo "HANGUP" >&4 2>/dev/null || true
rm -f "$PTT_FLAG" "$CONNECTED_FLAG"
break
elif [ -z "$key" ]; then
# No key pressed (timeout) — on Linux, release = stop and send
if [ $IS_TERMUX -eq 0 ] && [ $ptt_active -eq 1 ]; then
stty time 1 # restore fast timeout for key detection
ptt_active=0
stop_and_send
echo "PTT_STOP" >&4 2>/dev/null || true
# Update Last sent + status bar
printf '\033[s' >&2
printf '\033[%d;1H\033[K' "$SENT_INFO_ROW" >&2
printf ' \033[2mLast sent: \033[0m\033[1;37m%s\033[0m' "$LAST_SENT_INFO" >&2
printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2
printf ' \033[1;32m Sent! \033[0m \033[2m[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2
printf '\033[u' >&2
fi
elif [ "$key" = "t" ] || [ "$key" = "T" ]; then
# Text chat mode
# Switch to cooked mode for text input
stty "$ORIGINAL_STTY" 2>/dev/null || stty sane
echo "" >&2
echo -ne " ${CYAN}${BOLD}MSG>${NC} " >&2
local chat_msg=""
read -r chat_msg
if [ -n "$chat_msg" ]; then
# Encrypt and send
local _cid=$(uid)
local chat_plain="$AUDIO_DIR/chat_${_cid}.txt"
local chat_enc="$AUDIO_DIR/chat_enc_${_cid}.bin"
echo -n "$chat_msg" > "$chat_plain"
encrypt_file "$chat_plain" "$chat_enc" 2>/dev/null
if [ -s "$chat_enc" ]; then
local chat_b64
chat_b64=$(base64 -w 0 "$chat_enc" 2>/dev/null)
echo "MSG:${chat_b64}" >&4 2>/dev/null || true
echo -e " ${DIM}[you] ${chat_msg}${NC}" >&2
fi
rm -f "$chat_plain" "$chat_enc" 2>/dev/null
fi
# Switch back to raw mode for PTT
stty raw -echo -icanon min 0 time 1
# Restore status bar to Ready
printf '\033[s' >&2
printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2
if [ $IS_TERMUX -eq 1 ]; then
printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2
else
printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2
fi
printf '\033[u' >&2
elif [ "$key" = "s" ] || [ "$key" = "S" ]; then
# Mid-call settings
stty "$ORIGINAL_STTY" 2>/dev/null || stty sane
# Flush any leftover raw mode input
read -r -t 0.1 -n 10000 2>/dev/null || true
settings_menu
# Redraw call header and switch back to raw mode
local _rd="" _rc=""
[ -f "$remote_id_file" ] && _rd=$(cat "$remote_id_file" 2>/dev/null)
[ -z "$_rd" ] && _rd="$known_remote"
[ -f "$remote_cipher_file" ] && _rc=$(cat "$remote_cipher_file" 2>/dev/null)
draw_call_header "$_rd" "$_rc"
stty raw -echo -icanon min 0 time 1
# Restore status bar to Ready (header was redrawn, STATUS_ROW is fresh)
printf '\033[s' >&2
printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2
if [ $IS_TERMUX -eq 1 ]; then
printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2
else
printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2
fi
printf '\033[u' >&2
fi
done
echo -e "\n${BOLD}${RED} CALL ENDED ${NC}\n"
}
#=============================================================================
# AUDIO TEST (LOOPBACK)
#=============================================================================
test_audio() {
echo -e "\n${BOLD}${CYAN}═══ Audio Loopback Test ═══${NC}\n"
# Check dependencies first
local missing=0
local audio_deps=(opusenc opusdec)
if [ $IS_TERMUX -eq 1 ]; then
audio_deps+=(termux-microphone-record ffmpeg)
else
audio_deps+=(arecord aplay)
fi
for dep in "${audio_deps[@]}"; do
if ! check_dep "$dep"; then
log_err "$dep not found — run option 7 to install dependencies first"
missing=1
fi
done
if [ $missing -eq 1 ]; then
return 1
fi
echo -e " ${DIM}This will record 3 seconds of audio, encode it with Opus,${NC}"
echo -e " ${DIM}and play it back to verify your audio pipeline works.${NC}\n"
mkdir -p "$AUDIO_DIR"
# Step 1: Record
echo -ne " ${YELLOW}● Recording for 3 seconds... speak now!${NC} "
local _tid=$(uid)
local raw_file="$AUDIO_DIR/test_${_tid}.raw"
audio_record "$raw_file" 3
echo -e "${GREEN}done${NC}"
if [ ! -s "$raw_file" ]; then
log_err "Recording failed — check your microphone"
return 1
fi
local raw_size
raw_size=$(stat -c%s "$raw_file")
echo -e " ${DIM}Recorded $raw_size bytes of raw audio${NC}"
# Step 2: Encode with Opus
echo -ne " ${YELLOW}● Encoding with Opus at ${OPUS_BITRATE}kbps...${NC} "
local opus_file="$AUDIO_DIR/test_${_tid}.opus"
opusenc --raw --raw-rate "$SAMPLE_RATE" --raw-chan 1 \
--bitrate "$OPUS_BITRATE" --framesize "$OPUS_FRAMESIZE" \
--speech --quiet \
"$raw_file" "$opus_file" 2>/dev/null
echo -e "${GREEN}done${NC}"
if [ ! -s "$opus_file" ]; then
log_err "Opus encoding failed"
rm -f "$raw_file"
return 1
fi
local opus_size
opus_size=$(stat -c%s "$opus_file")
echo -e " ${DIM}Opus size: $opus_size bytes (compression ratio: $((raw_size / opus_size))x)${NC}"
# Step 3: Encrypt + Decrypt round-trip (if secret is set)
if [ -n "$SHARED_SECRET" ]; then
echo -ne " ${YELLOW}● Encrypting and decrypting...${NC} "
local enc_file="$AUDIO_DIR/test_enc_${_tid}.opus"
local dec_file="$AUDIO_DIR/test_dec_${_tid}.opus"
encrypt_file "$opus_file" "$enc_file"
decrypt_file "$enc_file" "$dec_file"
if cmp -s "$opus_file" "$dec_file"; then
echo -e "${GREEN}encryption round-trip OK${NC}"
else
echo -e "${RED}encryption round-trip FAILED${NC}"
fi
rm -f "$enc_file"
opus_file="$dec_file"
fi
# Step 4: Decode and play
echo -ne " ${YELLOW}● Playing back...${NC} "
play_chunk "$opus_file"
echo -e "${GREEN}done${NC}"
rm -f "$raw_file" "$opus_file" "$AUDIO_DIR/test_dec_${_tid}.opus" 2>/dev/null
echo -e "\n ${GREEN}${BOLD}Audio test complete!${NC}"
echo -e " ${DIM}If you heard your voice, the pipeline is working.${NC}\n"
}
#=============================================================================
# SHOW STATUS
#=============================================================================
show_status() {
echo -e "\n${BOLD}${CYAN}═══ Status ═══${NC}\n"
# Tor status
if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then
echo -e " ${GREEN}${NC} Tor running (PID $TOR_PID)"
local onion
onion=$(get_onion)
if [ -n "$onion" ]; then
echo -e " ${BOLD}${WHITE} Address: ${onion}${NC}"
fi
else
echo -e " ${RED}${NC} Tor not running"
fi
# Secret
if [ -n "$SHARED_SECRET" ]; then
echo -e " ${GREEN}${NC} Shared secret set"
else
echo -e " ${RED}${NC} No shared secret (set one before calling)"
fi
# Audio
if check_dep arecord && check_dep opusenc; then
echo -e " ${GREEN}${NC} Audio pipeline ready"
else
echo -e " ${RED}${NC} Audio dependencies missing"
fi
# Snowflake
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
if check_dep snowflake-client; then
echo -e " ${GREEN}${NC} Snowflake bridge enabled"
else
echo -e " ${YELLOW}${NC} Snowflake enabled (client not installed)"
fi
else
echo -e " ${DIM}${NC} Snowflake bridge disabled"
fi
# Config
echo -e "\n ${DIM}Listen port: $LISTEN_PORT${NC}"
echo -e " ${DIM}SOCKS port: $TOR_SOCKS_PORT${NC}"
echo -e " ${DIM}Cipher: $CIPHER${NC}"
echo -e " ${DIM}Opus bitrate: ${OPUS_BITRATE}kbps${NC}"
echo -e " ${DIM}Opus frame: ${OPUS_FRAMESIZE}ms${NC}"
echo -e " ${DIM}PTT key: [SPACEBAR]${NC}"
echo ""
}
#=============================================================================
# SETTINGS MENU
#=============================================================================
settings_menu() {
while true; do
clear
echo -e "\n${BOLD}${CYAN}═══ Settings ═══${NC}\n"
echo -e " ${DIM}Current cipher: ${NC}${WHITE}${CIPHER}${NC}"
echo -e " ${DIM}Current Opus bitrate: ${NC}${WHITE}${OPUS_BITRATE} kbps${NC}"
echo -e " ${DIM}Current Opus frame: ${NC}${WHITE}${OPUS_FRAMESIZE} ms${NC}"
local sf_label="${RED}disabled${NC}"
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
sf_label="${GREEN}enabled${NC}"
fi
echo -e " ${DIM}Snowflake bridge: ${NC}${sf_label}"
local al_label="${RED}disabled${NC}"
if [ "$AUTO_LISTEN" -eq 1 ]; then
al_label="${GREEN}enabled${NC}"
fi
echo -e " ${DIM}Auto-listen: ${NC}${al_label}"
local ptt_display="SPACE"
[ "$PTT_KEY" != " " ] && ptt_display="$PTT_KEY"
echo -e " ${DIM}PTT key: ${NC}${WHITE}${ptt_display}${NC}"
local vfx_display="${VOICE_EFFECT}"
[ "$vfx_display" = "none" ] && vfx_display="off"
echo -e " ${DIM}Voice effect: ${NC}${WHITE}${vfx_display}${NC}\n"
echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}${NC} Change encryption cipher"
echo -e " ${BOLD}${WHITE}2${NC} ${CYAN}${NC} Change Opus encoding quality"
echo -e " ${BOLD}${WHITE}3${NC} ${CYAN}${NC} Snowflake bridge (censorship circumvention)"
echo -e " ${BOLD}${WHITE}4${NC} ${CYAN}${NC} Auto-listen (listen for calls automatically once Tor starts)"
echo -e " ${BOLD}${WHITE}5${NC} ${CYAN}${NC} Change PTT (push-to-talk) key"
echo -e " ${BOLD}${WHITE}6${NC} ${CYAN}${NC} Voice changer"
echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}${NC} ${DIM}Back to main menu${NC}"
echo ""
echo -ne " ${BOLD}Select: ${NC}"
read -r schoice
case "$schoice" in
1) settings_cipher ;;
2) settings_opus ;;
3) settings_snowflake ;;
4)
if [ "$AUTO_LISTEN" -eq 1 ]; then
AUTO_LISTEN=0
stop_auto_listener
log_ok "Auto-listen disabled"
else
AUTO_LISTEN=1
log_ok "Auto-listen enabled"
start_auto_listener
fi
save_config
sleep 1
;;
5)
local _pd="SPACE"
[ "$PTT_KEY" != " " ] && _pd="$PTT_KEY"
echo -e "\n ${DIM}Current PTT key: ${NC}${WHITE}${_pd}${NC}"
echo -ne " ${BOLD}Press the key you want to use for PTT: ${NC}"
# Read a single character in raw mode
local _old_stty
_old_stty=$(stty -g)
stty raw -echo
local _newkey
_newkey=$(dd bs=1 count=1 2>/dev/null) || true
stty "$_old_stty"
if [ -n "$_newkey" ]; then
PTT_KEY="$_newkey"
save_config
local _nd="SPACE"
[ "$PTT_KEY" != " " ] && _nd="$PTT_KEY"
log_ok "PTT key set to: ${_nd}"
fi
sleep 1
;;
6) settings_voice ;;
0|q|Q) return ;;
*)
echo -e "\n ${RED}Invalid choice${NC}"
sleep 1
;;
esac
done
}
settings_cipher() {
echo -e "\n${BOLD}${CYAN}═══ Select Encryption Cipher ═══${NC}\n"
echo -e " ${DIM}Current: ${NC}${GREEN}${CIPHER}${NC}\n"
# Curated cipher list ranked from strongest to adequate
# Only includes ciphers verified to work with openssl enc -pbkdf2
# Excludes: ECB modes (pattern leakage), DES/RC2/RC4/Blowfish (weak), aliases
local ciphers=(
# ── 256-bit (Strongest) ──
"aes-256-ctr"
"aes-256-cbc"
"aes-256-cfb"
"aes-256-ofb"
"chacha20"
"camellia-256-ctr"
"camellia-256-cbc"
"aria-256-ctr"
"aria-256-cbc"
# ── 192-bit (Strong) ──
"aes-192-ctr"
"aes-192-cbc"
"camellia-192-ctr"
"camellia-192-cbc"
"aria-192-ctr"
"aria-192-cbc"
# ── 128-bit (Adequate) ──
"aes-128-ctr"
"aes-128-cbc"
"camellia-128-ctr"
"camellia-128-cbc"
"aria-128-ctr"
"aria-128-cbc"
)
local total=${#ciphers[@]}
while true; do
clear
echo -e "\n${BOLD}${CYAN}═══ Available Ciphers ═══${NC}"
echo -e " ${DIM}Current: ${NC}${GREEN}${CIPHER}${NC}"
echo -e " ${DIM}${total} ciphers, ranked strongest → adequate${NC}\n"
local tier=""
for ((i = 0; i < total; i++)); do
local num=$((i + 1))
local c="${ciphers[$i]}"
# Print tier headers
if [ $i -eq 0 ]; then
echo -e " ${GREEN}${BOLD}── 256-bit (Strongest) ──${NC}"
elif [ $i -eq 9 ]; then
echo -e " ${YELLOW}${BOLD}── 192-bit (Strong) ──${NC}"
elif [ $i -eq 15 ]; then
echo -e " ${WHITE}${BOLD}── 128-bit (Adequate) ──${NC}"
fi
if [ "$c" = "$CIPHER" ]; then
printf " ${GREEN}${BOLD}%4d${NC} ${CYAN}${NC} ${GREEN}%-30s ◄ current${NC}\n" "$num" "$c"
else
printf " ${WHITE}${BOLD}%4d${NC} ${CYAN}${NC} %-30s\n" "$num" "$c"
fi
done
echo ""
echo -e " ${DIM}[0] cancel${NC}"
echo -ne " ${BOLD}Enter cipher number: ${NC}"
read -r cinput
case "$cinput" in
0|q|Q)
return
;;
'')
;;
*)
if [[ "$cinput" =~ ^[0-9]+$ ]] && [ "$cinput" -ge 1 ] && [ "$cinput" -le "$total" ]; then
local selected="${ciphers[$((cinput - 1))]}"
# Validate that openssl can actually use this cipher
if echo "test" | openssl enc -"${selected}" -pbkdf2 -pass pass:test 2>/dev/null | openssl enc -d -"${selected}" -pbkdf2 -pass pass:test &>/dev/null; then
CIPHER="$selected"
save_config
# Update runtime file for live mid-call sync
[ -f "$CIPHER_RUNTIME_FILE" ] && echo "$CIPHER" > "$CIPHER_RUNTIME_FILE"
# Notify remote side if in a call
if [ "$CALL_ACTIVE" -eq 1 ]; then
echo "CIPHER:${CIPHER}" >&4 2>/dev/null || true
fi
echo -e "\n ${GREEN}${BOLD}${NC} Cipher set to ${WHITE}${BOLD}${CIPHER}${NC}"
else
echo -e "\n ${RED}${BOLD}${NC} Cipher '${selected}' failed validation — not compatible with stream encryption"
fi
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
return
else
echo -e "\n ${RED}Invalid number${NC}"
sleep 1
fi
;;
esac
done
}
settings_opus() {
echo -e "\n${BOLD}${CYAN}═══ Opus Encoding Quality ═══${NC}\n"
echo -e " ${DIM}Current bitrate: ${NC}${GREEN}${OPUS_BITRATE} kbps${NC}\n"
local -a presets=(6 8 12 16 24 32 48 64)
local -a labels=(
"6 kbps — Minimum (very low bandwidth)"
"8 kbps — Low (narrowband voice)"
"12 kbps — Medium-Low (clear voice)"
"16 kbps — Medium (recommended for Tor)"
"24 kbps — Medium-High (good quality)"
"32 kbps — High (wideband voice)"
"48 kbps — Very High (near-studio)"
"64 kbps — Maximum (best quality)"
)
for ((i = 0; i < ${#presets[@]}; i++)); do
local num=$((i + 1))
if [ "${presets[$i]}" = "$OPUS_BITRATE" ]; then
echo -e " ${GREEN}${BOLD}${num}${NC} ${CYAN}${NC} ${GREEN}${labels[$i]} ◄ current${NC}"
else
echo -e " ${BOLD}${WHITE}${num}${NC} ${CYAN}${NC} ${labels[$i]}"
fi
done
echo -e " ${BOLD}${WHITE}9${NC} ${CYAN}${NC} Custom bitrate"
echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}${NC} ${DIM}Cancel${NC}"
echo ""
echo -ne " ${BOLD}Select: ${NC}"
read -r oinput
case "$oinput" in
[1-8])
OPUS_BITRATE=${presets[$((oinput - 1))]}
save_config
echo -e "\n ${GREEN}${BOLD}${NC} Opus bitrate set to ${WHITE}${BOLD}${OPUS_BITRATE} kbps${NC}"
;;
9)
echo -ne "\n ${BOLD}Enter bitrate (6-510 kbps): ${NC}"
read -r custom_br
if [[ "$custom_br" =~ ^[0-9]+$ ]] && [ "$custom_br" -ge 6 ] && [ "$custom_br" -le 510 ]; then
OPUS_BITRATE=$custom_br
save_config
echo -e "\n ${GREEN}${BOLD}${NC} Opus bitrate set to ${WHITE}${BOLD}${OPUS_BITRATE} kbps${NC}"
else
echo -e "\n ${RED}Invalid bitrate. Must be 6510.${NC}"
fi
;;
0|q|Q)
return
;;
*)
echo -e "\n ${RED}Invalid choice${NC}"
;;
esac
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
}
settings_snowflake() {
clear
echo -e "\n${BOLD}${CYAN}═══ Snowflake Bridge ═══${NC}\n"
echo -e " ${DIM}Snowflake uses WebRTC proxies to help bypass Tor censorship.${NC}"
echo -e " ${DIM}Enable this if Tor is blocked in your region.${NC}\n"
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
echo -e " Status: ${GREEN}${BOLD}ENABLED${NC}"
else
echo -e " Status: ${RED}${BOLD}DISABLED${NC}"
fi
if check_dep snowflake-client; then
echo -e " Binary: ${GREEN}${NC} snowflake-client installed"
else
echo -e " Binary: ${RED}${NC} snowflake-client not installed"
fi
echo -e " ${DIM}Tor manages snowflake-client as a pluggable transport.${NC}"
echo ""
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}${NC} Disable Snowflake"
else
echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}${NC} Enable Snowflake"
fi
echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}${NC} ${DIM}Back${NC}"
echo ""
echo -ne " ${BOLD}Select: ${NC}"
read -r sf_choice
case "$sf_choice" in
1)
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
SNOWFLAKE_ENABLED=0
save_config
echo -e "\n ${YELLOW}${BOLD}${NC} Snowflake disabled"
echo -e " ${DIM}Restart Tor for changes to take effect.${NC}"
else
# Install if not present
if ! check_dep snowflake-client; then
echo ""
echo -ne " ${BOLD}snowflake-client not installed. Install now? [Y/n]: ${NC}"
read -r install_confirm
if [ "$install_confirm" != "n" ] && [ "$install_confirm" != "N" ]; then
install_snowflake || {
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
return
}
else
echo -e "\n ${YELLOW}Snowflake not enabled (client not installed)${NC}"
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
return
fi
fi
SNOWFLAKE_ENABLED=1
save_config
echo -e "\n ${GREEN}${BOLD}${NC} Snowflake enabled"
echo -e " ${DIM}Restart Tor for changes to take effect.${NC}"
fi
;;
0|q|Q) return ;;
*)
echo -e "\n ${RED}Invalid choice${NC}"
;;
esac
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
}
settings_voice() {
echo -e "\n${BOLD}${CYAN}═══ Voice Changer ═══${NC}\n"
echo -e " ${DIM}Current effect: ${NC}${GREEN}${VOICE_EFFECT}${NC}\n"
local effects=("none" "deep" "high" "robot" "echo" "whisper" "custom")
local descs=(
"No effect (natural voice)"
"Deep voice (pitch shifted down)"
"High voice (pitch shifted up)"
"Robot (overdrive + flanger)"
"Echo (delayed reverb)"
"Whisper (highpass + tremolo)"
"Custom (configure all parameters)"
)
local i
for i in "${!effects[@]}"; do
local num=$(( i + 1 ))
local marker=" "
if [ "${effects[$i]}" = "$VOICE_EFFECT" ]; then
marker="${GREEN}> ${NC}"
fi
echo -e " ${marker}${BOLD}${WHITE}${num}${NC} ${CYAN}${NC} ${descs[$i]}"
done
echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}${NC} ${DIM}Back${NC}"
echo ""
echo -ne " ${BOLD}Select: ${NC}"
read -r vchoice
case "$vchoice" in
1) VOICE_EFFECT="none" ;;
2) VOICE_EFFECT="deep" ;;
3) VOICE_EFFECT="high" ;;
4) VOICE_EFFECT="robot" ;;
5) VOICE_EFFECT="echo" ;;
6) VOICE_EFFECT="whisper" ;;
7) VOICE_EFFECT="custom"
settings_voice_custom
;;
0|q|Q) return ;;
*)
echo -e "\n ${RED}Invalid choice${NC}"
sleep 1
return
;;
esac
save_config
log_ok "Voice effect set to: ${VOICE_EFFECT}"
sleep 1
}
settings_voice_custom() {
while true; do
clear
echo -e "\n${BOLD}${CYAN}═══ Custom Voice Effect ═══${NC}\n"
echo -e " ${DIM}Configure each parameter. Effects are combined into one chain.${NC}"
echo -e " ${DIM}Set a value to 0 to disable that effect.${NC}\n"
local _p_status="${RED}off${NC}"
[ "$VOICE_PITCH" -ne 0 ] 2>/dev/null && _p_status="${GREEN}${VOICE_PITCH} cents${NC}"
local _od_status="${RED}off${NC}"
[ "$VOICE_OVERDRIVE" -gt 0 ] 2>/dev/null && _od_status="${GREEN}${VOICE_OVERDRIVE}${NC}"
local _fl_status="${RED}off${NC}"
[ "$VOICE_FLANGER" -eq 1 ] 2>/dev/null && _fl_status="${GREEN}on${NC}"
local _ed_status="${RED}off${NC}"
[ "$VOICE_ECHO_DELAY" -gt 0 ] 2>/dev/null && _ed_status="${GREEN}${VOICE_ECHO_DELAY}ms decay 0.${VOICE_ECHO_DECAY}${NC}"
local _hp_status="${RED}off${NC}"
[ "$VOICE_HIGHPASS" -gt 0 ] 2>/dev/null && _hp_status="${GREEN}${VOICE_HIGHPASS} Hz${NC}"
local _tr_status="${RED}off${NC}"
[ "$VOICE_TREMOLO" -gt 0 ] 2>/dev/null && _tr_status="${GREEN}${VOICE_TREMOLO} Hz${NC}"
echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}${NC} Pitch shift ${_p_status} ${DIM}(-600 to +600 cents)${NC}"
echo -e " ${BOLD}${WHITE}2${NC} ${CYAN}${NC} Overdrive ${_od_status} ${DIM}(0=off, 5-20)${NC}"
echo -e " ${BOLD}${WHITE}3${NC} ${CYAN}${NC} Flanger ${_fl_status} ${DIM}(0=off, 1=on)${NC}"
echo -e " ${BOLD}${WHITE}4${NC} ${CYAN}${NC} Echo ${_ed_status} ${DIM}(delay 0-200ms, decay 1-9)${NC}"
echo -e " ${BOLD}${WHITE}5${NC} ${CYAN}${NC} Highpass filter ${_hp_status} ${DIM}(0=off, 300-2000 Hz)${NC}"
echo -e " ${BOLD}${WHITE}6${NC} ${CYAN}${NC} Tremolo ${_tr_status} ${DIM}(0=off, 5-40 Hz)${NC}"
echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}${NC} ${DIM}Done${NC}"
echo ""
echo -ne " ${BOLD}Select parameter: ${NC}"
read -r pchoice
case "$pchoice" in
1)
echo -ne " ${BOLD}Pitch shift (cents, -600 to +600, 0=off): ${NC}"
read -r val
[[ "$val" =~ ^-?[0-9]+$ ]] && VOICE_PITCH=$val
;;
2)
echo -ne " ${BOLD}Overdrive gain (0=off, 5-20): ${NC}"
read -r val
[[ "$val" =~ ^[0-9]+$ ]] && VOICE_OVERDRIVE=$val
;;
3)
echo -ne " ${BOLD}Flanger (0=off, 1=on): ${NC}"
read -r val
[[ "$val" =~ ^[01]$ ]] && VOICE_FLANGER=$val
;;
4)
echo -ne " ${BOLD}Echo delay (ms, 0=off, 20-200): ${NC}"
read -r val
[[ "$val" =~ ^[0-9]+$ ]] && VOICE_ECHO_DELAY=$val
if [ "$VOICE_ECHO_DELAY" -gt 0 ] 2>/dev/null; then
echo -ne " ${BOLD}Echo decay (1-9, maps to 0.1-0.9): ${NC}"
read -r val
[[ "$val" =~ ^[1-9]$ ]] && VOICE_ECHO_DECAY=$val
fi
;;
5)
echo -ne " ${BOLD}Highpass frequency (Hz, 0=off, 300-2000): ${NC}"
read -r val
[[ "$val" =~ ^[0-9]+$ ]] && VOICE_HIGHPASS=$val
;;
6)
echo -ne " ${BOLD}Tremolo speed (Hz, 0=off, 5-40): ${NC}"
read -r val
[[ "$val" =~ ^[0-9]+$ ]] && VOICE_TREMOLO=$val
;;
0|q|Q)
save_config
return
;;
*)
echo -e "\n ${RED}Invalid choice${NC}"
sleep 1
;;
esac
done
}
#=============================================================================
# MAIN MENU
#=============================================================================
show_banner() {
clear
echo ""
echo -e "${BOLD}${TOR_PURPLE} ╔╦╗┌─┐┬─┐┌┬┐┬┌┐┌┌─┐┬ ╔═╗┬ ┬┌─┐┌┐┌┌─┐${NC}"
echo -e "${BOLD}${TOR_PURPLE} ║ ├┤ ├┬┘│││││││├─┤│ ╠═╝├─┤│ ││││├┤ ${NC}"
echo -e "${BOLD}${TOR_PURPLE} ╩ └─┘┴└─┴ ┴┴┘└┘┴ ┴┴─┘╩ ┴ ┴└─┘┘└┘└─┘${NC}"
echo ""
echo -e " ${TOR_PURPLE}───────────────────────────────────────${NC}"
echo -e " ${TOR_PURPLE}${BOLD}Encrypted Voice & Chat${NC} ${DIM}over${NC} ${TOR_PURPLE}${BOLD}Tor${NC} ${DIM}Hidden Services${NC}"
echo -e " ${TOR_PURPLE}───────────────────────────────────────${NC}"
local cipher_display="${CIPHER^^}"
echo -e " ${DIM}v${VERSION} | Push-to-Talk | End-to-End ${cipher_display}${NC}\n"
}
main_menu() {
while true; do
show_banner
# Show quick status line
local tor_status="${RED}${NC}"
if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then
tor_status="${GREEN}${NC}"
fi
local secret_status="${RED}${NC}"
if [ -n "$SHARED_SECRET" ]; then
secret_status="${GREEN}${NC}"
fi
local sf_status="${RED}${NC}"
if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then
sf_status="${GREEN}${NC}"
fi
local al_status="${RED}${NC}"
if [ "$AUTO_LISTEN" -eq 1 ]; then
al_status="${GREEN}${NC}"
fi
local _ptt_d="SPACE"
[ "$PTT_KEY" != " " ] && _ptt_d="$PTT_KEY"
echo -e " ${DIM}Tor:${NC} $tor_status ${DIM}Secret:${NC} $secret_status ${DIM}SF:${NC} $sf_status ${DIM}AL:${NC} $al_status ${DIM}PTT:${NC} ${GREEN}[${_ptt_d}]${NC}\n"
echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}${NC} Listen for calls"
echo -e " ${BOLD}${WHITE}2${NC} ${CYAN}${NC} Call an onion address"
echo -e " ${BOLD}${WHITE}3${NC} ${CYAN}${NC} Show my onion address"
echo -e " ${BOLD}${WHITE}4${NC} ${CYAN}${NC} Set shared secret"
echo -e " ${BOLD}${WHITE}5${NC} ${CYAN}${NC} Test audio (loopback)"
echo -e " ${BOLD}${WHITE}6${NC} ${CYAN}${NC} Show status"
echo -e " ${BOLD}${WHITE}7${NC} ${CYAN}${NC} Install dependencies"
echo -e " ${BOLD}${WHITE}8${NC} ${CYAN}${NC} Start Tor"
echo -e " ${BOLD}${WHITE}9${NC} ${CYAN}${NC} Stop Tor"
echo -e " ${BOLD}${WHITE}10${NC}${CYAN}${NC} Restart Tor"
echo -e " ${BOLD}${WHITE}11${NC}${CYAN}${NC} Rotate onion address"
echo -e " ${BOLD}${WHITE}12${NC}${CYAN}${NC} Settings"
echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}${NC} ${RED}Quit${NC}"
echo ""
echo -ne " ${BOLD}Select: ${NC}"
# If auto-listen is active, poll for incoming calls without redrawing
local choice=""
if [ "$AUTO_LISTEN" -eq 1 ] && [ -n "$AUTO_LISTEN_PID" ]; then
echo -ne "${DIM}[Auto-listening...]${NC} " >&2
while true; do
# Check for incoming call
if check_auto_listen; then
choice=""
break
fi
# Try to read user input with short timeout
if read -r -t 1 choice 2>/dev/null; then
break # user typed something
fi
done
else
read -r choice
fi
[ -z "$choice" ] && continue
case "$choice" in
1) listen_for_call ;;
2) call_remote ;;
3)
local onion
onion=$(get_onion)
if [ -n "$onion" ]; then
echo -e "\n ${BOLD}${GREEN}Your address:${NC} ${WHITE}${BOLD}${onion}${NC}\n"
else
echo -e "\n ${YELLOW}Tor hidden service not running. Start Tor first (option 8).${NC}\n"
fi
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
;;
4) set_shared_secret ;;
5) test_audio
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
;;
6) show_status
echo -ne " ${DIM}Press Enter to continue...${NC}"
read -r
;;
7) install_deps
echo -ne "\n ${DIM}Press Enter to continue...${NC}"
read -r
;;
8)
start_tor
start_auto_listener
echo -ne "\n ${DIM}Press Enter to continue...${NC}"
read -r
;;
9)
stop_tor
echo -ne "\n ${DIM}Press Enter to continue...${NC}"
read -r
;;
10)
stop_tor
start_tor
start_auto_listener
echo -ne "\n ${DIM}Press Enter to continue...${NC}"
read -r
;;
11)
rotate_onion
echo -ne "\n ${DIM}Press Enter to continue...${NC}"
read -r
;;
12)
settings_menu
;;
0|q|Q)
echo -e "\n${GREEN}Goodbye!${NC}"
stop_tor
cleanup
exit 0
;;
*)
echo -e "\n ${RED}Invalid choice${NC}"
sleep 1
;;
esac
done
}
#=============================================================================
# ENTRY POINT
#=============================================================================
trap cleanup EXIT INT TERM
# Create data directories
mkdir -p "$DATA_DIR" "$AUDIO_DIR" "$PID_DIR" "$DATA_DIR/run"
# Clean any stale run files from previous sessions
rm -f "$DATA_DIR/run/"* 2>/dev/null || true
# Load saved config
load_config
# Handle command-line arguments
case "${1:-}" in
install)
install_deps
;;
test)
test_audio
;;
status)
show_status
;;
listen)
load_config
listen_for_call
;;
call)
load_config
if [ -n "${2:-}" ]; then
remote_onion="$2"
if [[ "$remote_onion" != *.onion ]]; then
remote_onion="${remote_onion}.onion"
fi
start_tor
call_remote
else
echo "Usage: $0 call <onion-address>"
fi
;;
help|-h|--help)
echo -e "${BOLD}${APP_NAME} v${VERSION}${NC}"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " (none) Interactive menu"
echo " install Install dependencies"
echo " test Run audio loopback test"
echo " status Show current status"
echo " listen Start listening for calls"
echo " call ADDR Call an onion address"
echo " help Show this help"
;;
*)
main_menu
;;
esac