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

@@ -12,6 +12,7 @@ A Raspberry Pi-based rotary phone system for weddings and events. Guests can pic
- **Volume Control**: Adjust playback volume with real-time slider (0-100%)
- **Multiple Message Support**: Upload and manage multiple greeting messages
- **Active Message Selector**: Choose which greeting plays when the phone is picked up
- **Extra Button Support**: Optional GPIO button to play custom sounds on demand
- **HiFiBerry Support**: Optimized for HiFiBerry DAC+ADC Pro audio quality
- **Real-time Status**: Monitor phone status (on-hook/off-hook/recording)
- **Auto-refresh**: Status updates every 5 seconds
@@ -100,8 +101,11 @@ nano config.json # or use your preferred editor
```json
{
"gpio": {
"hook_pin": 17, // GPIO pin for hookswitch
"hook_pressed_state": "LOW" // "LOW" or "HIGH"
"hook_pin": 17, // GPIO pin for hookswitch
"hook_pressed_state": "LOW", // "LOW" or "HIGH"
"extra_button_enabled": true, // Enable extra button feature
"extra_button_pin": 27, // GPIO pin for extra button
"extra_button_pressed_state": "LOW" // "LOW" or "HIGH"
},
"audio": {
"device_index": 1, // HiFiBerry device index
@@ -164,6 +168,7 @@ The web interface provides four main sections:
- **Upload**: Click "Choose WAV File(s)" to upload one or multiple greeting messages
- **Play**: Click "▶️ Play" to preview any greeting in your browser
- **Set Active**: Click "⭐ Set Active" to select which greeting plays when the phone is picked up
- **Set Button**: Click "🔘 Set Button" to assign sound to extra button (if enabled)
- **Delete**: Remove unwanted greetings (cannot delete the active one)
- **Default Tone**: Generate a classic telephone dial tone
@@ -181,6 +186,14 @@ The web interface provides four main sections:
4. **Guest hangs up**: Recording stops and saves automatically
5. **Ready for next call**: System returns to waiting state
### Extra Button Operation (Optional)
If enabled in `config.json`:
1. **Guest presses button**: System detects GPIO signal
2. **Sound plays**: Configured button sound plays through speaker
3. **Non-blocking**: Button can be pressed anytime, even during recording
4. **Debounced**: 0.5s delay prevents accidental double-presses
## File Structure
```
@@ -218,7 +231,10 @@ The `config.json` file contains all system settings:
{
"gpio": {
"hook_pin": 17, // GPIO pin number for hookswitch
"hook_pressed_state": "LOW" // "LOW" or "HIGH" depending on switch
"hook_pressed_state": "LOW", // "LOW" or "HIGH" depending on switch
"extra_button_enabled": true, // Enable optional extra button
"extra_button_pin": 27, // GPIO pin for extra button
"extra_button_pressed_state": "LOW" // Button pressed state
},
"audio": {
"device_index": 1, // Audio device index (run test to find)
@@ -239,6 +255,7 @@ The `config.json` file contains all system settings:
},
"system": {
"active_greeting": "dialtone.wav", // Default greeting
"extra_button_sound": "button_sound.wav", // Default button sound
"volume": 70 // Default volume (0-100)
}
}

View File

@@ -1,7 +1,10 @@
{
"gpio": {
"hook_pin": 17,
"hook_pressed_state": "LOW"
"hook_pressed_state": "LOW",
"extra_button_enabled": true,
"extra_button_pin": 27,
"extra_button_pressed_state": "LOW"
},
"audio": {
"device_index": 1,
@@ -22,6 +25,7 @@
},
"system": {
"active_greeting": "dialtone.wav",
"extra_button_sound": "button_sound.wav",
"volume": 70
}
}

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' })