feat(ui-core,ui-lvgl): sound in the Core, L2 speaker via ES8311, Sound settings, melody editor

- genericBuzzer I2S path (BUZZER_I2S): an audio task synthesises the notes
  with eased edges and keeps the DMA queue fed with silence between sounds;
  ES8311 setup; amp powered through the board only around sounds
- ui-core/SoundControl.h (mode, volume, built-in sounds, melody model),
  SoundNotifier moved to the Core, chanctl::melody, schema Sound page
- L2: notifications, alarm / timer ring, locator sounds, Settings > Sound,
  melody editor with the playing note lit, per-chat sound, mute icon
- noteIndex() on every buzzer backend; scrolling popups past screen height

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 00:27:52 +02:00
co-authored by Claude Opus 5.5
parent 2624ab0548
commit 938dfefba6
25 changed files with 1335 additions and 151 deletions
+38 -1
View File
@@ -224,8 +224,45 @@
const y = (e.clientY - r.top - by) * 240 / canvas.clientHeight;
return [Math.max(0, Math.min(319, x)), Math.max(0, Math.min(239, y))];
}
// Speaker: a Web Audio oscillator polled against L's sim_buzzer_* exports,
// as index.html does for the piezo (see its comments). A sine here: the L2
// plays its melodies through a codec and a speaker, not a buzzer. The
// context can only start on a user gesture, so the first touch creates it.
let audioCtx = null, osc = null, gain = null, wasPlaying = false;
function ensureAudio() {
if (audioCtx) { if (audioCtx.state === 'suspended') audioCtx.resume(); return; }
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
osc = audioCtx.createOscillator();
gain = audioCtx.createGain();
gain.gain.value = 0;
osc.type = 'sine';
osc.connect(gain).connect(audioCtx.destination);
osc.start();
}
window.__buzzAudio = () => (audioCtx ? { state: audioCtx.state, gain: gain.gain.value, freq: osc.frequency.value } : null);
const SPEAKER_GAIN = [0.02, 0.05, 0.1, 0.18, 0.28]; // the firmware's -24..0 dB steps
function rampGain(target, s, now) {
gain.gain.cancelScheduledValues(now);
gain.gain.setValueAtTime(gain.gain.value, now);
gain.gain.linearRampToValueAtTime(target, now + s);
}
setInterval(() => {
if (!audioCtx || !L || !L.mod._sim_buzzer_is_playing) return;
const playing = L.call('sim_buzzer_is_playing', 'number') === 1;
const now = audioCtx.currentTime;
if (playing) {
const freq = L.call('sim_buzzer_freq_hz', 'number');
if (freq > 0) osc.frequency.setValueAtTime(freq, now);
rampGain(freq > 0 ? (SPEAKER_GAIN[L.call('sim_buzzer_get_volume', 'number')] || 0) : 0, 0.005, now);
} else if (wasPlaying) {
rampGain(0, 0.01, now);
}
wasPlaying = playing;
}, 20);
let pressed = false;
canvas.addEventListener('pointerdown', (e) => { pressed = true; canvas.setPointerCapture(e.pointerId); touch(...toDevice(e), true); });
canvas.addEventListener('pointerdown', (e) => { ensureAudio(); pressed = true; canvas.setPointerCapture(e.pointerId); touch(...toDevice(e), true); });
canvas.addEventListener('pointermove', (e) => { if (pressed) touch(...toDevice(e), true); });
const lift = (e) => { if (!pressed) return; pressed = false; touch(...toDevice(e), false); };
canvas.addEventListener('pointerup', lift);