Files
wedding-phone/rotary_phone_web.py
grabowski 46c727ea75 Update frontend to modern responsive design (v2.0.0)
Modernized the web interface with a mobile-first responsive design:

- Added CSS custom properties (variables) for consistent theming
- Implemented responsive breakpoints (640px, 768px) for mobile/tablet/desktop
- Applied fluid typography using clamp() for automatic font scaling
- Enhanced animations (fade-in, slide-in, scale effects) for smooth UX
- Improved component styling with modern shadows and hover effects
- Added backdrop blur effects on modals
- Optimized touch targets (min 44px height) for mobile accessibility
- Updated button groups and layouts to stack on mobile
- Enhanced visual hierarchy with better spacing and typography
- Improved recordings grid with responsive card layout

Template version bumped from 1.9.2 to 2.0.0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 10:01:28 +07:00

3010 lines
111 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
from waitress import serve
import json
import sys
import zlib
import shutil
import zipfile
import io
# Load configuration
def load_system_config():
"""Load system configuration from config.json and auto-update with missing defaults"""
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:
config = json.load(f)
# Default values for all settings
defaults = {
'gpio': {
'hook_pin': 17,
'hook_pressed_state': 'LOW',
'extra_button_enabled': False,
'extra_button_pin': 27,
'extra_button_pressed_state': 'LOW'
},
'audio': {
'device_index': 1,
'chunk_size': 1024,
'format': 'paInt16',
'channels': 1,
'sample_rate': 48000,
'max_record_seconds': 300
},
'paths': {
'base_dir': './rotary_phone_data',
'recordings_dir': 'recordings',
'sounds_dir': 'sounds'
},
'backup': {
'enabled': True,
'usb_paths': ['/media/usb0', '/media/usb1'],
'verify_crc': True,
'backup_on_write': True
},
'web': {
'port': 8080,
'max_upload_size_mb': 50
},
'system': {
'active_greeting': 'dialtone.wav',
'extra_button_sound': 'button_sound.wav',
'beep_sound': 'beep.wav',
'beep_enabled': True,
'greeting_delay_seconds': 0,
'volume': 70,
'volume_greeting': 70,
'volume_button': 70,
'volume_beep': 70
}
}
# Check if any defaults are missing and add them
updated = False
for section, section_defaults in defaults.items():
if section not in config:
config[section] = section_defaults
updated = True
print(f"[CONFIG] Added missing section: {section}")
else:
for key, default_value in section_defaults.items():
if key not in config[section]:
config[section][key] = default_value
updated = True
print(f"[CONFIG] Added missing setting: {section}.{key} = {default_value}")
# Save updated config if changes were made
if updated:
try:
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
print(f"[CONFIG] Updated config.json with missing defaults")
except Exception as e:
print(f"[CONFIG] Warning: Could not save updated config: {e}")
return config
# 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
# Extra Button Configuration
EXTRA_BUTTON_ENABLED = SYS_CONFIG['gpio'].get('extra_button_enabled', False)
EXTRA_BUTTON_PIN = SYS_CONFIG['gpio'].get('extra_button_pin', 27)
EXTRA_BUTTON_PRESSED = GPIO.LOW if SYS_CONFIG['gpio'].get('extra_button_pressed_state', 'LOW') == '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")
# Backup Configuration
BACKUP_CONFIG = SYS_CONFIG.get('backup', {})
BACKUP_ENABLED = BACKUP_CONFIG.get('enabled', False)
BACKUP_USB_PATHS = BACKUP_CONFIG.get('usb_paths', [])
BACKUP_VERIFY_CRC = BACKUP_CONFIG.get('verify_crc', True)
BACKUP_ON_WRITE = BACKUP_CONFIG.get('backup_on_write', True)
# Web server settings
WEB_PORT = SYS_CONFIG['web']['port']
# Template version - increment this when HTML template changes
TEMPLATE_VERSION = "2.0.0" # Updated: Modern responsive design with mobile-first approach, improved layout and animations
# Flask app
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = SYS_CONFIG['web']['max_upload_size_mb'] * 1024 * 1024
# Backup and CRC Functions
def calculate_crc32(filepath):
"""Calculate CRC32 checksum of a file"""
try:
with open(filepath, 'rb') as f:
checksum = 0
while chunk := f.read(65536): # Read in 64KB chunks
checksum = zlib.crc32(chunk, checksum)
return checksum & 0xFFFFFFFF
except Exception as e:
print(f"Error calculating CRC for {filepath}: {e}")
return None
def backup_file_to_usb(source_file, relative_path):
"""
Backup a file to all configured USB drives with CRC verification
Args:
source_file: Full path to source file
relative_path: Relative path from base dir (e.g., 'recordings/file.wav')
Returns:
dict: Status of each USB backup attempt
"""
if not BACKUP_ENABLED or not BACKUP_ON_WRITE:
return {"enabled": False}
if not os.path.exists(source_file):
return {"error": f"Source file not found: {source_file}"}
# Calculate source CRC if verification is enabled
source_crc = None
if BACKUP_VERIFY_CRC:
source_crc = calculate_crc32(source_file)
if source_crc is None:
return {"error": "Failed to calculate source CRC"}
results = {}
for usb_path in BACKUP_USB_PATHS:
usb_name = os.path.basename(usb_path)
# Check if USB is mounted
if not os.path.exists(usb_path) or not os.path.ismount(usb_path):
results[usb_name] = {"status": "not_mounted", "path": usb_path}
continue
try:
# Create backup directory structure
backup_dir = os.path.join(usb_path, "wedding-phone-backup")
dest_dir = os.path.join(backup_dir, os.path.dirname(relative_path))
os.makedirs(dest_dir, exist_ok=True)
# Copy file
dest_file = os.path.join(backup_dir, relative_path)
shutil.copy2(source_file, dest_file)
# Verify CRC if enabled
if BACKUP_VERIFY_CRC:
dest_crc = calculate_crc32(dest_file)
if dest_crc != source_crc:
results[usb_name] = {
"status": "crc_mismatch",
"path": dest_file,
"source_crc": hex(source_crc),
"dest_crc": hex(dest_crc) if dest_crc else None
}
# Delete corrupted backup
try:
os.remove(dest_file)
except:
pass
continue
results[usb_name] = {
"status": "success",
"path": dest_file,
"crc": hex(source_crc) if source_crc else None
}
except Exception as e:
results[usb_name] = {"status": "error", "error": str(e)}
return results
def get_usb_backup_status():
"""Get status of all configured USB backup drives"""
status = {
"enabled": BACKUP_ENABLED,
"verify_crc": BACKUP_VERIFY_CRC,
"backup_on_write": BACKUP_ON_WRITE,
"drives": []
}
for usb_path in BACKUP_USB_PATHS:
drive_info = {
"path": usb_path,
"name": os.path.basename(usb_path),
"mounted": os.path.exists(usb_path) and os.path.ismount(usb_path),
"writable": False,
"free_space": None
}
if drive_info["mounted"]:
try:
# Try to create backup directory first
backup_dir = os.path.join(usb_path, "wedding-phone-backup")
try:
os.makedirs(backup_dir, exist_ok=True)
test_location = backup_dir
except PermissionError:
# Can't create backup dir, try root
test_location = usb_path
# Check if writable in backup directory
test_file = os.path.join(test_location, ".wedding_phone_test")
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
drive_info["writable"] = True
drive_info["backup_dir"] = backup_dir if test_location == backup_dir else None
# Get free space
stat = os.statvfs(usb_path)
drive_info["free_space"] = stat.f_bavail * stat.f_frsize
drive_info["free_space_mb"] = drive_info["free_space"] / (1024 * 1024)
except PermissionError as e:
drive_info["error"] = f"Permission denied. Mount with proper permissions or run: sudo chown -R $USER {usb_path}"
except Exception as e:
drive_info["error"] = str(e)
status["drives"].append(drive_info)
return status
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)
# Setup extra button if enabled
if EXTRA_BUTTON_ENABLED:
GPIO.setup(EXTRA_BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
self.extra_button_playing = False
# 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()
# Generate default beep if none exists
beep_file = os.path.join(SOUNDS_DIR, "beep.wav")
if not os.path.exists(beep_file):
self.generate_default_beep()
def load_config(self):
"""Load user runtime configuration from JSON file"""
default_config = {
"active_greeting": SYS_CONFIG['system']['active_greeting'],
"extra_button_sound": SYS_CONFIG['system'].get('extra_button_sound', 'button_sound.wav'),
"beep_sound": SYS_CONFIG['system'].get('beep_sound', 'beep.wav'),
"beep_enabled": SYS_CONFIG['system'].get('beep_enabled', True),
"greetings": [],
"volume": SYS_CONFIG['system']['volume'],
"volume_greeting": SYS_CONFIG['system'].get('volume_greeting', 70),
"volume_button": SYS_CONFIG['system'].get('volume_button', 70),
"volume_beep": SYS_CONFIG['system'].get('volume_beep', 70),
"greeting_delay": SYS_CONFIG['system'].get('greeting_delay_seconds', 0)
}
if os.path.exists(USER_CONFIG_FILE):
try:
with open(USER_CONFIG_FILE, 'r') as f:
config = json.load(f)
# Check for missing keys and add defaults
updated = False
for key, default_value in default_config.items():
if key not in config:
config[key] = default_value
updated = True
print(f"[USER_CONFIG] Added missing setting: {key} = {default_value}")
# Save updated config if changes were made
if updated:
try:
with open(USER_CONFIG_FILE, 'w') as fw:
json.dump(config, fw, indent=2)
print(f"[USER_CONFIG] Updated user_config.json with missing defaults")
except Exception as e:
print(f"[USER_CONFIG] Warning: Could not save updated config: {e}")
return config
except Exception as e:
print(f"[USER_CONFIG] Error loading user config: {e}, using defaults")
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 get_extra_button_sound_path(self):
"""Get the full path to the extra button sound file"""
sound = self.config.get("extra_button_sound", "button_sound.wav")
return os.path.join(SOUNDS_DIR, sound)
def set_extra_button_sound(self, filename):
"""Set which sound plays for extra button"""
self.config["extra_button_sound"] = filename
self.save_config()
def get_beep_sound_path(self):
"""Get the full path to the beep sound file"""
sound = self.config.get("beep_sound", "beep.wav")
return os.path.join(SOUNDS_DIR, sound)
def set_beep_sound(self, filename):
"""Set which sound plays as recording beep"""
self.config["beep_sound"] = filename
self.save_config()
def is_beep_enabled(self):
"""Check if beep sound is enabled"""
return self.config.get("beep_enabled", True)
def set_beep_enabled(self, enabled):
"""Enable or disable beep sound"""
self.config["beep_enabled"] = bool(enabled)
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 set_volume_greeting(self, volume):
"""Set greeting volume (0-100)"""
volume = max(0, min(100, int(volume)))
self.config["volume_greeting"] = volume
self.save_config()
return volume
def get_volume_greeting(self):
"""Get greeting volume setting"""
return self.config.get("volume_greeting", 70)
def set_volume_button(self, volume):
"""Set button sound volume (0-100)"""
volume = max(0, min(100, int(volume)))
self.config["volume_button"] = volume
self.save_config()
return volume
def get_volume_button(self):
"""Get button sound volume setting"""
return self.config.get("volume_button", 70)
def set_volume_beep(self, volume):
"""Set beep sound volume (0-100)"""
volume = max(0, min(100, int(volume)))
self.config["volume_beep"] = volume
self.save_config()
return volume
def get_volume_beep(self):
"""Get beep sound volume setting"""
return self.config.get("volume_beep", 70)
def set_greeting_delay(self, delay):
"""Set greeting delay in seconds (0-10)"""
delay = max(0, min(10, int(delay))) # Clamp between 0-10
self.config["greeting_delay"] = delay
self.save_config()
return delay
def get_greeting_delay(self):
"""Get current greeting delay"""
return self.config.get("greeting_delay", 0)
def generate_default_dialtone(self):
"""Generate a classic dial tone (350Hz + 440Hz) and save as default"""
print(f"Generating default dial tone at {RATE}Hz...")
duration = 3
t = np.linspace(0, duration, int(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(RATE)
wf.writeframes(tone.tobytes())
wf.close()
print(f"Default dial tone saved to {DIALTONE_FILE} ({RATE}Hz, 16-bit)")
def generate_default_beep(self):
"""Generate a simple beep tone (1000Hz) to indicate recording start"""
print(f"Generating default beep sound at {RATE}Hz...")
beep_file = os.path.join(SOUNDS_DIR, "beep.wav")
duration = 0.5 # Half second beep
t = np.linspace(0, duration, int(RATE * duration), False)
# Generate 1000Hz tone
frequency = 1000
tone = np.sin(2 * np.pi * frequency * t)
# Convert to 16-bit PCM
tone = (tone * 32767).astype(np.int16)
# Save as WAV file
wf = wave.open(beep_file, 'wb')
wf.setnchannels(1)
wf.setsampwidth(2) # 16-bit
wf.setframerate(RATE)
wf.writeframes(tone.tobytes())
wf.close()
print(f"Default beep sound saved to {beep_file} ({RATE}Hz, 16-bit)")
def play_sound_file(self, filepath, check_hook_status=True, volume_override=None):
"""Play a WAV file with volume control
Args:
filepath: Path to WAV file
check_hook_status: If True, only play while off-hook (for greeting).
If False, play completely regardless of hook (for button)
volume_override: Override volume (0-100), or None to use default volume
"""
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')
# Check sample rate compatibility
file_rate = wf.getframerate()
if file_rate != RATE:
print(f"WARNING: File sample rate ({file_rate}Hz) doesn't match configured rate ({RATE}Hz)")
print(f"Audio may not play correctly. Please resample the file to {RATE}Hz")
wf.close()
return False
# Use configured audio device
stream = self.audio.open(
format=self.audio.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=file_rate,
output=True,
output_device_index=AUDIO_DEVICE_INDEX,
frames_per_buffer=CHUNK
)
# Get volume multiplier (0.0 to 1.0)
volume = (volume_override if volume_override is not None else self.get_volume()) / 100.0
# Play the sound with volume control
data = wf.readframes(CHUNK)
while data:
# For greeting sounds, stop immediately if handset is hung up
if check_hook_status and GPIO.input(HOOK_PIN) != HOOK_PRESSED:
print("Handset hung up, stopping playback immediately")
break
# 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}")
MIN_RECORDING_DURATION = 1.0 # Minimum 1 second to save recording
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 - immediate detection
if GPIO.input(HOOK_PIN) != HOOK_PRESSED:
print("Handset hung up, stopping recording immediately")
break
# Check extra button during recording
if EXTRA_BUTTON_ENABLED and GPIO.input(EXTRA_BUTTON_PIN) == EXTRA_BUTTON_PRESSED:
print(f"[BUTTON] Button pressed during recording!")
if not self.extra_button_playing:
print(f"[BUTTON] Triggering button sound...")
button_thread = threading.Thread(target=self.play_extra_button_sound, daemon=True)
button_thread.start()
time.sleep(0.5) # Debounce to prevent multiple triggers
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()
# Calculate recording duration
duration = len(frames) * CHUNK / RATE if frames else 0
# Only save if recording is long enough
if frames and duration >= MIN_RECORDING_DURATION:
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()
print(f"Recording saved: {filename} ({duration:.1f}s)")
# Backup to USB drives if enabled
if BACKUP_ENABLED:
relative_path = os.path.join(SYS_CONFIG['paths']['recordings_dir'], os.path.basename(filename))
backup_results = backup_file_to_usb(filename, relative_path)
print(f"Backup results: {backup_results}")
else:
# Delete aborted/too short recording
if os.path.exists(filename):
os.remove(filename)
print(f"Recording aborted or too short ({duration:.1f}s), not saved")
except Exception as e:
print(f"Recording error: {e}")
# Clean up failed recording file
if os.path.exists(filename):
try:
os.remove(filename)
print(f"Cleaned up failed recording: {filename}")
except:
pass
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,
"extra_button_playing": self.extra_button_playing if EXTRA_BUTTON_ENABLED else False
}
def play_extra_button_sound(self):
"""Play sound when extra button is pressed (only during recording)"""
if not EXTRA_BUTTON_ENABLED:
print("[BUTTON] Extra button disabled in config")
return
# Only play if currently recording
if not self.recording:
print("[BUTTON] Extra button ignored - not recording")
return
button_sound = self.get_extra_button_sound_path()
print(f"[BUTTON] Button sound path: {button_sound}")
if not os.path.exists(button_sound):
print(f"[BUTTON] ERROR: Sound file not found: {button_sound}")
return
print(f"[BUTTON] Playing extra button sound...")
self.extra_button_playing = True
try:
# Use a separate PyAudio instance for playback during recording
# This allows simultaneous input (recording) and output (button sound)
print(f"[BUTTON] Opening audio file...")
audio_playback = pyaudio.PyAudio()
wf = wave.open(button_sound, 'rb')
file_rate = wf.getframerate()
file_channels = wf.getnchannels()
file_width = wf.getsampwidth()
print(f"[BUTTON] File info - Rate: {file_rate}Hz, Channels: {file_channels}, Width: {file_width}")
if file_rate != RATE:
print(f"[BUTTON] WARNING: File sample rate ({file_rate}Hz) != configured rate ({RATE}Hz)")
print(f"[BUTTON] Opening audio stream on device {AUDIO_DEVICE_INDEX}...")
stream = audio_playback.open(
format=audio_playback.get_format_from_width(file_width),
channels=file_channels,
rate=file_rate,
output=True,
output_device_index=AUDIO_DEVICE_INDEX,
frames_per_buffer=CHUNK
)
# Get button volume multiplier
button_volume = self.config.get("volume_button", 70)
volume = button_volume / 100.0
print(f"[BUTTON] Volume: {int(volume * 100)}%")
# Play the sound
print(f"[BUTTON] Starting playback...")
data = wf.readframes(CHUNK)
chunk_count = 0
while data:
# Apply volume
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)
chunk_count += 1
stream.stop_stream()
stream.close()
wf.close()
audio_playback.terminate()
print(f"[BUTTON] Playback finished ({chunk_count} chunks)")
except Exception as e:
print(f"[BUTTON] ERROR playing button sound: {e}")
import traceback
traceback.print_exc()
finally:
self.extra_button_playing = False
print(f"[BUTTON] Button playback complete")
def phone_loop(self):
"""Main phone handling loop"""
print("Rotary Phone System Started")
print(f"Hook pin: GPIO {HOOK_PIN}")
if EXTRA_BUTTON_ENABLED:
print(f"Extra button: ENABLED on GPIO {EXTRA_BUTTON_PIN}")
print(f"Extra button pressed state: {EXTRA_BUTTON_PRESSED} ({'LOW' if EXTRA_BUTTON_PRESSED == GPIO.LOW else 'HIGH'})")
print(f"Extra button sound: {self.get_extra_button_sound_path()}")
else:
print(f"Extra button: DISABLED")
print(f"Recordings will be saved to: {OUTPUT_DIR}")
try:
while True:
# Check extra button first (higher priority)
if EXTRA_BUTTON_ENABLED and GPIO.input(EXTRA_BUTTON_PIN) == EXTRA_BUTTON_PRESSED:
print(f"[BUTTON] Button pressed detected (recording={self.recording}, playing={self.extra_button_playing})")
if not self.extra_button_playing:
# Play extra button sound in a separate thread to not block
print(f"[BUTTON] Starting button sound thread...")
button_thread = threading.Thread(target=self.play_extra_button_sound, daemon=True)
button_thread.start()
time.sleep(0.5) # Debounce
else:
print(f"[BUTTON] Button sound already playing, ignoring")
# 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 ===")
# Apply greeting delay if configured
greeting_delay = self.get_greeting_delay()
if greeting_delay > 0:
print(f"Waiting {greeting_delay} seconds before greeting...")
time.sleep(greeting_delay)
# Play active greeting message
greeting_file = self.get_active_greeting_path()
greeting_volume = self.config.get("volume_greeting", 70)
self.play_sound_file(greeting_file, volume_override=greeting_volume)
# Play beep sound to indicate recording will start
if self.phone_status == "off_hook" and self.is_beep_enabled():
beep_file = self.get_beep_sound_path()
beep_volume = self.config.get("volume_beep", 70)
if os.path.exists(beep_file):
self.play_sound_file(beep_file, volume_override=beep_volume)
# 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"""
# Get sort parameters from query string
sort_by = request.args.get('sort', 'date')
sort_order = request.args.get('order', 'desc')
recordings = get_recordings(sort_by=sort_by, sort_order=sort_order)
greetings = get_greetings()
status = phone.get_status()
active_greeting = phone.config.get("active_greeting", "dialtone.wav")
extra_button_sound = phone.config.get("extra_button_sound", "button_sound.wav")
beep_sound = phone.config.get("beep_sound", "beep.wav")
beep_enabled = phone.is_beep_enabled()
volume = phone.get_volume()
volume_greeting = phone.get_volume_greeting()
volume_button = phone.get_volume_button()
volume_beep = phone.get_volume_beep()
greeting_delay = phone.get_greeting_delay()
return render_template('index.html',
recordings=recordings,
greetings=greetings,
active_greeting=active_greeting,
extra_button_sound=extra_button_sound,
beep_sound=beep_sound,
beep_enabled=beep_enabled,
extra_button_enabled=EXTRA_BUTTON_ENABLED,
status=status,
volume=volume,
volume_greeting=volume_greeting,
volume_button=volume_button,
volume_beep=volume_beep,
greeting_delay=greeting_delay,
sort_by=sort_by,
sort_order=sort_order)
@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('/api/volume/greeting', methods=['GET'])
def api_get_volume_greeting():
"""Get greeting volume setting"""
return jsonify({"volume": phone.get_volume_greeting()})
@app.route('/api/volume/greeting', methods=['POST'])
def api_set_volume_greeting():
"""Set greeting volume level"""
data = request.get_json()
volume = data.get('volume', 70)
new_volume = phone.set_volume_greeting(volume)
return jsonify({"success": True, "volume": new_volume})
@app.route('/api/volume/button', methods=['GET'])
def api_get_volume_button():
"""Get button sound volume setting"""
return jsonify({"volume": phone.get_volume_button()})
@app.route('/api/volume/button', methods=['POST'])
def api_set_volume_button():
"""Set button sound volume level"""
data = request.get_json()
volume = data.get('volume', 70)
new_volume = phone.set_volume_button(volume)
return jsonify({"success": True, "volume": new_volume})
@app.route('/api/volume/beep', methods=['GET'])
def api_get_volume_beep():
"""Get beep sound volume setting"""
return jsonify({"volume": phone.get_volume_beep()})
@app.route('/api/volume/beep', methods=['POST'])
def api_set_volume_beep():
"""Set beep sound volume level"""
data = request.get_json()
volume = data.get('volume', 70)
new_volume = phone.set_volume_beep(volume)
return jsonify({"success": True, "volume": new_volume})
@app.route('/api/greeting_delay', methods=['GET'])
def api_get_greeting_delay():
"""Get current greeting delay setting"""
return jsonify({"delay": phone.get_greeting_delay()})
@app.route('/api/greeting_delay', methods=['POST'])
def api_set_greeting_delay():
"""Set greeting delay in seconds"""
data = request.get_json()
delay = data.get('delay', 0)
new_delay = phone.set_greeting_delay(delay)
return jsonify({"success": True, "delay": new_delay})
@app.route('/api/backup/status', methods=['GET'])
def api_backup_status():
"""Get USB backup drive status"""
return jsonify(get_usb_backup_status())
@app.route('/api/backup/test', methods=['POST'])
def api_backup_test():
"""Test backup to all USB drives"""
try:
# Create a test file
test_file = os.path.join(OUTPUT_DIR, ".backup_test.txt")
with open(test_file, 'w') as f:
f.write(f"Backup test at {datetime.now()}")
# Try to backup
results = backup_file_to_usb(test_file, os.path.join(SYS_CONFIG['paths']['recordings_dir'], ".backup_test.txt"))
# Clean up test file
try:
os.remove(test_file)
except:
pass
return jsonify({"success": True, "results": results})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/system/shutdown', methods=['POST'])
def api_system_shutdown():
"""Shutdown the Raspberry Pi"""
try:
import subprocess
# Schedule shutdown in 1 minute to allow response to be sent
subprocess.Popen(['sudo', '/sbin/shutdown', '-h', '+1'])
return jsonify({"success": True, "message": "System will shutdown in 1 minute"})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/system/restart', methods=['POST'])
def api_system_restart():
"""Restart the Raspberry Pi"""
try:
import subprocess
# Schedule restart to allow response to be sent
subprocess.Popen(['sudo', '/sbin/reboot'])
return jsonify({"success": True, "message": "System will restart shortly"})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@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)
# Backup to USB drives if enabled
if BACKUP_ENABLED:
relative_path = os.path.join(SYS_CONFIG['paths']['sounds_dir'], filename)
backup_results = backup_file_to_usb(filepath, relative_path)
print(f"Greeting backup results: {backup_results}")
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('/set_extra_button_sound', methods=['POST'])
def set_extra_button_sound():
"""Set which sound plays for extra button"""
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": "Sound file not found"}), 404
phone.set_extra_button_sound(filename)
return jsonify({"success": True, "message": f"Extra button sound set to '{filename}'"})
@app.route('/set_beep_sound', methods=['POST'])
def set_beep_sound():
"""Set which sound plays as recording beep"""
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": "Sound file not found"}), 404
phone.set_beep_sound(filename)
return jsonify({"success": True, "message": f"Beep sound set to '{filename}'"})
@app.route('/api/beep_enabled', methods=['GET', 'POST'])
def beep_enabled():
"""Get or set beep enabled status"""
if request.method == 'POST':
data = request.get_json()
enabled = data.get('enabled', True)
phone.set_beep_enabled(enabled)
return jsonify({"success": True, "enabled": phone.is_beep_enabled()})
else:
return jsonify({"enabled": phone.is_beep_enabled()})
@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('/download_all')
def download_all_recordings():
"""Download all recordings as a ZIP file"""
if not os.path.exists(OUTPUT_DIR):
return "No recordings found", 404
# Get all recording files
recordings = [f for f in os.listdir(OUTPUT_DIR)
if f.endswith('.wav') and os.path.isfile(os.path.join(OUTPUT_DIR, f))]
if not recordings:
return "No recordings found", 404
# Create ZIP file in memory
memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
for filename in recordings:
filepath = os.path.join(OUTPUT_DIR, filename)
# Add file to ZIP with just the filename (no path)
zf.write(filepath, arcname=filename)
# Seek to beginning of file
memory_file.seek(0)
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"wedding_recordings_{timestamp}.zip"
return send_file(
memory_file,
mimetype='application/zip',
as_attachment=True,
download_name=zip_filename
)
@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('/rename/<filename>', methods=['POST'])
def rename_recording(filename):
"""Rename a recording"""
data = request.get_json()
new_name = data.get('new_name', '').strip()
if not new_name:
return jsonify({"error": "New name is required"}), 400
# Ensure .wav extension
if not new_name.endswith('.wav'):
new_name += '.wav'
# Sanitize filenames
old_filepath = os.path.join(OUTPUT_DIR, secure_filename(filename))
new_filepath = os.path.join(OUTPUT_DIR, secure_filename(new_name))
if not os.path.exists(old_filepath):
return jsonify({"error": "File not found"}), 404
if os.path.exists(new_filepath):
return jsonify({"error": "A file with that name already exists"}), 409
try:
os.rename(old_filepath, new_filepath)
# Also backup renamed file if backup is enabled
if SYS_CONFIG['backup']['enabled'] and SYS_CONFIG['backup']['backup_on_write']:
backup_file_to_usb(new_filepath, 'recordings')
return jsonify({"success": True, "new_name": secure_filename(new_name)})
except Exception as e:
return jsonify({"error": str(e)}), 500
@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"),
"is_button_sound": filename == phone.config.get("extra_button_sound", "button_sound.wav"),
"is_beep_sound": filename == phone.config.get("beep_sound", "beep.wav")
})
return greetings
def get_recordings(sort_by='date', sort_order='desc'):
"""Get list of all recordings with metadata
Args:
sort_by: Sort field - 'date', 'name', 'duration', or 'size'
sort_order: Sort order - 'asc' or 'desc'
"""
recordings = []
if os.path.exists(OUTPUT_DIR):
for filename in os.listdir(OUTPUT_DIR):
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'),
"timestamp": stat.st_mtime, # For sorting
"duration": duration
})
# Sort recordings based on parameters
reverse = (sort_order == 'desc')
if sort_by == 'name':
recordings.sort(key=lambda x: x['filename'].lower(), reverse=reverse)
elif sort_by == 'duration':
recordings.sort(key=lambda x: x['duration'], reverse=reverse)
elif sort_by == 'size':
recordings.sort(key=lambda x: x['size'], reverse=reverse)
else: # default to date
recordings.sort(key=lambda x: x['timestamp'], reverse=reverse)
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"
def check_template_version(template_file):
"""Check if template needs regeneration based on version"""
if not os.path.exists(template_file):
return False # Template doesn't exist, needs creation
try:
with open(template_file, 'r') as f:
content = f.read()
# Look for version comment in template
if f'<!-- TEMPLATE_VERSION: {TEMPLATE_VERSION} -->' in content:
return True # Version matches, no regeneration needed
else:
return False # Version mismatch or missing, needs regeneration
except:
return False # Error reading, regenerate to be safe
def main():
"""Main entry point for the wedding phone application"""
# 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)
# Check if template needs regeneration
template_file = os.path.join(templates_dir, 'index.html')
template_up_to_date = check_template_version(template_file)
if not template_up_to_date:
if os.path.exists(template_file):
print(f"Template version mismatch - regenerating template at {template_file}")
else:
print(f"Creating new template at {template_file}")
with open(template_file, 'w', encoding='utf-8') as f:
# Write version comment first, then the rest of the template
f.write(f'<!-- TEMPLATE_VERSION: {TEMPLATE_VERSION} -->\n')
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>
:root {
--primary: #667eea;
--primary-dark: #5568d3;
--secondary: #764ba2;
--success: #10b981;
--success-dark: #059669;
--danger: #ef4444;
--danger-dark: #dc2626;
--warning: #f59e0b;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-900: #111827;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
min-height: 100vh;
padding: 1rem;
line-height: 1.6;
}
@media (min-width: 768px) {
body {
padding: 2rem;
}
}
.container {
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.header {
text-align: center;
color: white;
margin-bottom: 2rem;
animation: fadeInDown 0.6s ease-out;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header h1 {
font-size: clamp(1.75rem, 5vw, 3rem);
margin-bottom: 0.5rem;
text-shadow: 2px 2px 8px rgba(0,0,0,0.2);
font-weight: 700;
letter-spacing: -0.02em;
}
.header p {
font-size: clamp(0.95rem, 2vw, 1.15rem);
opacity: 0.95;
font-weight: 300;
}
.card {
background: white;
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-xl);
transition: var(--transition);
animation: fadeInUp 0.6s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.2s; }
.card:nth-child(4) { animation-delay: 0.3s; }
.card:nth-child(5) { animation-delay: 0.4s; }
.card:nth-child(6) { animation-delay: 0.5s; }
.card:nth-child(7) { animation-delay: 0.6s; }
@media (min-width: 768px) {
.card {
padding: 2rem;
}
}
.card:hover {
box-shadow: 0 25px 35px -5px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.status-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.status-card {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.status-dot {
width: 18px;
height: 18px;
border-radius: 50%;
animation: pulse 2s infinite;
flex-shrink: 0;
}
.status-dot.on-hook {
background: var(--success);
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.2);
}
.status-dot.off-hook {
background: var(--danger);
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
}
.status-dot.recording {
background: var(--warning);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.2);
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.05);
}
}
.card h2 {
color: var(--gray-900);
margin-bottom: 1.25rem;
font-size: clamp(1.25rem, 3vw, 1.5rem);
font-weight: 700;
border-bottom: 3px solid var(--primary);
padding-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.upload-section {
border: 2px dashed var(--primary);
border-radius: var(--radius-md);
padding: 1.5rem;
text-align: center;
margin-bottom: 1.5rem;
background: var(--gray-50);
transition: var(--transition);
}
@media (min-width: 768px) {
.upload-section {
padding: 2rem;
}
}
.upload-section:hover {
border-color: var(--primary-dark);
background: white;
}
.upload-section input[type="file"] {
display: none;
}
.upload-label {
display: inline-block;
padding: 0.875rem 1.75rem;
background: var(--primary);
color: white;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
font-size: 0.95rem;
box-shadow: var(--shadow-md);
}
.upload-label:hover {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.upload-label:active {
transform: translateY(0);
}
.sound-selector {
width: 100%;
padding: 0.875rem 1rem;
font-size: 0.95rem;
border: 2px solid var(--gray-200);
border-radius: var(--radius-sm);
background: white;
cursor: pointer;
transition: var(--transition);
font-family: inherit;
}
.sound-selector:hover {
border-color: var(--primary);
}
.sound-selector:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: var(--transition);
font-size: 0.9rem;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
box-shadow: var(--shadow-sm);
white-space: nowrap;
min-height: 44px;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: var(--success-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: var(--danger-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--gray-500);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: var(--gray-600);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.recordings-grid {
display: grid;
gap: 1rem;
}
.recording-item {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: var(--gray-50);
border-radius: var(--radius-md);
transition: var(--transition);
border: 1px solid var(--gray-200);
}
@media (min-width: 768px) {
.recording-item {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.recording-item:hover {
background: white;
transform: translateX(3px);
box-shadow: var(--shadow-md);
border-color: var(--primary);
}
.recording-info {
flex-grow: 1;
min-width: 0;
}
.recording-info h3 {
color: var(--gray-900);
font-size: 1rem;
margin-bottom: 0.5rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
}
.recording-meta {
color: var(--gray-500);
font-size: 0.85rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.recording-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-start;
}
@media (min-width: 768px) {
.recording-actions {
justify-content: flex-end;
}
}
@media (max-width: 767px) {
.recording-actions .btn {
flex: 1 1 calc(50% - 0.25rem);
font-size: 0.85rem;
padding: 0.625rem 1rem;
}
}
.no-recordings {
text-align: center;
padding: 3rem 1.5rem;
color: var(--gray-500);
}
.alert {
padding: 1rem 1.25rem;
border-radius: var(--radius-md);
margin-bottom: 1.5rem;
font-size: 0.95rem;
box-shadow: var(--shadow-sm);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.alert-success {
background: #d1fae5;
color: #065f46;
border-left: 4px solid var(--success);
}
.alert-error {
background: #fee2e2;
color: #991b1b;
border-left: 4px solid var(--danger);
}
.alert-warning {
background: #fef3c7;
color: #92400e;
border-left: 4px solid var(--warning);
}
.alert-info {
background: #dbeafe;
color: #1e40af;
border-left: 4px solid #3b82f6;
}
.filename-display {
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Courier New', monospace;
background: var(--gray-100);
padding: 0.625rem 1rem;
border-radius: var(--radius-sm);
margin-top: 1rem;
font-size: 0.875rem;
word-break: break-all;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
@media (min-width: 640px) {
.stats {
grid-template-columns: repeat(3, 1fr);
}
}
.stat-box {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
padding: 1.5rem;
border-radius: var(--radius-md);
text-align: center;
box-shadow: var(--shadow-lg);
transition: var(--transition);
}
.stat-box:hover {
transform: translateY(-3px) scale(1.02);
box-shadow: var(--shadow-xl);
}
.stat-value {
font-size: clamp(1.75rem, 4vw, 2.25rem);
font-weight: 800;
margin-bottom: 0.25rem;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 0.875rem;
opacity: 0.95;
font-weight: 500;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
@media (max-width: 640px) {
.button-group {
flex-direction: column;
}
.button-group .btn {
width: 100%;
}
}
.active-greeting {
border: 2px solid var(--primary);
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.75);
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
padding: 1rem;
}
.audio-modal.show {
display: flex;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.audio-modal-content {
background: white;
padding: 2rem;
border-radius: var(--radius-lg);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
max-width: 500px;
width: 100%;
animation: scaleIn 0.3s ease-out;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.audio-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.audio-modal-header h3 {
margin: 0;
color: var(--gray-900);
font-size: 1.25rem;
font-weight: 700;
}
.close-modal {
font-size: 1.75rem;
font-weight: bold;
color: var(--gray-400);
cursor: pointer;
border: none;
background: none;
padding: 0.25rem;
width: 36px;
height: 36px;
line-height: 1;
border-radius: var(--radius-sm);
transition: var(--transition);
}
.close-modal:hover {
color: var(--gray-900);
background: var(--gray-100);
}
.audio-player-container {
text-align: center;
}
.audio-player-container audio {
width: 100%;
margin-top: 1.25rem;
border-radius: var(--radius-sm);
}
.audio-filename {
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Courier New', monospace;
background: var(--gray-50);
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
margin-bottom: 0.75rem;
word-break: break-all;
font-size: 0.875rem;
color: var(--gray-700);
}
/* Volume slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
width: 100%;
}
input[type="range"]::-webkit-slider-track {
height: 6px;
border-radius: 3px;
}
input[type="range"]::-moz-range-track {
height: 6px;
border-radius: 3px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
transition: var(--transition);
margin-top: -7px;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: var(--primary-dark);
transform: scale(1.15);
box-shadow: 0 3px 8px rgba(0,0,0,0.3);
}
input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.05);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
transition: var(--transition);
}
input[type="range"]::-moz-range-thumb:hover {
background: var(--primary-dark);
transform: scale(1.15);
box-shadow: 0 3px 8px rgba(0,0,0,0.3);
}
input[type="range"]::-moz-range-thumb:active {
transform: scale(1.05);
}
/* Responsive utilities */
@media (max-width: 640px) {
.card h2 {
font-size: 1.15rem;
}
h3 {
font-size: 1rem;
}
}
</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 %}
{% if extra_button_enabled %}
<div id="button-status" style="margin-top: 10px; padding: 8px; border-radius: 5px; background: {{ '#fef3c7' if status.extra_button_playing else '#f3f4f6' }}; transition: background 0.3s;">
<span style="font-size: 0.9em;">
{% if status.extra_button_playing %}
🔘 Button sound playing...
{% else %}
🔘 Button ready
{% endif %}
</span>
</div>
{% endif %}
</div>
<button class="btn btn-secondary" onclick="refreshStatus()">🔄 Refresh</button>
</div>
<!-- Volume & Delay Control Card -->
<div class="card">
<h2>🔊 Volume & Delay Control</h2>
<!-- Volume Controls -->
<div style="padding: 20px; border-bottom: 1px solid #e5e7eb;">
<h3 style="margin: 0 0 20px 0; font-size: 1.1em; color: #333;">🔊 Volume Controls</h3>
<!-- Greeting Volume -->
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #555;">📢 Greeting</label>
<div style="display: flex; align-items: center; gap: 15px;">
<span style="font-size: 1.2em;">🔇</span>
<input type="range" id="volume-greeting-slider" min="0" max="100" value="{{ volume_greeting }}"
style="flex: 1; height: 6px; border-radius: 3px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ volume_greeting }}%, #ddd {{ volume_greeting }}%, #ddd 100%);">
<span style="font-size: 1.2em;">🔊</span>
<span id="volume-greeting-display" style="font-weight: bold; min-width: 45px; text-align: center;">{{ volume_greeting }}%</span>
</div>
</div>
<!-- Button Volume -->
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #555;">🔘 Button Sound</label>
<div style="display: flex; align-items: center; gap: 15px;">
<span style="font-size: 1.2em;">🔇</span>
<input type="range" id="volume-button-slider" min="0" max="100" value="{{ volume_button }}"
style="flex: 1; height: 6px; border-radius: 3px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ volume_button }}%, #ddd {{ volume_button }}%, #ddd 100%);">
<span style="font-size: 1.2em;">🔊</span>
<span id="volume-button-display" style="font-weight: bold; min-width: 45px; text-align: center;">{{ volume_button }}%</span>
</div>
</div>
<!-- Beep Volume -->
<div>
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #555;">📣 Recording Beep</label>
<div style="display: flex; align-items: center; gap: 15px;">
<span style="font-size: 1.2em;">🔇</span>
<input type="range" id="volume-beep-slider" min="0" max="100" value="{{ volume_beep }}"
style="flex: 1; height: 6px; border-radius: 3px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ volume_beep }}%, #ddd {{ volume_beep }}%, #ddd 100%);">
<span style="font-size: 1.2em;">🔊</span>
<span id="volume-beep-display" style="font-weight: bold; min-width: 45px; text-align: center;">{{ volume_beep }}%</span>
</div>
</div>
</div>
<!-- Greeting Delay Control -->
<div style="padding: 20px; border-bottom: 1px solid #e5e7eb;">
<h3 style="margin: 0 0 15px 0; font-size: 1.1em; color: #333;">Greeting Delay</h3>
<div style="display: flex; align-items: center; gap: 20px;">
<span style="font-size: 1.5em;">⏱️</span>
<input type="range" id="delay-slider" min="0" max="10" value="{{ greeting_delay }}"
style="flex: 1; height: 8px; border-radius: 5px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ greeting_delay * 10 }}%, #ddd {{ greeting_delay * 10 }}%, #ddd 100%);">
<span style="font-size: 1.5em;">⏰</span>
<span id="delay-display" style="font-weight: bold; min-width: 50px; text-align: center; font-size: 1.2em;">{{ greeting_delay }}s</span>
</div>
<p style="margin-top: 10px; color: #6b7280; font-size: 0.85em; text-align: center;">
Delay before greeting plays after pickup (0-10 seconds)
</p>
</div>
<!-- Recording Beep Control -->
<div style="padding: 20px;">
<h3 style="margin: 0 0 15px 0; font-size: 1.1em; color: #333;">Recording Beep</h3>
<div style="display: flex; align-items: center; gap: 15px; justify-content: center;">
<span style="font-size: 1.5em;">📣</span>
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" id="beep-enabled" {% if beep_enabled %}checked{% endif %}
style="width: 20px; height: 20px; cursor: pointer;">
<span style="font-size: 1em; font-weight: 500;">Play beep before recording</span>
</label>
</div>
<p style="margin-top: 10px; color: #6b7280; font-size: 0.85em; text-align: center;">
Audio cue to signal recording has started
</p>
</div>
</div>
<!-- System Control Card -->
<div class="card">
<h2>⚙️ System Control</h2>
<div style="padding: 20px;">
<p style="margin: 0 0 20px 0; color: #6b7280; text-align: center;">
Safely shutdown or restart the Raspberry Pi
</p>
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<button class="btn btn-danger" onclick="shutdownSystem()" style="min-width: 150px;">
🔌 Shutdown
</button>
<button class="btn btn-warning" onclick="restartSystem()" style="min-width: 150px; background: #f59e0b;">
🔄 Restart
</button>
</div>
<p style="margin-top: 15px; color: #9ca3af; font-size: 0.8em; text-align: center;">
⚠️ System will shutdown/restart after confirmation
</p>
</div>
</div>
<!-- Sound Assignment Card -->
<div class="card">
<h2>🎵 Sound Assignment</h2>
<div style="padding: 20px; border-bottom: 1px solid #e5e7eb;">
<h3 style="margin: 0 0 15px 0; font-size: 1.1em; color: #333;">⭐ Greeting Sound</h3>
<p style="margin-bottom: 10px; color: #6b7280; font-size: 0.9em;">
Plays when handset is picked up
</p>
<select id="greeting-selector" class="sound-selector" onchange="setActiveGreeting(this.value)">
{% for greeting in greetings %}
<option value="{{ greeting.filename }}" {% if greeting.is_active %}selected{% endif %}>
{{ greeting.filename }} ({{ "%.1f"|format(greeting.duration) }}s)
</option>
{% endfor %}
</select>
</div>
{% if extra_button_enabled %}
<div style="padding: 20px; border-bottom: 1px solid #e5e7eb;">
<h3 style="margin: 0 0 15px 0; font-size: 1.1em; color: #333;">🔘 Button Sound</h3>
<p style="margin-bottom: 10px; color: #6b7280; font-size: 0.9em;">
Plays when extra button is pressed during recording
</p>
<select id="button-selector" class="sound-selector" onchange="setExtraButtonSound(this.value)">
{% for greeting in greetings %}
<option value="{{ greeting.filename }}" {% if greeting.is_button_sound %}selected{% endif %}>
{{ greeting.filename }} ({{ "%.1f"|format(greeting.duration) }}s)
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div style="padding: 20px;">
<h3 style="margin: 0 0 15px 0; font-size: 1.1em; color: #333;">📣 Recording Beep</h3>
<p style="margin-bottom: 10px; color: #6b7280; font-size: 0.9em;">
Plays after greeting to signal recording start
</p>
<select id="beep-selector" class="sound-selector" onchange="setBeepSound(this.value)">
{% for greeting in greetings %}
<option value="{{ greeting.filename }}" {% if greeting.is_beep_sound %}selected{% endif %}>
{{ greeting.filename }} ({{ "%.1f"|format(greeting.duration) }}s)
</option>
{% endfor %}
</select>
</div>
</div>
<!-- Available Sounds Card -->
<div class="card">
<h2>🔊 Available Sounds</h2>
<div class="upload-section">
<p style="margin-bottom: 15px; color: #6b7280;">
Upload WAV audio files for greetings, button sounds, and beeps
</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 Sound(s)
</button>
<button class="btn btn-secondary" onclick="restoreDefault()">
🔄 Generate Default Dial Tone
</button>
</div>
</div>
<!-- Sounds List -->
{% if greetings %}
<h3 style="margin-top: 30px; margin-bottom: 15px; color: #333;">Uploaded Sounds</h3>
<div class="recordings-grid">
{% for greeting in greetings %}
<div class="recording-item" id="greeting-{{ loop.index }}">
<div class="recording-info">
<h3>{{ greeting.filename }}</h3>
<div class="recording-meta">
📅 {{ greeting.date }} |
⏱️ {{ "%.1f"|format(greeting.duration) }}s |
💾 {{ "%.2f"|format(greeting.size_mb) }} MB
</div>
<div style="margin-top: 5px; font-size: 0.85em; color: #667eea;">
{% if greeting.is_active %}⭐ Active Greeting{% endif %}
{% if greeting.is_button_sound and extra_button_enabled %}{% if greeting.is_active %} | {% endif %}🔘 Button Sound{% endif %}
{% if greeting.is_beep_sound %}{% if greeting.is_active or greeting.is_button_sound %} | {% endif %}📣 Beep Sound{% endif %}
</div>
</div>
<div class="recording-actions">
<button class="btn btn-success" onclick="playAudio('greeting', '{{ greeting.filename }}')">
▶️ Play
</button>
<button class="btn btn-danger" onclick="deleteGreeting('{{ greeting.filename }}', {{ loop.index }})">
🗑️ Delete
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-recordings" style="margin-top: 20px;">
<p style="font-size: 2em; margin-bottom: 10px;">🎵</p>
<p>No sounds uploaded yet. Upload your first sound file!</p>
</div>
{% endif %}
</div>
<!-- USB Backup Card -->
<div class="card">
<h2>💾 USB Backup</h2>
<div style="padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<div>
<p style="margin: 0; color: #6b7280; font-size: 0.9em;">
Automatic backup to USB drives with CRC verification
</p>
</div>
<button class="btn btn-primary" onclick="refreshBackupStatus()">🔄 Refresh</button>
</div>
<div id="backup-status-container">
<p style="text-align: center; color: #6b7280;">Loading backup status...</p>
</div>
<div style="margin-top: 20px; text-align: center;">
<button class="btn btn-success" onclick="testBackup()">
🧪 Test Backup
</button>
</div>
</div>
</div>
<!-- Recordings Card -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">🎙️ Recordings</h2>
{% if recordings %}
<button class="btn btn-primary" onclick="downloadAllRecordings()" style="font-size: 0.9em;">
📦 Download All as ZIP
</button>
{% endif %}
</div>
{% if recordings %}
<!-- Sort Controls -->
<div style="margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb;">
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
<span style="font-weight: 600; color: #374151;">🔄 Sort by:</span>
<select id="sort-by" onchange="updateSort()" style="padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; font-size: 0.95em;">
<option value="date" {% if sort_by == 'date' %}selected{% endif %}>📅 Date</option>
<option value="name" {% if sort_by == 'name' %}selected{% endif %}>📝 Name</option>
<option value="duration" {% if sort_by == 'duration' %}selected{% endif %}>⏱️ Duration</option>
<option value="size" {% if sort_by == 'size' %}selected{% endif %}>💾 Size</option>
</select>
<select id="sort-order" onchange="updateSort()" style="padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; font-size: 0.95em;">
<option value="desc" {% if sort_order == 'desc' %}selected{% endif %}>⬇️ Descending</option>
<option value="asc" {% if sort_order == 'asc' %}selected{% endif %}>⬆️ Ascending</option>
</select>
</div>
</div>
<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 id="filename-{{ loop.index }}">{{ 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-secondary" onclick="renameRecording('{{ recording.filename }}', {{ loop.index }})">
✏️ Rename
</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;
let volumeGreetingTimer = null;
let volumeButtonTimer = null;
let volumeBeepTimer = null;
let delayUpdateTimer = null;
// Volume control helper function
function setupVolumeSlider(sliderId, displayId, apiEndpoint) {
const slider = document.getElementById(sliderId);
const display = document.getElementById(displayId);
let timer = null;
if (!slider) return;
slider.addEventListener('input', function() {
const value = this.value;
display.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(timer);
timer = setTimeout(() => {
fetch(apiEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume: parseInt(value) })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log(`Volume updated to ${data.volume}%`);
}
})
.catch(error => console.error('Error updating volume:', error));
}, 300);
});
}
// Setup all volume sliders
setupVolumeSlider('volume-greeting-slider', 'volume-greeting-display', '/api/volume/greeting');
setupVolumeSlider('volume-button-slider', 'volume-button-display', '/api/volume/button');
setupVolumeSlider('volume-beep-slider', 'volume-beep-display', '/api/volume/beep');
// Greeting Delay control
const delaySlider = document.getElementById('delay-slider');
const delayDisplay = document.getElementById('delay-display');
delaySlider.addEventListener('input', function() {
const value = this.value;
delayDisplay.textContent = value + 's';
// Update slider background gradient (0-10 range = 0-100%)
const percent = (value / 10) * 100;
this.style.background = `linear-gradient(to right, #667eea 0%, #667eea ${percent}%, #ddd ${percent}%, #ddd 100%)`;
// Debounce API call
clearTimeout(delayUpdateTimer);
delayUpdateTimer = setTimeout(() => {
updateGreetingDelay(value);
}, 300);
});
function updateGreetingDelay(delay) {
fetch('/api/greeting_delay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ delay: parseInt(delay) })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Greeting delay updated to ' + data.delay + 's');
}
})
.catch(error => console.error('Error updating delay:', error));
}
// Beep enabled checkbox
const beepCheckbox = document.getElementById('beep-enabled');
beepCheckbox.addEventListener('change', function() {
fetch('/api/beep_enabled', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled: this.checked })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.enabled ? 'Recording beep enabled ✓' : 'Recording beep disabled', 'success');
}
})
.catch(error => console.error('Error updating beep setting:', 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 downloadAllRecordings() {
window.location.href = '/download_all';
}
function updateSort() {
const sortBy = document.getElementById('sort-by').value;
const sortOrder = document.getElementById('sort-order').value;
window.location.href = `/?sort=${sortBy}&order=${sortOrder}`;
}
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 renameRecording(filename, index) {
// Remove .wav extension for cleaner input
const nameWithoutExt = filename.replace('.wav', '');
const newName = prompt('Enter new name for recording:', nameWithoutExt);
if (newName && newName.trim() !== '' && newName !== nameWithoutExt) {
fetch('/rename/' + filename, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_name: newName.trim() })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('Recording renamed! ✓', 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.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 setExtraButtonSound(filename) {
fetch('/set_extra_button_sound', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: filename })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Button sound set to '${filename}'! ✓`, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
function setBeepSound(filename) {
fetch('/set_beep_sound', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: filename })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Beep sound 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 => {
// Update status text
const statusText = document.getElementById('status-text');
const statusDot = document.getElementById('status-dot');
if (data.recording) {
statusText.textContent = '🔴 Recording in progress...';
statusDot.className = 'status-dot recording';
} else if (data.status === 'off_hook') {
statusText.textContent = '📞 Handset off hook';
statusDot.className = 'status-dot off-hook';
} else {
statusText.textContent = '✅ Ready (handset on hook)';
statusDot.className = 'status-dot on-hook';
}
// Update extra button status
const buttonStatus = document.getElementById('button-status');
if (buttonStatus) {
if (data.extra_button_playing) {
buttonStatus.style.background = '#fef3c7';
buttonStatus.querySelector('span').textContent = '🔘 Button sound playing...';
} else {
buttonStatus.style.background = '#f3f4f6';
buttonStatus.querySelector('span').textContent = '🔘 Button ready';
}
}
})
.catch(error => console.error('Error refreshing status:', error));
}
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);
}
// System Control Functions
function shutdownSystem() {
if (confirm('\\u26A0\\uFE0F Are you sure you want to SHUTDOWN the system?\\n\\nThe Raspberry Pi will power off in 1 minute.')) {
fetch('/api/system/shutdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('\\uD83D\\uDD0C System shutting down in 1 minute...', 'warning');
setTimeout(() => {
showAlert('System is now shutting down. This page will be unavailable.', 'error');
}, 3000);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
function restartSystem() {
if (confirm('\\u26A0\\uFE0F Are you sure you want to RESTART the system?\\n\\nThe Raspberry Pi will reboot shortly.')) {
fetch('/api/system/restart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('\\uD83D\\uDD04 System restarting...', 'warning');
setTimeout(() => {
showAlert('System is rebooting. Page will reload automatically.', 'warning');
// Try to reload page after 30 seconds
setTimeout(() => {
location.reload();
}, 30000);
}, 3000);
} else {
showAlert('Error: ' + data.error, 'error');
}
})
.catch(error => showAlert('Error: ' + error, 'error'));
}
}
// USB Backup Functions
function refreshBackupStatus() {
fetch('/api/backup/status')
.then(response => response.json())
.then(data => {
displayBackupStatus(data);
})
.catch(error => console.error('Error fetching backup status:', error));
}
function displayBackupStatus(data) {
const container = document.getElementById('backup-status-container');
if (!data.enabled) {
container.innerHTML = `
<div class="alert alert-warning">
⚠️ USB backup is disabled in configuration
</div>
`;
return;
}
let html = `
<div style="margin-bottom: 15px;">
<p style="margin: 5px 0;"><strong>CRC Verification:</strong> ${data.verify_crc ? '✅ Enabled' : '❌ Disabled'}</p>
<p style="margin: 5px 0;"><strong>Backup on Write:</strong> ${data.backup_on_write ? '✅ Enabled' : '❌ Disabled'}</p>
</div>
<div style="display: grid; gap: 15px;">
`;
data.drives.forEach(drive => {
let statusColor = '#dc2626'; // red
let statusIcon = '';
let statusText = 'Not Mounted';
if (drive.mounted) {
if (drive.writable) {
statusColor = '#16a34a'; // green
statusIcon = '';
statusText = 'Ready';
} else {
statusColor = '#ea580c'; // orange
statusIcon = '⚠️';
statusText = 'Not Writable';
}
}
html += `
<div style="border: 2px solid ${statusColor}; border-radius: 8px; padding: 15px; background: ${statusColor}15;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<p style="margin: 0; font-weight: bold; font-size: 1.1em;">${statusIcon} ${drive.name}</p>
<p style="margin: 5px 0 0 0; color: #6b7280; font-size: 0.85em;">${drive.path}</p>
</div>
<div style="text-align: right;">
<p style="margin: 0; font-weight: bold; color: ${statusColor};">${statusText}</p>
${drive.free_space_mb ? `<p style="margin: 5px 0 0 0; color: #6b7280; font-size: 0.85em;">${drive.free_space_mb.toFixed(0)} MB free</p>` : ''}
</div>
</div>
${drive.error ? `<p style="margin: 10px 0 0 0; color: #dc2626; font-size: 0.85em;">Error: ${drive.error}</p>` : ''}
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
function testBackup() {
const btn = event.target;
btn.disabled = true;
btn.textContent = '🔄 Testing...';
fetch('/api/backup/test', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('Backup test completed. Check console for results.', 'success');
console.log('Backup test results:', data.results);
} else {
showAlert('Backup test failed: ' + data.error, 'error');
}
btn.disabled = false;
btn.textContent = '🧪 Test Backup';
refreshBackupStatus();
})
.catch(error => {
showAlert('Backup test error: ' + error, 'error');
btn.disabled = false;
btn.textContent = '🧪 Test Backup';
});
}
// Load backup status on page load
document.addEventListener('DOMContentLoaded', function() {
refreshBackupStatus();
});
// 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 up-to-date (v{TEMPLATE_VERSION}) 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"🚀 Wedding Phone System Starting...")
print(f"Web Interface Available At:")
print(f" http://{local_ip}:{WEB_PORT}")
print(f" http://localhost:{WEB_PORT}")
print("="*60 + "\n")
# Start production WSGI server (waitress)
print(f"Starting production server on 0.0.0.0:{WEB_PORT}")
serve(app, host='0.0.0.0', port=WEB_PORT, threads=4)
if __name__ == "__main__":
main()