Files
wedding-phone/rotary_phone_web.py
grabowski ef0373e60b Add external configuration file system
Breaking Changes:
- Configuration now via config.json instead of editing Python code
- Remove all hardcoded paths (no more /home/berwn)
- Separate system config (config.json) from runtime config (user_config.json)

Features:
- config.example.json with all configurable options
- GPIO pin and state configuration
- Audio device index configuration
- Customizable paths (relative or absolute)
- Web port and upload size settings
- No code editing required for deployment

Configuration Structure:
- gpio: hook_pin, hook_pressed_state
- audio: device_index, chunk_size, channels, sample_rate, max_record_seconds
- paths: base_dir, recordings_dir, sounds_dir
- web: port, max_upload_size_mb
- system: active_greeting, default_volume

Script automatically:
- Checks for config.json on startup
- Provides helpful error if missing
- Uses relative paths by default
- Loads test_complete.py config from same file

Updated Documentation:
- Complete configuration guide in README
- Setup instructions without hardcoded paths
- Troubleshooting for config errors
- Device index discovery command

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 14:48:43 +07:00

1388 lines
46 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 python3
"""
Rotary Phone Audio Handler with Web Interface
Detects handset pickup, plays custom sound, records audio, and provides web UI
"""
import pyaudio
import wave
import time
import RPi.GPIO as GPIO
from datetime import datetime
import os
import numpy as np
import threading
from flask import Flask, render_template, request, send_file, jsonify, redirect, url_for
from werkzeug.utils import secure_filename
import json
import sys
# Load configuration
def load_system_config():
"""Load system configuration from config.json"""
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
# Check if config exists, otherwise use example
if not os.path.exists(config_path):
example_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.example.json')
if os.path.exists(example_path):
print(f"Config file not found. Please copy {example_path} to {config_path}")
print("Command: cp config.example.json config.json")
sys.exit(1)
else:
print("ERROR: No configuration file found!")
sys.exit(1)
with open(config_path, 'r') as f:
return json.load(f)
# Load system configuration
SYS_CONFIG = load_system_config()
# GPIO Configuration
HOOK_PIN = SYS_CONFIG['gpio']['hook_pin']
HOOK_PRESSED = GPIO.LOW if SYS_CONFIG['gpio']['hook_pressed_state'] == 'LOW' else GPIO.HIGH
# Audio settings
AUDIO_DEVICE_INDEX = SYS_CONFIG['audio']['device_index']
CHUNK = SYS_CONFIG['audio']['chunk_size']
FORMAT = pyaudio.paInt16 # Fixed format
CHANNELS = SYS_CONFIG['audio']['channels']
RATE = SYS_CONFIG['audio']['sample_rate']
RECORD_SECONDS = SYS_CONFIG['audio']['max_record_seconds']
# Directories
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.join(SCRIPT_DIR, SYS_CONFIG['paths']['base_dir']) if SYS_CONFIG['paths']['base_dir'].startswith('.') else SYS_CONFIG['paths']['base_dir']
BASE_DIR = os.path.abspath(BASE_DIR)
OUTPUT_DIR = os.path.join(BASE_DIR, SYS_CONFIG['paths']['recordings_dir'])
SOUNDS_DIR = os.path.join(BASE_DIR, SYS_CONFIG['paths']['sounds_dir'])
USER_CONFIG_FILE = os.path.join(BASE_DIR, "user_config.json") # Runtime user settings
DIALTONE_FILE = os.path.join(SOUNDS_DIR, "dialtone.wav")
# Web server settings
WEB_PORT = SYS_CONFIG['web']['port']
# Flask app
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = SYS_CONFIG['web']['max_upload_size_mb'] * 1024 * 1024
class RotaryPhone:
def __init__(self):
self.audio = pyaudio.PyAudio()
self.recording = False
self.phone_status = "on_hook"
self.current_recording = None
# Setup GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(HOOK_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
# Create directories
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(SOUNDS_DIR, exist_ok=True)
# Initialize configuration
self.config = self.load_config()
# Generate default dial tone if none exists
if not os.path.exists(DIALTONE_FILE):
self.generate_default_dialtone()
def load_config(self):
"""Load user runtime configuration from JSON file"""
default_config = {
"active_greeting": SYS_CONFIG['system']['active_greeting'],
"greetings": [],
"volume": SYS_CONFIG['system']['volume']
}
if os.path.exists(USER_CONFIG_FILE):
try:
with open(USER_CONFIG_FILE, 'r') as f:
config = json.load(f)
# Ensure volume key exists
if "volume" not in config:
config["volume"] = SYS_CONFIG['system']['volume']
return config
except:
pass
return default_config
def save_config(self):
"""Save user runtime configuration to JSON file"""
with open(USER_CONFIG_FILE, 'w') as f:
json.dump(self.config, f, indent=2)
def get_active_greeting_path(self):
"""Get the full path to the active greeting file"""
active = self.config.get("active_greeting", "dialtone.wav")
return os.path.join(SOUNDS_DIR, active)
def set_active_greeting(self, filename):
"""Set which greeting message to play"""
self.config["active_greeting"] = filename
self.save_config()
def set_volume(self, volume):
"""Set playback volume (0-100)"""
volume = max(0, min(100, int(volume))) # Clamp between 0-100
self.config["volume"] = volume
self.save_config()
return volume
def get_volume(self):
"""Get current volume setting"""
return self.config.get("volume", 70)
def generate_default_dialtone(self):
"""Generate a classic dial tone (350Hz + 440Hz) and save as default"""
print("Generating default dial tone...")
duration = 3
sample_rate = 44100
t = np.linspace(0, duration, int(sample_rate * duration), False)
# Generate two frequencies and combine them
tone1 = np.sin(2 * np.pi * 350 * t)
tone2 = np.sin(2 * np.pi * 440 * t)
tone = (tone1 + tone2) / 2
# Convert to 16-bit PCM
tone = (tone * 32767).astype(np.int16)
# Save as WAV file
wf = wave.open(DIALTONE_FILE, 'wb')
wf.setnchannels(1)
wf.setsampwidth(2) # 16-bit
wf.setframerate(sample_rate)
wf.writeframes(tone.tobytes())
wf.close()
print(f"Default dial tone saved to {DIALTONE_FILE}")
def play_sound_file(self, filepath):
"""Play a WAV file with volume control"""
if not os.path.exists(filepath):
print(f"Sound file not found: {filepath}")
return False
print(f"Playing sound: {filepath}")
try:
wf = wave.open(filepath, 'rb')
# Use configured audio device
stream = self.audio.open(
format=self.audio.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
output_device_index=AUDIO_DEVICE_INDEX,
frames_per_buffer=CHUNK
)
# Get volume multiplier (0.0 to 1.0)
volume = self.get_volume() / 100.0
# Play the sound with volume control
data = wf.readframes(CHUNK)
while data and self.phone_status == "off_hook":
# Apply volume by converting to numpy array and scaling
if volume < 1.0:
audio_data = np.frombuffer(data, dtype=np.int16)
audio_data = (audio_data * volume).astype(np.int16)
data = audio_data.tobytes()
stream.write(data)
data = wf.readframes(CHUNK)
stream.stop_stream()
stream.close()
wf.close()
print("Sound playback finished")
return True
except Exception as e:
print(f"Error playing sound: {e}")
return False
def record_audio(self):
"""Record audio from the microphone"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(OUTPUT_DIR, f"recording_{timestamp}.wav")
self.current_recording = filename
print(f"Recording to {filename}")
try:
# Use configured audio device
stream = self.audio.open(
format=FORMAT,
channels=CHANNELS,
rate=RATE,
input=True,
input_device_index=AUDIO_DEVICE_INDEX,
frames_per_buffer=CHUNK
)
frames = []
self.recording = True
# Record until handset is hung up or max time reached
start_time = time.time()
while self.recording and (time.time() - start_time) < RECORD_SECONDS:
# Check if handset is still off hook
if GPIO.input(HOOK_PIN) != HOOK_PRESSED:
print("Handset hung up, stopping recording")
break
try:
data = stream.read(CHUNK, exception_on_overflow=False)
frames.append(data)
except Exception as e:
print(f"Error reading audio: {e}")
break
stream.stop_stream()
stream.close()
# Save the recording
if frames:
wf = wave.open(filename, 'wb')
wf.setnchannels(CHANNELS)
wf.setsampwidth(self.audio.get_sample_size(FORMAT))
wf.setframerate(RATE)
wf.writeframes(b''.join(frames))
wf.close()
duration = len(frames) * CHUNK / RATE
print(f"Recording saved: {filename} ({duration:.1f}s)")
else:
print("No audio recorded")
except Exception as e:
print(f"Recording error: {e}")
self.recording = False
self.current_recording = None
def get_status(self):
"""Get current phone status"""
return {
"status": self.phone_status,
"recording": self.recording,
"current_recording": self.current_recording
}
def phone_loop(self):
"""Main phone handling loop"""
print("Rotary Phone System Started")
print(f"Hook pin: GPIO {HOOK_PIN}")
print(f"Recordings will be saved to: {OUTPUT_DIR}")
try:
while True:
# Wait for handset pickup
if GPIO.input(HOOK_PIN) == HOOK_PRESSED and self.phone_status == "on_hook":
self.phone_status = "off_hook"
print("\n=== Handset picked up ===")
# Play active greeting message
greeting_file = self.get_active_greeting_path()
self.play_sound_file(greeting_file)
# Start recording
if self.phone_status == "off_hook": # Still off hook after sound
self.record_audio()
self.phone_status = "on_hook"
time.sleep(0.1)
except KeyboardInterrupt:
print("\nShutting down phone system...")
finally:
self.cleanup()
def cleanup(self):
"""Clean up resources"""
self.audio.terminate()
GPIO.cleanup()
print("Cleanup complete")
# Global phone instance
phone = RotaryPhone()
# Flask Routes
@app.route('/')
def index():
"""Main page"""
recordings = get_recordings()
greetings = get_greetings()
status = phone.get_status()
active_greeting = phone.config.get("active_greeting", "dialtone.wav")
volume = phone.get_volume()
return render_template('index.html',
recordings=recordings,
greetings=greetings,
active_greeting=active_greeting,
status=status,
volume=volume)
@app.route('/api/status')
def api_status():
"""API endpoint for phone status"""
return jsonify(phone.get_status())
@app.route('/api/recordings')
def api_recordings():
"""API endpoint for recordings list"""
return jsonify(get_recordings())
@app.route('/api/greetings')
def api_greetings():
"""API endpoint for greetings list"""
return jsonify(get_greetings())
@app.route('/api/volume', methods=['GET'])
def api_get_volume():
"""Get current volume setting"""
return jsonify({"volume": phone.get_volume()})
@app.route('/api/volume', methods=['POST'])
def api_set_volume():
"""Set volume level"""
data = request.get_json()
volume = data.get('volume', 70)
new_volume = phone.set_volume(volume)
return jsonify({"success": True, "volume": new_volume})
@app.route('/upload_greeting', methods=['POST'])
def upload_greeting():
"""Upload a new greeting message"""
if 'soundfile' not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files['soundfile']
if file.filename == '':
return jsonify({"error": "No file selected"}), 400
if file and file.filename.lower().endswith('.wav'):
filename = secure_filename(file.filename)
# Ensure unique filename
counter = 1
base_name = os.path.splitext(filename)[0]
while os.path.exists(os.path.join(SOUNDS_DIR, filename)):
filename = f"{base_name}_{counter}.wav"
counter += 1
filepath = os.path.join(SOUNDS_DIR, filename)
file.save(filepath)
return jsonify({"success": True, "message": f"Greeting '{filename}' uploaded successfully", "filename": filename})
return jsonify({"error": "Only WAV files are supported"}), 400
@app.route('/set_active_greeting', methods=['POST'])
def set_active_greeting():
"""Set which greeting to play"""
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({"error": "No filename provided"}), 400
filepath = os.path.join(SOUNDS_DIR, filename)
if not os.path.exists(filepath):
return jsonify({"error": "Greeting file not found"}), 404
phone.set_active_greeting(filename)
return jsonify({"success": True, "message": f"Active greeting set to '{filename}'"})
@app.route('/delete_greeting/<filename>', methods=['POST'])
def delete_greeting(filename):
"""Delete a greeting file"""
filename = secure_filename(filename)
filepath = os.path.join(SOUNDS_DIR, filename)
# Don't delete the active greeting
if filename == phone.config.get("active_greeting"):
return jsonify({"error": "Cannot delete the active greeting. Please select a different greeting first."}), 400
if os.path.exists(filepath):
os.remove(filepath)
return jsonify({"success": True})
return jsonify({"error": "File not found"}), 404
@app.route('/play_audio/<audio_type>/<filename>')
def play_audio(audio_type, filename):
"""Serve audio file for web playback"""
filename = secure_filename(filename)
if audio_type == 'recording':
filepath = os.path.join(OUTPUT_DIR, filename)
elif audio_type == 'greeting':
filepath = os.path.join(SOUNDS_DIR, filename)
else:
return "Invalid audio type", 400
if os.path.exists(filepath):
return send_file(filepath, mimetype='audio/wav')
return "File not found", 404
@app.route('/download/<filename>')
def download_recording(filename):
"""Download a recording"""
filepath = os.path.join(OUTPUT_DIR, secure_filename(filename))
if os.path.exists(filepath):
return send_file(filepath, as_attachment=True)
return "File not found", 404
@app.route('/delete/<filename>', methods=['POST'])
def delete_recording(filename):
"""Delete a recording"""
filepath = os.path.join(OUTPUT_DIR, secure_filename(filename))
if os.path.exists(filepath):
os.remove(filepath)
return jsonify({"success": True})
return jsonify({"error": "File not found"}), 404
@app.route('/restore_default_sound', methods=['POST'])
def restore_default_sound():
"""Restore default dial tone"""
if os.path.exists(DIALTONE_FILE):
os.remove(DIALTONE_FILE)
phone.generate_default_dialtone()
phone.set_active_greeting("dialtone.wav")
return jsonify({"success": True, "message": "Default dial tone restored"})
def get_greetings():
"""Get list of all greeting sound files"""
greetings = []
if os.path.exists(SOUNDS_DIR):
for filename in sorted(os.listdir(SOUNDS_DIR)):
if filename.endswith('.wav'):
filepath = os.path.join(SOUNDS_DIR, filename)
stat = os.stat(filepath)
# Get duration from WAV file
try:
wf = wave.open(filepath, 'rb')
frames = wf.getnframes()
rate = wf.getframerate()
duration = frames / float(rate)
wf.close()
except:
duration = 0
greetings.append({
"filename": filename,
"size": stat.st_size,
"size_mb": stat.st_size / (1024 * 1024),
"date": datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
"duration": duration,
"is_active": filename == phone.config.get("active_greeting", "dialtone.wav")
})
return greetings
def get_recordings():
"""Get list of all recordings with metadata"""
recordings = []
if os.path.exists(OUTPUT_DIR):
for filename in sorted(os.listdir(OUTPUT_DIR), reverse=True):
if filename.endswith('.wav'):
filepath = os.path.join(OUTPUT_DIR, filename)
stat = os.stat(filepath)
# Get duration from WAV file
try:
wf = wave.open(filepath, 'rb')
frames = wf.getnframes()
rate = wf.getframerate()
duration = frames / float(rate)
wf.close()
except:
duration = 0
recordings.append({
"filename": filename,
"size": stat.st_size,
"size_mb": stat.st_size / (1024 * 1024),
"date": datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
"duration": duration
})
return recordings
def get_local_ip():
"""Get local IP address"""
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except:
return "127.0.0.1"
if __name__ == "__main__":
# Create templates directory and HTML template
script_dir = os.path.dirname(os.path.abspath(__file__))
templates_dir = os.path.join(script_dir, 'templates')
os.makedirs(templates_dir, exist_ok=True)
# Create the HTML template if it doesn't exist
template_file = os.path.join(templates_dir, 'index.html')
if not os.path.exists(template_file):
print(f"Creating template at {template_file}")
with open(template_file, 'w') as f:
f.write('''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rotary Phone Control Panel</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.card {
background: white;
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.status-card {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-indicator {
display: flex;
align-items: center;
gap: 10px;
}
.status-dot {
width: 20px;
height: 20px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.on-hook {
background: #10b981;
}
.status-dot.off-hook {
background: #ef4444;
}
.status-dot.recording {
background: #f59e0b;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.card h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5em;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.upload-section {
border: 2px dashed #667eea;
border-radius: 10px;
padding: 30px;
text-align: center;
margin-bottom: 20px;
background: #f8f9fa;
}
.upload-section input[type="file"] {
display: none;
}
.upload-label {
display: inline-block;
padding: 12px 30px;
background: #667eea;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.upload-label:hover {
background: #5568d3;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s;
font-size: 0.95em;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.recordings-grid {
display: grid;
gap: 15px;
}
.recording-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
transition: all 0.3s;
}
.recording-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
.recording-info {
flex-grow: 1;
}
.recording-info h3 {
color: #333;
font-size: 1em;
margin-bottom: 5px;
}
.recording-meta {
color: #6b7280;
font-size: 0.85em;
}
.recording-actions {
display: flex;
gap: 10px;
}
.no-recordings {
text-align: center;
padding: 40px;
color: #6b7280;
}
.alert {
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border-left: 4px solid #10b981;
}
.alert-error {
background: #fee2e2;
color: #991b1b;
border-left: 4px solid #ef4444;
}
.alert-info {
background: #dbeafe;
color: #1e40af;
border-left: 4px solid #3b82f6;
}
.filename-display {
font-family: 'Courier New', monospace;
background: #e9ecef;
padding: 5px 10px;
border-radius: 5px;
margin-top: 10px;
font-size: 0.9em;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.stat-box {
background: #667eea;
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9em;
opacity: 0.9;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.active-greeting {
border: 2px solid #667eea;
background: #f0f4ff !important;
}
.active-greeting:hover {
background: #e5edff !important;
}
/* Audio player modal */
.audio-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
align-items: center;
justify-content: center;
}
.audio-modal.show {
display: flex;
}
.audio-modal-content {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
max-width: 500px;
width: 90%;
}
.audio-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.audio-modal-header h3 {
margin: 0;
color: #333;
}
.close-modal {
font-size: 28px;
font-weight: bold;
color: #6b7280;
cursor: pointer;
border: none;
background: none;
padding: 0;
width: 30px;
height: 30px;
line-height: 1;
}
.close-modal:hover {
color: #333;
}
.audio-player-container {
text-align: center;
}
.audio-player-container audio {
width: 100%;
margin-top: 20px;
}
.audio-filename {
font-family: 'Courier New', monospace;
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
word-break: break-all;
}
/* Volume slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #5568d3;
transform: scale(1.2);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s;
}
input[type="range"]::-moz-range-thumb:hover {
background: #5568d3;
transform: scale(1.2);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📞 Rotary Phone Control Panel</h1>
<p>Manage your vintage phone system</p>
</div>
<div id="alert-container"></div>
<!-- Status Card -->
<div class="card status-card">
<div>
<h2>Phone Status</h2>
<div class="status-indicator">
<div class="status-dot {{ 'recording' if status.recording else ('off-hook' if status.status == 'off_hook' else 'on-hook') }}" id="status-dot"></div>
<span id="status-text">
{% if status.recording %}
🔴 Recording in progress...
{% elif status.status == 'off_hook' %}
📞 Handset off hook
{% else %}
✅ Ready (handset on hook)
{% endif %}
</span>
</div>
{% if status.current_recording %}
<p style="margin-top: 10px; color: #6b7280; font-size: 0.9em;">
Recording to: {{ status.current_recording.split('/')[-1] }}
</p>
{% endif %}
</div>
<button class="btn btn-secondary" onclick="refreshStatus()">🔄 Refresh</button>
</div>
<!-- Volume Control Card -->
<div class="card">
<h2>🔊 Volume Control</h2>
<div style="padding: 20px;">
<div style="display: flex; align-items: center; gap: 20px;">
<span style="font-size: 1.5em;">🔇</span>
<input type="range" id="volume-slider" min="0" max="100" value="{{ volume }}"
style="flex: 1; height: 8px; border-radius: 5px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ volume }}%, #ddd {{ volume }}%, #ddd 100%);">
<span style="font-size: 1.5em;">🔊</span>
<span id="volume-display" style="font-weight: bold; min-width: 50px; text-align: center; font-size: 1.2em;">{{ volume }}%</span>
</div>
<p style="margin-top: 15px; color: #6b7280; font-size: 0.9em; text-align: center;">
Adjust the playback volume for greeting messages
</p>
</div>
</div>
<!-- Greeting Messages Card -->
<div class="card">
<h2>🎵 Greeting Messages</h2>
<div class="alert alert-info">
Active greeting: <strong>{{ active_greeting }}</strong>
</div>
<div class="upload-section">
<p style="margin-bottom: 15px; color: #6b7280;">
Upload WAV files to play when the handset is picked up
</p>
<form id="upload-form" enctype="multipart/form-data">
<label for="soundfile" class="upload-label">
📁 Choose WAV File(s)
</label>
<input type="file" id="soundfile" name="soundfile" accept=".wav" multiple onchange="handleFileSelect(this)">
<div id="filename-display" class="filename-display" style="display: none;"></div>
</form>
<div class="button-group">
<button class="btn btn-primary" onclick="uploadGreeting()" id="upload-btn" disabled>
⬆️ Upload Greeting(s)
</button>
<button class="btn btn-secondary" onclick="restoreDefault()">
🔄 Generate Default Dial Tone
</button>
</div>
</div>
<!-- Greetings List -->
{% if greetings %}
<h3 style="margin-top: 30px; margin-bottom: 15px; color: #333;">Available Greetings</h3>
<div class="recordings-grid">
{% for greeting in greetings %}
<div class="recording-item {% if greeting.is_active %}active-greeting{% endif %}" id="greeting-{{ loop.index }}">
<div class="recording-info">
<h3>
{% if greeting.is_active %}⭐{% endif %} {{ greeting.filename }}
</h3>
<div class="recording-meta">
📅 {{ greeting.date }} |
⏱️ {{ "%.1f"|format(greeting.duration) }}s |
💾 {{ "%.2f"|format(greeting.size_mb) }} MB
</div>
</div>
<div class="recording-actions">
<button class="btn btn-success" onclick="playAudio('greeting', '{{ greeting.filename }}')">
▶️ Play
</button>
{% if not greeting.is_active %}
<button class="btn btn-primary" onclick="setActiveGreeting('{{ greeting.filename }}')">
⭐ Set Active
</button>
<button class="btn btn-danger" onclick="deleteGreeting('{{ greeting.filename }}', {{ loop.index }})">
🗑️ Delete
</button>
{% else %}
<button class="btn btn-secondary" disabled>
✓ Active
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-recordings" style="margin-top: 20px;">
<p style="font-size: 2em; margin-bottom: 10px;">🎵</p>
<p>No greeting messages uploaded yet. Upload your first greeting!</p>
</div>
{% endif %}
</div>
<!-- Recordings Card -->
<div class="card">
<h2>🎙️ Recordings</h2>
{% if recordings %}
<div class="stats">
<div class="stat-box">
<div class="stat-value">{{ recordings|length }}</div>
<div class="stat-label">Total Recordings</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ "%.1f"|format(recordings|sum(attribute='size_mb')) }} MB</div>
<div class="stat-label">Total Size</div>
</div>
<div class="stat-box">
<div class="stat-value">{{ "%.1f"|format(recordings|sum(attribute='duration')/60) }} min</div>
<div class="stat-label">Total Duration</div>
</div>
</div>
<div class="recordings-grid" style="margin-top: 30px;">
{% for recording in recordings %}
<div class="recording-item" id="recording-{{ loop.index }}">
<div class="recording-info">
<h3>{{ recording.filename }}</h3>
<div class="recording-meta">
📅 {{ recording.date }} |
⏱️ {{ "%.1f"|format(recording.duration) }}s |
💾 {{ "%.2f"|format(recording.size_mb) }} MB
</div>
</div>
<div class="recording-actions">
<button class="btn btn-success" onclick="playAudio('recording', '{{ recording.filename }}')">
▶️ Play
</button>
<button class="btn btn-primary" onclick="downloadRecording('{{ recording.filename }}')">
⬇️ Download
</button>
<button class="btn btn-danger" onclick="deleteRecording('{{ recording.filename }}', {{ loop.index }})">
🗑️ Delete
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-recordings">
<p style="font-size: 3em; margin-bottom: 10px;">📭</p>
<p>No recordings yet. Pick up the phone to start recording!</p>
</div>
{% endif %}
</div>
</div>
<!-- Audio Player Modal -->
<div id="audio-modal" class="audio-modal" onclick="closeAudioModal(event)">
<div class="audio-modal-content" onclick="event.stopPropagation()">
<div class="audio-modal-header">
<h3>🎵 Audio Player</h3>
<button class="close-modal" onclick="closeAudioModal()">&times;</button>
</div>
<div class="audio-player-container">
<div class="audio-filename" id="audio-filename"></div>
<audio id="audio-player" controls autoplay>
Your browser does not support the audio element.
</audio>
</div>
</div>
</div>
<script>
let selectedFiles = null;
let volumeUpdateTimer = null;
// Volume control
const volumeSlider = document.getElementById('volume-slider');
const volumeDisplay = document.getElementById('volume-display');
volumeSlider.addEventListener('input', function() {
const value = this.value;
volumeDisplay.textContent = value + '%';
// Update slider background gradient
this.style.background = `linear-gradient(to right, #667eea 0%, #667eea ${value}%, #ddd ${value}%, #ddd 100%)`;
// Debounce API call
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = setTimeout(() => {
updateVolume(value);
}, 300);
});
function updateVolume(volume) {
fetch('/api/volume', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ volume: parseInt(volume) })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Volume updated to ' + data.volume + '%');
}
})
.catch(error => console.error('Error updating volume:', error));
}
function handleFileSelect(input) {
if (input.files && input.files.length > 0) {
selectedFiles = input.files;
const fileCount = selectedFiles.length;
const displayText = fileCount === 1
? '📄 ' + selectedFiles[0].name
: `📄 ${fileCount} files selected`;
document.getElementById('filename-display').textContent = displayText;
document.getElementById('filename-display').style.display = 'block';
document.getElementById('upload-btn').disabled = false;
}
}
async function uploadGreeting() {
if (!selectedFiles || selectedFiles.length === 0) {
showAlert('Please select file(s) first', 'error');
return;
}
document.getElementById('upload-btn').disabled = true;
document.getElementById('upload-btn').textContent = '⏳ Uploading...';
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < selectedFiles.length; i++) {
const formData = new FormData();
formData.append('soundfile', selectedFiles[i]);
try {
const response = await fetch('/upload_greeting', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
successCount++;
} else {
errorCount++;
}
} catch (error) {
errorCount++;
}
}
if (errorCount === 0) {
showAlert(`${successCount} greeting(s) uploaded successfully! ✓`, 'success');
} else {
showAlert(`${successCount} succeeded, ${errorCount} failed`, 'error');
}
setTimeout(() => location.reload(), 1500);
}
function restoreDefault() {
if (confirm('Restore the default dial tone sound?')) {
fetch('/restore_default_sound', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('Default dial tone restored! ✓', 'success');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
function downloadRecording(filename) {
window.location.href = '/download/' + filename;
}
function deleteRecording(filename, index) {
if (confirm('Delete this recording?')) {
fetch('/delete/' + filename, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('recording-' + index).remove();
showAlert('Recording deleted! ✓', 'success');
setTimeout(() => location.reload(), 1500);
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
function playAudio(type, filename) {
const modal = document.getElementById('audio-modal');
const player = document.getElementById('audio-player');
const filenameDisplay = document.getElementById('audio-filename');
filenameDisplay.textContent = filename;
player.src = '/play_audio/' + type + '/' + filename;
modal.classList.add('show');
}
function closeAudioModal(event) {
const modal = document.getElementById('audio-modal');
const player = document.getElementById('audio-player');
player.pause();
player.src = '';
modal.classList.remove('show');
}
function setActiveGreeting(filename) {
fetch('/set_active_greeting', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: filename })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Greeting set to '${filename}'! ✓`, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
function deleteGreeting(filename, index) {
if (confirm(`Delete greeting '${filename}'?`)) {
fetch('/delete_greeting/' + filename, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('greeting-' + index).remove();
showAlert('Greeting deleted! ✓', 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
function refreshStatus() {
fetch('/api/status')
.then(response => response.json())
.then(data => {
location.reload();
});
}
function showAlert(message, type) {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-' + type;
alertDiv.textContent = message;
const container = document.getElementById('alert-container');
container.innerHTML = '';
container.appendChild(alertDiv);
setTimeout(() => {
alertDiv.remove();
}, 5000);
}
// Auto-refresh status every 5 seconds
setInterval(() => {
fetch('/api/status')
.then(response => response.json())
.then(data => {
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
statusDot.className = 'status-dot';
if (data.recording) {
statusDot.classList.add('recording');
statusText.textContent = '🔴 Recording in progress...';
} else if (data.status === 'off_hook') {
statusDot.classList.add('off-hook');
statusText.textContent = '📞 Handset off hook';
} else {
statusDot.classList.add('on-hook');
statusText.textContent = '✅ Ready (handset on hook)';
}
});
}, 5000);
</script>
</body>
</html>''')
else:
print(f"Template already exists at {template_file}")
# Start phone handling in separate thread
phone_thread = threading.Thread(target=phone.phone_loop, daemon=True)
phone_thread.start()
# Get and display local IP
local_ip = get_local_ip()
print("\n" + "="*60)
print(f"Web Interface Available At:")
print(f" http://{local_ip}:{WEB_PORT}")
print(f" http://localhost:{WEB_PORT}")
print("="*60 + "\n")
# Start Flask web server
app.run(host='0.0.0.0', port=WEB_PORT, debug=False, threaded=True)