Features: - Web interface for managing rotary phone system - Support for multiple greeting messages with selector - Direct audio playback in browser for recordings and greetings - Upload multiple WAV files at once - Set active greeting that plays when phone is picked up - HiFiBerry DAC+ADC Pro audio configuration - GPIO-based handset detection and audio recording - Real-time status monitoring with auto-refresh 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1229 lines
40 KiB
Python
1229 lines
40 KiB
Python
#!/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
|
||
|
||
# Configuration
|
||
HOOK_PIN = 17 # GPIO pin for hook switch (change to your pin)
|
||
HOOK_PRESSED = GPIO.LOW # Change to GPIO.HIGH if your switch is active high
|
||
|
||
# Audio settings
|
||
CHUNK = 1024
|
||
FORMAT = pyaudio.paInt16
|
||
CHANNELS = 1
|
||
RATE = 44100
|
||
RECORD_SECONDS = 300 # Maximum recording time (5 minutes)
|
||
|
||
# Directories
|
||
BASE_DIR = "/home/berwn/rotary_phone_data"
|
||
OUTPUT_DIR = os.path.join(BASE_DIR, "recordings")
|
||
SOUNDS_DIR = os.path.join(BASE_DIR, "sounds")
|
||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
|
||
DIALTONE_FILE = os.path.join(SOUNDS_DIR, "dialtone.wav") # Legacy default
|
||
|
||
# Web server settings
|
||
WEB_PORT = 8080
|
||
|
||
# Flask app
|
||
app = Flask(__name__)
|
||
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB max file size
|
||
|
||
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 configuration from JSON file"""
|
||
default_config = {
|
||
"active_greeting": "dialtone.wav",
|
||
"greetings": []
|
||
}
|
||
|
||
if os.path.exists(CONFIG_FILE):
|
||
try:
|
||
with open(CONFIG_FILE, 'r') as f:
|
||
return json.load(f)
|
||
except:
|
||
pass
|
||
|
||
return default_config
|
||
|
||
def save_config(self):
|
||
"""Save configuration to JSON file"""
|
||
with open(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 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"""
|
||
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 device index 1 for HiFiBerry (change if needed)
|
||
stream = self.audio.open(
|
||
format=self.audio.get_format_from_width(wf.getsampwidth()),
|
||
channels=wf.getnchannels(),
|
||
rate=wf.getframerate(),
|
||
output=True,
|
||
output_device_index=1, # HiFiBerry device index
|
||
frames_per_buffer=CHUNK
|
||
)
|
||
|
||
# Play the sound
|
||
data = wf.readframes(CHUNK)
|
||
while data and self.phone_status == "off_hook":
|
||
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 device index 1 for HiFiBerry (change if needed)
|
||
stream = self.audio.open(
|
||
format=FORMAT,
|
||
channels=CHANNELS,
|
||
rate=RATE,
|
||
input=True,
|
||
input_device_index=1, # HiFiBerry 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")
|
||
|
||
return render_template('index.html',
|
||
recordings=recordings,
|
||
greetings=greetings,
|
||
active_greeting=active_greeting,
|
||
status=status)
|
||
|
||
@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('/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;
|
||
}
|
||
</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>
|
||
|
||
<!-- 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()">×</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;
|
||
|
||
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)
|