mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 21:11:16 +00:00
Preserve notification preview opening notes through audio startup
This commit is contained in:
1 parent
760fa9434e
commit
def8db940d
5 files changed
+90
-15
No files matched your search
@@ -1,6 +1,7 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
const OUTPUTS = { vibration: 1, sound: 2, led: 4, screen: 8, gpio: 16 };
|
||||
const PREVIEW_WARMUP_MS = 1000;
|
||||
const EXAMPLES = {
|
||||
find: { label: "Find my node", kind: "all", id: "", when: "disconnected", vibration: "200,200,200", led: "100,100,100,700", sound: "find:d=8,o=6,b=180:c,e,g,p", screen: "on", gpio: "off", repeat: "forever", gap: "1000", stop: "connected" },
|
||||
food: { label: "Your food truck order is ready", kind: "channel", id: "3", when: "any", vibration: "50,300,40,20,500", led: "100,100,100,500", sound: "order:d=8,o=5,b=180:c,e,g,4c6", screen: "on", gpio: "off", repeat: "forever", gap: "2000", stop: "button" },
|
||||
@@ -33,16 +34,18 @@
|
||||
return { ms, hz: match[2] === "p" ? 0 : 440 * Math.pow(2, (midi - 69) / 12) };
|
||||
});
|
||||
}
|
||||
function melodyWav(text) {
|
||||
function melodyWav(text, leadInMs = 0) {
|
||||
if (!Number.isInteger(leadInMs) || leadInMs < 0 || leadInMs > 2000) throw new Error("Audio lead-in must be 0-2000 ms.");
|
||||
const notes = melody(text), rate = 22050;
|
||||
const frames = Math.round(notes.reduce((total, note) => total + note.ms, 0) * rate / 1000);
|
||||
if (!notes.length) leadInMs = 0;
|
||||
const frames = Math.round((leadInMs + notes.reduce((total, note) => total + note.ms, 0)) * rate / 1000);
|
||||
const bytes = new Uint8Array(44 + frames * 2), view = new DataView(bytes.buffer);
|
||||
const label = (offset, value) => { for (let i = 0; i < value.length; i++) bytes[offset + i] = value.charCodeAt(i); };
|
||||
label(0, "RIFF"); view.setUint32(4, bytes.length - 8, true); label(8, "WAVE");
|
||||
label(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true); view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true);
|
||||
view.setUint16(32, 2, true); view.setUint16(34, 16, true); label(36, "data"); view.setUint32(40, frames * 2, true);
|
||||
let elapsed = 0, start = 0;
|
||||
let elapsed = leadInMs, start = Math.round(leadInMs * rate / 1000);
|
||||
for (const note of notes) {
|
||||
elapsed += note.ms;
|
||||
const end = Math.round(elapsed * rate / 1000), count = end - start;
|
||||
@@ -211,7 +214,9 @@
|
||||
// Preview one cycle. Never creates an unbounded oscillator or vibration loop.
|
||||
if (notes.length) {
|
||||
// Use media playback rather than Web Audio's ambient/ringer channel on iOS.
|
||||
previewUrl = global.URL.createObjectURL(new Blob([melodyWav(c.sound)], { type: "audio/wav" }));
|
||||
// play() can resolve before the output is audible. Put warmup and melody
|
||||
// in one stream so no opening notes are spent starting a cold audio route.
|
||||
previewUrl = global.URL.createObjectURL(new Blob([melodyWav(c.sound, PREVIEW_WARMUP_MS)], { type: "audio/wav" }));
|
||||
audio.src = previewUrl; audio.loop = false; audio.muted = false; audio.volume = 1;
|
||||
find("preview-sound").textContent = "Starting melody...";
|
||||
find("preview-time").textContent = "0 / " + duration + " ms";
|
||||
@@ -226,26 +231,40 @@
|
||||
stopPreview("Sound unavailable"); throw new Error("Sound preview could not start: " + e.message);
|
||||
}
|
||||
if (version !== previewVersion) return;
|
||||
find("preview-sound").textContent = "Playing melody";
|
||||
find("preview-sound").textContent = "Warming up audio...";
|
||||
} else {
|
||||
find("preview-sound").textContent = c.sound === "inherit" ? "Choose a melody to preview inherited sound." : "Sound is off for this preview.";
|
||||
}
|
||||
let finishedAt = null;
|
||||
const started = performance.now() - (notes.length ? audio.currentTime * 1000 : 0);
|
||||
const started = performance.now();
|
||||
previewTimer = setInterval(() => {
|
||||
if (!root.isConnected) { stopPreview(); return; }
|
||||
let elapsed = performance.now() - started;
|
||||
if (notes.length) {
|
||||
if (!audio.ended) elapsed = audio.currentTime * 1000;
|
||||
let mediaElapsed;
|
||||
if (!audio.ended) {
|
||||
mediaElapsed = audio.currentTime * 1000 - PREVIEW_WARMUP_MS;
|
||||
}
|
||||
else {
|
||||
if (finishedAt === null) finishedAt = performance.now();
|
||||
elapsed = tuneMs + performance.now() - finishedAt;
|
||||
mediaElapsed = tuneMs + performance.now() - finishedAt;
|
||||
}
|
||||
// Some media clocks run ahead during preroll. Keep the display from
|
||||
// spending the warmup or the first notes before real time has passed.
|
||||
elapsed = Math.min(mediaElapsed, elapsed - PREVIEW_WARMUP_MS);
|
||||
if (elapsed < 0) return;
|
||||
find("preview-sound").textContent = "Playing melody";
|
||||
}
|
||||
for (const name of Object.keys(pulses)) root.querySelector('[data-indicator="' + name + '"]').dataset.on = String(level(pulses[name], elapsed));
|
||||
root.querySelector('[data-indicator="screen"]').dataset.on = String(c.screen === "on");
|
||||
find("preview-time").textContent = Math.min(Math.round(elapsed), duration) + " / " + duration + " ms";
|
||||
if (elapsed >= duration && (!notes.length || audio.ended)) stopPreview("Cycle finished");
|
||||
if (elapsed >= duration && (!notes.length || audio.ended)) {
|
||||
clearInterval(previewTimer); previewTimer = null;
|
||||
for (const item of root.querySelectorAll("[data-indicator]")) item.dataset.on = "false";
|
||||
find("preview-sound").textContent = "Cycle finished";
|
||||
// Do not load/reset an ended source: output may still have buffered
|
||||
// notes. Explicit Stop, the next preview, or pagehide releases it.
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
const append = text => { log.textContent += text + "\n"; log.scrollTop = log.scrollHeight; };
|
||||
|
||||
@@ -74,7 +74,8 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
|
||||
<audio data-role="preview-audio" preload="none"></audio>
|
||||
<button type="button" data-action="preview">Preview with sound</button>
|
||||
<button type="button" data-action="preview-stop">Stop preview</button>
|
||||
<p>The melody plays on this device. No contact or room key is needed to
|
||||
<p>The melody plays on this device after a one-second audio warmup, then
|
||||
the timer and output indicators start together. No contact or room key is needed to
|
||||
preview an example. Sound set to off or inherit plays no melody here.</p>
|
||||
<p>Timing here is a visual approximation. Firmware schedules outputs using
|
||||
millisecond timestamps without blocking radio work; loop and hardware
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ extra_css:
|
||||
- _stylesheets/preset_test.css?v=20260923-2
|
||||
|
||||
extra_javascript:
|
||||
- _javascript/notification_builder.js?v=20260930-audio
|
||||
- _javascript/notification_builder.js?v=20260930-audio-start
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
|
||||
- _javascript/firmware_picker.js?v=20260929-4
|
||||
- _javascript/telemetry_decoder.js
|
||||
|
||||
@@ -62,6 +62,15 @@ assert.equal(waveView.getInt16(wav.length - 2, true), 0);
|
||||
assert.equal(builder.melodyWav("off").length, 44);
|
||||
assert.equal(builder.melodyWav("inherit").length, 44);
|
||||
assert.throws(() => builder.melodyWav("invalid"));
|
||||
// The browser warmup must preserve every sample, including the opening notes.
|
||||
const warmedWav = builder.melodyWav("test:d=4,o=5,b=120:a,p,a6", 1000);
|
||||
const leadBytes = rate * 2;
|
||||
assert.equal(warmedWav.length, wav.length + leadBytes);
|
||||
assert.ok(warmedWav.slice(44, 44 + leadBytes).every(byte => byte === 0));
|
||||
assert.deepEqual(warmedWav.slice(44 + leadBytes), wav.slice(44));
|
||||
assert.equal(new DataView(warmedWav.buffer).getUint32(40, true), warmedWav.length - 44);
|
||||
assert.equal(builder.melodyWav("off", 1000).length, 44);
|
||||
for (const lead of [-1, 2001, 0.5, NaN]) assert.throws(() => builder.melodyWav("off", lead));
|
||||
console.log("Sound preview: audible PCM, note pitch, silence, duration, and recipient-free examples passed");
|
||||
const encoded = builder.encodeCommand("notify.stop", "A7");
|
||||
assert.equal(encoded[0], 60);assert.equal(encoded[3], 0x42);
|
||||
|
||||
@@ -18,19 +18,33 @@ except ImportError:
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TAP_AUDIO = """() => {
|
||||
const audio = document.querySelector('[data-role="preview-audio"]');
|
||||
window.previewAudioStats = { plays: 0, rms: 0 };
|
||||
window.previewAudioStats = { plays: 0, rms: 0, tones: [] };
|
||||
// Attach before playback: late MediaElementSource wiring is silent in WebKit.
|
||||
const context = new AudioContext(), analyser = context.createAnalyser();
|
||||
context.createMediaElementSource(audio).connect(analyser);
|
||||
const context = new AudioContext(), analyser = context.createAnalyser(), gate = context.createGain();
|
||||
context.createMediaElementSource(audio).connect(gate); gate.connect(analyser);
|
||||
analyser.connect(context.destination);
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
let collectingAfter = Infinity;
|
||||
setInterval(() => {
|
||||
// Analyser windows can retain the previous play's final samples.
|
||||
if (context.currentTime < collectingAfter) return;
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
const rms = Math.sqrt(samples.reduce((sum, v) => sum + v * v, 0) / samples.length);
|
||||
previewAudioStats.rms = Math.max(previewAudioStats.rms, rms);
|
||||
if (rms > 0.05) {
|
||||
let crossings = 0;
|
||||
for (let i = 1; i < samples.length; i++) if (samples[i] > 0 && samples[i - 1] <= 0) crossings++;
|
||||
previewAudioStats.tones.push({hz: crossings * context.sampleRate / samples.length, ms: audio.currentTime * 1000});
|
||||
}
|
||||
}, 10);
|
||||
audio.addEventListener('playing', () => {
|
||||
previewAudioStats.plays++;
|
||||
previewAudioStats.rms = 0; previewAudioStats.tones = [];
|
||||
const now = context.currentTime;
|
||||
collectingAfter = now + Math.max((window.previewMuteStartupMs || 0) / 1000, samples.length / context.sampleRate);
|
||||
gate.gain.cancelScheduledValues(now);
|
||||
gate.gain.setValueAtTime(window.previewMuteStartupMs ? 0 : 1, now);
|
||||
gate.gain.setValueAtTime(1, now + (window.previewMuteStartupMs || 0) / 1000);
|
||||
context.resume();
|
||||
});
|
||||
}"""
|
||||
@@ -87,7 +101,7 @@ class NotificationAudioTest(unittest.TestCase):
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
self.page.wait_for_function("document.querySelector('[data-role=preview-sound]').textContent === 'Cycle finished'")
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.plays"), index)
|
||||
self.assertTrue(self.page.evaluate("document.querySelector('audio').paused && !document.querySelector('audio').hasAttribute('src')"))
|
||||
self.assertTrue(self.page.evaluate("document.querySelector('audio').paused && document.querySelector('audio').ended"))
|
||||
|
||||
def test_timer_waits_for_audio_and_stop_cancels_pending_start(self):
|
||||
self.page.evaluate("""() => {
|
||||
@@ -128,6 +142,38 @@ class NotificationAudioTest(unittest.TestCase):
|
||||
self.assertEqual(self.page.locator('[data-role="preview-sound"]').inner_text(), "Stopped")
|
||||
self.assertTrue(self.page.locator('[data-action="preview"]').is_enabled())
|
||||
|
||||
def test_cold_output_preserves_all_opening_notes_on_repeated_starts(self):
|
||||
# A successful play() does not tell us when a cold output becomes audible.
|
||||
# Drop its first 750 ms, enough to lose several unprotected opening notes.
|
||||
self.page.evaluate("window.previewMuteStartupMs = 750")
|
||||
for attempt in range(3):
|
||||
self.page.evaluate("previewAudioStats.rms = 0; previewAudioStats.tones = []")
|
||||
self.click("preview")
|
||||
self.page.wait_for_function("previewAudioStats.plays === " + str(attempt + 1))
|
||||
self.page.wait_for_function("document.querySelector('audio').currentTime >= 0.3")
|
||||
self.assertEqual(self.page.locator('[data-role="preview-time"]').inner_text(), "0 / 910 ms")
|
||||
self.assertEqual(self.page.locator('[data-role="preview-sound"]').inner_text(), "Warming up audio...")
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.rms"), 0)
|
||||
for indicator in ("vibration", "led", "gpio", "screen"):
|
||||
self.assertEqual(self.page.locator('[data-indicator="' + indicator + '"]').get_attribute("data-on"), "false")
|
||||
self.audible()
|
||||
self.page.wait_for_function("document.querySelector('[data-role=preview-sound]').textContent === 'Cycle finished'")
|
||||
tones = self.page.evaluate("previewAudioStats.tones")
|
||||
# Check every pitch, especially the initial C/E/G and final C6,
|
||||
# rather than treating any later sound as proof of a complete tune.
|
||||
for hz in (523.25, 659.25, 783.99, 1046.50):
|
||||
self.assertTrue(any(abs(tone["hz"] - hz) < 25 for tone in tones), (attempt, hz, tones))
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.plays"), attempt + 1)
|
||||
|
||||
def test_stop_during_warmup_never_plays_the_first_note(self):
|
||||
self.click("preview")
|
||||
self.page.wait_for_function("document.querySelector('audio').currentTime >= 0.2")
|
||||
self.click("preview-stop")
|
||||
self.page.wait_for_timeout(1100)
|
||||
self.assertEqual(self.page.evaluate("previewAudioStats.rms"), 0)
|
||||
self.assertEqual(self.page.locator('[data-role="preview-sound"]').inner_text(), "Stopped")
|
||||
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
|
||||
|
||||
def test_off_inherit_stop_and_playback_errors(self):
|
||||
for value in ("off", "inherit"):
|
||||
self.page.locator('input[name="sound"]').fill(value)
|
||||
|
||||
Reference in new issue
Block a user