Add extra GPIO button support with custom sound playback

Features:
- Configurable extra button on any GPIO pin
- Upload and select custom sound for button press
- Non-blocking button operation (separate thread)
- Debounced to prevent double-triggers (0.5s)
- Works independently from phone handset
- Can be pressed anytime, even during recording

Configuration:
- gpio.extra_button_enabled: Enable/disable feature
- gpio.extra_button_pin: GPIO pin number (default: 27)
- gpio.extra_button_pressed_state: "LOW" or "HIGH"
- system.extra_button_sound: Default sound file

Web Interface:
- Display active button sound in alert
- "🔘 Set Button" action on greeting items
- Visual indicator (🔘) for active button sound
- Upload any WAV file and assign to button
- Play/preview button sounds in browser

Backend:
- RotaryPhone.play_extra_button_sound() method
- RotaryPhone.set_extra_button_sound() method
- Thread-based playback to not block main loop
- /set_extra_button_sound API endpoint
- Extra button sound tracked in user_config.json

Documentation:
- Extra button setup in README
- Configuration examples
- GPIO pin configuration
- Operation workflow

Use Cases:
- Play special message on button press
- Sound effects for wedding games
- Multiple interaction points
- Custom audio triggers

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-24 14:57:31 +07:00
parent ef0373e60b
commit 2dde7d8e43
3 changed files with 132 additions and 10 deletions

View File

@@ -43,6 +43,11 @@ SYS_CONFIG = load_system_config()
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']
@@ -78,6 +83,11 @@ class RotaryPhone:
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)
@@ -93,6 +103,7 @@ class RotaryPhone:
"""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'),
"greetings": [],
"volume": SYS_CONFIG['system']['volume']
}
@@ -125,6 +136,16 @@ class RotaryPhone:
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 set_volume(self, volume):
"""Set playback volume (0-100)"""
volume = max(0, min(100, int(volume))) # Clamp between 0-100
@@ -273,14 +294,39 @@ class RotaryPhone:
"current_recording": self.current_recording
}
def play_extra_button_sound(self):
"""Play sound when extra button is pressed"""
if not EXTRA_BUTTON_ENABLED:
return
button_sound = self.get_extra_button_sound_path()
if not os.path.exists(button_sound):
print(f"Extra button sound not found: {button_sound}")
return
print(f"\n=== Extra button pressed ===")
self.extra_button_playing = True
self.play_sound_file(button_sound)
self.extra_button_playing = False
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: GPIO {EXTRA_BUTTON_PIN}")
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:
if not self.extra_button_playing:
# Play extra button sound in a separate thread to not block
button_thread = threading.Thread(target=self.play_extra_button_sound, daemon=True)
button_thread.start()
time.sleep(0.5) # Debounce
# Wait for handset pickup
if GPIO.input(HOOK_PIN) == HOOK_PRESSED and self.phone_status == "on_hook":
self.phone_status = "off_hook"
@@ -289,13 +335,13 @@ class RotaryPhone:
# Play active greeting message
greeting_file = self.get_active_greeting_path()
self.play_sound_file(greeting_file)
# Start recording
if self.phone_status == "off_hook": # Still off hook after sound
self.record_audio()
self.phone_status = "on_hook"
time.sleep(0.1)
except KeyboardInterrupt:
@@ -320,12 +366,15 @@ def index():
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")
volume = phone.get_volume()
return render_template('index.html',
recordings=recordings,
greetings=greetings,
active_greeting=active_greeting,
extra_button_sound=extra_button_sound,
extra_button_enabled=EXTRA_BUTTON_ENABLED,
status=status,
volume=volume)
@@ -401,6 +450,22 @@ def set_active_greeting():
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('/delete_greeting/<filename>', methods=['POST'])
def delete_greeting(filename):
"""Delete a greeting file"""
@@ -484,7 +549,8 @@ def get_greetings():
"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_active": filename == phone.config.get("active_greeting", "dialtone.wav"),
"is_button_sound": filename == phone.config.get("extra_button_sound", "button_sound.wav")
})
return greetings
@@ -1000,6 +1066,9 @@ if __name__ == "__main__":
<div class="alert alert-info">
Active greeting: <strong>{{ active_greeting }}</strong>
{% if extra_button_enabled %}
<br>🔘 Extra button sound: <strong>{{ extra_button_sound }}</strong>
{% endif %}
</div>
<div class="upload-section">
@@ -1032,7 +1101,9 @@ if __name__ == "__main__":
<div class="recording-item {% if greeting.is_active %}active-greeting{% endif %}" id="greeting-{{ loop.index }}">
<div class="recording-info">
<h3>
{% if greeting.is_active %}⭐{% endif %} {{ greeting.filename }}
{% if greeting.is_active %}⭐{% endif %}
{% if greeting.is_button_sound and extra_button_enabled %}🔘{% endif %}
{{ greeting.filename }}
</h3>
<div class="recording-meta">
📅 {{ greeting.date }} |
@@ -1048,6 +1119,11 @@ if __name__ == "__main__":
<button class="btn btn-primary" onclick="setActiveGreeting('{{ greeting.filename }}')">
⭐ Set Active
</button>
{% if extra_button_enabled and not greeting.is_button_sound %}
<button class="btn btn-primary" onclick="setExtraButtonSound('{{ greeting.filename }}')">
🔘 Set Button
</button>
{% endif %}
<button class="btn btn-danger" onclick="deleteGreeting('{{ greeting.filename }}', {{ loop.index }})">
🗑️ Delete
</button>
@@ -1055,6 +1131,11 @@ if __name__ == "__main__":
<button class="btn btn-secondary" disabled>
✓ Active
</button>
{% if extra_button_enabled and not greeting.is_button_sound %}
<button class="btn btn-primary" onclick="setExtraButtonSound('{{ greeting.filename }}')">
🔘 Set Button
</button>
{% endif %}
{% endif %}
</div>
</div>
@@ -1304,6 +1385,26 @@ if __name__ == "__main__":
.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 deleteGreeting(filename, index) {
if (confirm(`Delete greeting '${filename}'?`)) {
fetch('/delete_greeting/' + filename, { method: 'POST' })