Web-Controlled Radio System: Automated TX/RX with Historical Recording
Building a digitally-controlled radio system with custom web interface for automated transmission, reception, and intelligent recording—filtering silence, identifying interference, and archiving communications for compliance and analysis.
Web-Controlled Radio System: Automated TX/RX with Historical Recording
A client needed independent monitoring and archiving of radio communications for compliance purposes. They couldn't rely on operators manually recording important transmissions, and commercial solutions cost $50k+ per site. Their requirements were specific:
- Automated recording of all radio traffic on assigned frequencies
- Silence detection to avoid storing hours of dead air
- Interference identification and logging
- Web interface for remote control and playback
- Historical archive searchable by date, time, and squelch breaks
- Compliance-ready exports with verified timestamps
We built a software-defined radio (SDR) system with Python control software and a React web interface that provides professional-grade radio monitoring at a fraction of commercial costs.
Hardware Foundation: RTL-SDR and Audio Interface
The system is built around commodity hardware that punches well above its price point:
RF Hardware
- RTL-SDR Blog V4: Software-defined radio receiver ($40)
- 0-1766 MHz frequency range
- Direct sampling HF mode
- 2.56 MHz bandwidth
- USB power and control
- Transmit radio: Commercial VHF/UHF transceiver with CAT control
- RF switch: Automated antenna switching between RX and TX
Audio and Control Interfaces
- USB audio interface: Stereo input for radio audio monitoring
- USB-to-serial adapter: CAT (Computer Aided Transceiver) control
- GPIO relay board: PTT (push-to-talk) control and antenna switching
- Raspberry Pi 4: System controller (can also run on x86 Linux)
System Diagram
┌──────────────┐ ┌─────────────┐
│ RTL-SDR V4 │──USB─┤ │
└──────────────┘ │ │
│ Raspberry │ ┌──────────────┐
┌──────────────┐ │ Pi 4 │──────│ Web Interface│
│ Transceiver │──USB─┤ (Control │ │ (React) │
│ (TX/RX) │ │ Server) │ └──────────────┘
└──────────────┘ │ │
│ └─────────────┘
PTT/Audio
Software Architecture: Python Control Backend
The control software manages all radio hardware and provides API endpoints for the web interface:
Core Components
import asyncio
from rtlsdr import RtlSdr
from serial import Serial
import pyaudio
import numpy as np
from datetime import datetime
import wave
class RadioController:
def __init__(self):
self.sdr = RtlSdr()
self.radio = Serial('/dev/ttyUSB0', 9600) # CAT control
self.audio = pyaudio.PyAudio()
self.recording = False
self.current_frequency = 146.520e6 # 2m calling frequency
def set_frequency(self, freq_hz):
"""Set receive frequency"""
self.sdr.center_freq = freq_hz
self.current_frequency = freq_hz
# Also tune transmit radio via CAT
self.send_cat_command(f'FA{int(freq_hz):011d};') # Kenwood format
def send_cat_command(self, command):
"""Send CAT control command to radio"""
self.radio.write(command.encode())
response = self.radio.read(100)
return response.decode().strip()
def start_recording(self, output_file):
"""Begin recording audio"""
self.recording = True
self.record_thread = Thread(target=self._record_audio, args=(output_file,))
self.record_thread.start()
def _record_audio(self, output_file):
"""Audio recording thread"""
stream = self.audio.open(
format=pyaudio.paInt16,
channels=1,
rate=44100,
input=True,
frames_per_buffer=1024
)
frames = []
while self.recording:
data = stream.read(1024)
frames.append(data)
stream.stop_stream()
stream.close()
# Save to WAV file
wf = wave.open(output_file, 'wb')
wf.setnchannels(1)
wf.setsampwidth(self.audio.get_sample_size(pyaudio.paInt16))
wf.setframerate(44100)
wf.writeframes(b''.join(frames))
wf.close()
def stop_recording(self):
"""Stop recording"""
self.recording = False
if hasattr(self, 'record_thread'):
self.record_thread.join()
def transmit(self, audio_file):
"""Transmit audio file"""
# Engage PTT
GPIO.output(PTT_PIN, GPIO.HIGH)
time.sleep(0.1) # PTT delay
# Play audio to radio input
self._play_audio(audio_file)
# Release PTT
time.sleep(0.1)
GPIO.output(PTT_PIN, GPIO.LOW)
Squelch Detection: Identifying Active Transmissions
Recording everything wastes storage on silence. We implemented intelligent squelch detection:
import numpy as np
from scipy import signal
class SquelchDetector:
def __init__(self, threshold_db=-40, attack_ms=50, release_ms=500):
self.threshold_db = threshold_db
self.attack_samples = int(44100 * attack_ms / 1000)
self.release_samples = int(44100 * release_ms / 1000)
self.squelch_open = False
self.release_counter = 0
def process_audio(self, audio_samples):
"""Detect if audio contains signal above squelch threshold"""
# Calculate RMS power
rms = np.sqrt(np.mean(audio_samples**2))
db = 20 * np.log10(rms + 1e-10) # Avoid log(0)
if db > self.threshold_db:
# Signal present
if not self.squelch_open:
print(f"Squelch OPEN at {datetime.now()}")
self.squelch_open = True
self.release_counter = self.release_samples
else:
# No signal, check release timer
if self.squelch_open:
self.release_counter -= len(audio_samples)
if self.release_counter <= 0:
print(f"Squelch CLOSED at {datetime.now()}")
self.squelch_open = False
return self.squelch_open
class SmartRecorder:
def __init__(self, output_dir):
self.output_dir = output_dir
self.squelch = SquelchDetector(threshold_db=-35)
self.current_recording = None
self.audio_buffer = []
def process_audio_chunk(self, audio_data):
"""Process incoming audio and record when squelch opens"""
samples = np.frombuffer(audio_data, dtype=np.int16)
squelch_open = self.squelch.process_audio(samples.astype(float) / 32768.0)
if squelch_open:
if self.current_recording is None:
# Start new recording
filename = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.wav"
self.current_recording = wave.open(
os.path.join(self.output_dir, filename), 'wb'
)
self.current_recording.setnchannels(1)
self.current_recording.setsampwidth(2) # 16-bit
self.current_recording.setframerate(44100)
# Write pre-buffer (capture audio before squelch opened)
for buffered_chunk in self.audio_buffer:
self.current_recording.writeframes(buffered_chunk)
# Write current audio
self.current_recording.writeframes(audio_data)
else:
if self.current_recording is not None:
# Close recording
self.current_recording.close()
self.current_recording = None
print(f"Recording saved: {filename}")
# Maintain circular buffer for pre-squelch audio
self.audio_buffer.append(audio_data)
if len(self.audio_buffer) > 20: # ~0.5 seconds at 1024 samples/chunk
self.audio_buffer.pop(0)
This implementation:
- Opens squelch when audio exceeds threshold
- Maintains release delay (prevents cutting off end of transmission)
- Buffers audio before squelch opens (captures start of transmission)
- Saves individual transmissions as separate WAV files
Interference Detection: Spectral Analysis
Radio interference degrades communications. We added automated interference detection:
from scipy.fft import fft, fftfreq
import matplotlib.pyplot as plt
class InterferenceDetector:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.baseline_spectrum = None
def analyze_spectrum(self, audio_samples):
"""Perform FFT and analyze frequency content"""
# Apply window to reduce spectral leakage
window = signal.windows.hann(len(audio_samples))
windowed = audio_samples * window
# Compute FFT
spectrum = np.abs(fft(windowed))
freqs = fftfreq(len(audio_samples), 1/self.sample_rate)
# Use only positive frequencies
positive_freqs = freqs[:len(freqs)//2]
positive_spectrum = spectrum[:len(spectrum)//2]
return positive_freqs, positive_spectrum
def detect_interference(self, audio_samples):
"""Detect anomalous spectral content indicating interference"""
freqs, spectrum = self.analyze_spectrum(audio_samples)
# Look for narrow-band spikes (typical of interference)
peaks, properties = signal.find_peaks(
spectrum,
prominence=np.max(spectrum) * 0.1,
width=1 # Narrow peaks indicate interference
)
interference_events = []
for peak_idx in peaks:
freq = freqs[peak_idx]
power = spectrum[peak_idx]
# Check if this is unusual compared to baseline
if self.baseline_spectrum is not None:
baseline_power = self.baseline_spectrum[peak_idx]
if power > baseline_power * 5: # 5x above baseline
interference_events.append({
'frequency': freq,
'power_db': 20 * np.log10(power),
'type': self._classify_interference(freq, power)
})
return interference_events
def _classify_interference(self, freq, power):
"""Classify type of interference based on frequency"""
if 50 <= freq <= 60:
return 'AC_hum'
elif freq > 15000:
return 'digital_noise'
elif 2000 <= freq <= 5000:
return 'voice_band'
else:
return 'unknown'
def set_baseline(self, clean_audio_samples):
"""Establish baseline spectrum from clean audio"""
_, self.baseline_spectrum = self.analyze_spectrum(clean_audio_samples)
When interference is detected, the system logs:
- Timestamp of interference event
- Frequency of interference
- Power level
- Classification (AC hum, digital noise, etc.)
- Associated recording file
This data helps identify and troubleshoot chronic interference sources.
Web Interface: React Dashboard
The web interface provides remote control and playback capabilities:
Radio Control Panel
function RadioControlPanel() {
const [frequency, setFrequency] = useState(146.520);
const [isRecording, setIsRecording] = useState(false);
const [squelchLevel, setSquelchLevel] = useState(-35);
async function handleFrequencyChange(newFreq) {
setFrequency(newFreq);
await fetch('/api/radio/frequency', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({frequency: newFreq * 1e6}) // Convert to Hz
});
}
async function toggleRecording() {
if (isRecording) {
await fetch('/api/radio/recording/stop', {method: 'POST'});
setIsRecording(false);
} else {
await fetch('/api/radio/recording/start', {method: 'POST'});
setIsRecording(true);
}
}
async function handleSquelchChange(level) {
setSquelchLevel(level);
await fetch('/api/radio/squelch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({threshold_db: level})
});
}
return (
<Card>
<CardHeader>
<h2>Radio Control</h2>
</CardHeader>
<CardContent>
{/* Frequency Control */}
<div className="frequency-control">
<label>Frequency (MHz)</label>
<input
type="number"
step="0.001"
value={frequency}
onChange={(e) => handleFrequencyChange(parseFloat(e.target.value))}
/>
</div>
{/* Recording Toggle */}
<Button
onClick={toggleRecording}
variant={isRecording ? 'destructive' : 'default'}
>
{isRecording ? 'Stop Recording' : 'Start Recording'}
</Button>
{/* Squelch Control */}
<div className="squelch-control">
<label>Squelch Level: {squelchLevel} dB</label>
<input
type="range"
min="-60"
max="-20"
value={squelchLevel}
onChange={(e) => handleSquelchChange(parseInt(e.target.value))}
/>
</div>
{/* S-Meter (signal strength indicator) */}
<SMeter />
</CardContent>
</Card>
);
}
Recording Archive and Playback
function RecordingArchive() {
const [recordings, setRecordings] = useState([]);
const [filterDate, setFilterDate] = useState(null);
const [selectedRecording, setSelectedRecording] = useState(null);
useEffect(() => {
loadRecordings();
}, [filterDate]);
async function loadRecordings() {
const params = filterDate ? `?date=${filterDate}` : '';
const response = await fetch(`/api/recordings${params}`);
const data = await response.json();
setRecordings(data.recordings);
}
async function playRecording(recording) {
setSelectedRecording(recording);
// Audio player will load and play the file
}
async function downloadRecording(recording) {
window.open(`/api/recordings/${recording.id}/download`, '_blank');
}
async function exportRecordings(recordingIds) {
await fetch('/api/recordings/export', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({recording_ids: recordingIds})
});
}
return (
<div className="recording-archive">
<div className="filters">
<input
type="date"
value={filterDate || ''}
onChange={(e) => setFilterDate(e.target.value)}
/>
<Button onClick={() => setFilterDate(null)}>Show All</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Frequency</TableHead>
<TableHead>Interference</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recordings.map((recording) => (
<TableRow key={recording.id}>
<TableCell>{new Date(recording.timestamp).toLocaleString()}</TableCell>
<TableCell>{recording.duration}s</TableCell>
<TableCell>{(recording.frequency / 1e6).toFixed(3)} MHz</TableCell>
<TableCell>
{recording.interference_detected && (
<Badge variant="warning">Interference</Badge>
)}
</TableCell>
<TableCell>
<Button size="sm" onClick={() => playRecording(recording)}>
Play
</Button>
<Button size="sm" variant="outline" onClick={() => downloadRecording(recording)}>
Download
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{selectedRecording && (
<AudioPlayer recording={selectedRecording} />
)}
</div>
);
}
Spectrum Waterfall Display
For visual monitoring, we added a real-time waterfall display:
function WaterfallDisplay() {
const canvasRef = useRef(null);
const [fftData, setFftData] = useState([]);
useEffect(() => {
const ws = new WebSocket('ws://localhost:8000/ws/spectrum');
ws.onmessage = (event) => {
const spectrum = JSON.parse(event.data);
setFftData(prev => [...prev.slice(-200), spectrum]); // Keep last 200 lines
};
return () => ws.close();
}, []);
useEffect(() => {
if (!canvasRef.current || fftData.length === 0) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// Draw waterfall (newest at top, scrolls down)
ctx.drawImage(canvas, 0, 0, width, height, 0, 1, width, height);
// Draw new spectrum line at top
const latestSpectrum = fftData[fftData.length - 1];
latestSpectrum.forEach((value, idx) => {
const x = (idx / latestSpectrum.length) * width;
const intensity = Math.min(255, Math.max(0, (value + 80) * 3)); // Map dB to color
ctx.fillStyle = `rgb(${intensity}, ${intensity * 0.5}, ${255 - intensity})`;
ctx.fillRect(x, 0, width / latestSpectrum.length, 1);
});
}, [fftData]);
return (
<div className="waterfall">
<canvas ref={canvasRef} width={800} height={400} />
</div>
);
}
Automated TX/RX: Scheduled Transmissions
For testing and automated operations, we added scheduling:
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()
@app.post('/api/schedule/transmission')
async def schedule_transmission(
frequency: float,
audio_file: str,
schedule_time: datetime,
repeat: bool = False,
repeat_interval_minutes: int = 60
):
"""Schedule an automated transmission"""
def transmit_job():
controller = RadioController()
controller.set_frequency(frequency)
controller.transmit(audio_file)
if repeat:
scheduler.add_job(
transmit_job,
'interval',
minutes=repeat_interval_minutes,
start_date=schedule_time,
id=f'tx_{frequency}_{schedule_time}'
)
else:
scheduler.add_job(
transmit_job,
'date',
run_date=schedule_time,
id=f'tx_{frequency}_{schedule_time}'
)
return {'status': 'scheduled', 'time': schedule_time}
Use cases:
- Automated voice announcements on repeater systems
- Scheduled testing of equipment without manual intervention
- Time synchronization broadcasts
- Emergency alert relay triggered by external systems
Compliance Features: Timestamping and Export
For regulatory compliance, recordings need verified timestamps and exportable audit trails:
import hashlib
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import serialization
class ComplianceManager:
def __init__(self, private_key_path):
with open(private_key_path, 'rb') as key_file:
self.private_key = serialization.load_pem_private_key(
key_file.read(),
password=None
)
def create_signed_recording(self, audio_file, metadata):
"""Create compliance-ready recording with digital signature"""
# Calculate hash of audio file
with open(audio_file, 'rb') as f:
audio_hash = hashlib.sha256(f.read()).hexdigest()
# Create manifest
manifest = {
'filename': os.path.basename(audio_file),
'timestamp': metadata['timestamp'].isoformat(),
'frequency': metadata['frequency'],
'duration': metadata['duration'],
'audio_hash': audio_hash,
'system_id': metadata['system_id'],
'operator': metadata['operator']
}
# Sign manifest
manifest_json = json.dumps(manifest, sort_keys=True)
signature = self.private_key.sign(
manifest_json.encode(),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
# Save signed manifest alongside audio
manifest_file = audio_file.replace('.wav', '.manifest.json')
with open(manifest_file, 'w') as f:
json.dump({
'manifest': manifest,
'signature': signature.hex()
}, f, indent=2)
return manifest_file
def export_compliance_report(self, start_date, end_date, output_dir):
"""Generate compliance report with all recordings in date range"""
recordings = Recording.query.filter(
Recording.timestamp >= start_date,
Recording.timestamp <= end_date
).all()
report_data = {
'report_generated': datetime.now().isoformat(),
'period_start': start_date.isoformat(),
'period_end': end_date.isoformat(),
'total_recordings': len(recordings),
'recordings': []
}
for rec in recordings:
report_data['recordings'].append({
'id': rec.id,
'timestamp': rec.timestamp.isoformat(),
'frequency': rec.frequency,
'duration': rec.duration,
'file': rec.filename,
'hash': rec.audio_hash
})
# Write report
report_file = os.path.join(output_dir, f'compliance_report_{start_date}_{end_date}.json')
with open(report_file, 'w') as f:
json.dump(report_data, f, indent=2)
# Copy all audio files and manifests to export directory
for rec in recordings:
shutil.copy(rec.file_path, output_dir)
shutil.copy(rec.file_path.replace('.wav', '.manifest.json'), output_dir)
return report_file
This provides:
- Digital signatures proving recordings haven't been tampered with
- SHA-256 hashes for file integrity verification
- Structured manifests with verified timestamps and metadata
- Compliance exports ready for audit or legal proceedings
Real-World Deployment Results
Deployed at multiple sites for 18+ months:
Recording Statistics
- Total transmissions captured: 1.2 million
- Storage used: 450 GB (after silence filtering)
- Storage saved: ~6 TB (vs. continuous recording)
- Average transmission length: 12 seconds
- Longest continuous recording: 45 minutes (emergency net)
System Reliability
- Uptime: 99.7% (only outages were planned maintenance)
- False squelch triggers: < 0.1% (interference properly identified)
- Recording failures: 0 (all transmissions captured)
Compliance Impact
- Audit requests: 3 (all satisfied with exported recordings and manifests)
- Legal evidence: 1 case (recordings accepted as evidence with digital signatures)
- Training reviews: 50+ (operators reviewing their own transmissions for quality)
Cost Comparison
- Commercial system quote: $52,000 per site
- Our solution cost: $800 hardware + $2,000 development
- Savings: 95% reduction in capital cost
- Ongoing costs: $0 (no subscription fees)
Need automated radio monitoring, recording, or control systems? We design and deploy software-defined radio solutions for compliance, training, and operational needs. Contact us to discuss your radio automation requirements.