Add UV package management and volume control feature

Features:
- Add pyproject.toml for UV package management
- Volume control with real-time slider (0-100%)
- Backend volume adjustment with numpy audio scaling
- Volume setting persists in config.json
- Debounced API calls for smooth slider interaction
- Enhanced audio playback with volume multiplier
- Update README with UV installation instructions
- Add volume control documentation

API Changes:
- GET /api/volume - Get current volume setting
- POST /api/volume - Set volume level

🤖 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:43:57 +07:00
parent 753b1bddfa
commit 1657a242fd
3 changed files with 211 additions and 21 deletions

View File

@@ -67,13 +67,18 @@ class RotaryPhone:
"""Load configuration from JSON file"""
default_config = {
"active_greeting": "dialtone.wav",
"greetings": []
"greetings": [],
"volume": 70 # Default volume percentage (0-100)
}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
config = json.load(f)
# Ensure volume key exists
if "volume" not in config:
config["volume"] = 70
return config
except:
pass
@@ -93,6 +98,17 @@ class RotaryPhone:
"""Set which greeting message to play"""
self.config["active_greeting"] = 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
self.config["volume"] = volume
self.save_config()
return volume
def get_volume(self):
"""Get current volume setting"""
return self.config.get("volume", 70)
def generate_default_dialtone(self):
"""Generate a classic dial tone (350Hz + 440Hz) and save as default"""
@@ -119,16 +135,16 @@ class RotaryPhone:
print(f"Default dial tone saved to {DIALTONE_FILE}")
def play_sound_file(self, filepath):
"""Play a WAV file"""
"""Play a WAV file with volume control"""
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')
# Use device index 1 for HiFiBerry (change if needed)
stream = self.audio.open(
format=self.audio.get_format_from_width(wf.getsampwidth()),
@@ -138,19 +154,28 @@ class RotaryPhone:
output_device_index=1, # HiFiBerry device index
frames_per_buffer=CHUNK
)
# Play the sound
# Get volume multiplier (0.0 to 1.0)
volume = self.get_volume() / 100.0
# Play the sound with volume control
data = wf.readframes(CHUNK)
while data and self.phone_status == "off_hook":
# 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
@@ -269,12 +294,14 @@ def index():
greetings = get_greetings()
status = phone.get_status()
active_greeting = phone.config.get("active_greeting", "dialtone.wav")
volume = phone.get_volume()
return render_template('index.html',
recordings=recordings,
greetings=greetings,
active_greeting=active_greeting,
status=status)
status=status,
volume=volume)
@app.route('/api/status')
def api_status():
@@ -291,6 +318,19 @@ 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('/upload_greeting', methods=['POST'])
def upload_greeting():
"""Upload a new greeting message"""
@@ -835,6 +875,46 @@ if __name__ == "__main__":
margin-bottom: 10px;
word-break: break-all;
}
/* Volume slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #5568d3;
transform: scale(1.2);
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s;
}
input[type="range"]::-moz-range-thumb:hover {
background: #5568d3;
transform: scale(1.2);
}
</style>
</head>
<body>
@@ -870,7 +950,24 @@ if __name__ == "__main__":
</div>
<button class="btn btn-secondary" onclick="refreshStatus()">🔄 Refresh</button>
</div>
<!-- Volume Control Card -->
<div class="card">
<h2>🔊 Volume Control</h2>
<div style="padding: 20px;">
<div style="display: flex; align-items: center; gap: 20px;">
<span style="font-size: 1.5em;">🔇</span>
<input type="range" id="volume-slider" min="0" max="100" value="{{ volume }}"
style="flex: 1; height: 8px; border-radius: 5px; outline: none; background: linear-gradient(to right, #667eea 0%, #667eea {{ volume }}%, #ddd {{ volume }}%, #ddd 100%);">
<span style="font-size: 1.5em;">🔊</span>
<span id="volume-display" style="font-weight: bold; min-width: 50px; text-align: center; font-size: 1.2em;">{{ volume }}%</span>
</div>
<p style="margin-top: 15px; color: #6b7280; font-size: 0.9em; text-align: center;">
Adjust the playback volume for greeting messages
</p>
</div>
</div>
<!-- Greeting Messages Card -->
<div class="card">
<h2>🎵 Greeting Messages</h2>
@@ -1017,7 +1114,43 @@ if __name__ == "__main__":
<script>
let selectedFiles = null;
let volumeUpdateTimer = null;
// Volume control
const volumeSlider = document.getElementById('volume-slider');
const volumeDisplay = document.getElementById('volume-display');
volumeSlider.addEventListener('input', function() {
const value = this.value;
volumeDisplay.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(volumeUpdateTimer);
volumeUpdateTimer = setTimeout(() => {
updateVolume(value);
}, 300);
});
function updateVolume(volume) {
fetch('/api/volume', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ volume: parseInt(volume) })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Volume updated to ' + data.volume + '%');
}
})
.catch(error => console.error('Error updating volume:', error));
}
function handleFileSelect(input) {
if (input.files && input.files.length > 0) {
selectedFiles = input.files;