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>
3010 lines
111 KiB
Python
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()">×</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()
|