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:
23
README.md
23
README.md
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user