diff --git a/docs/_javascript/notification_builder.js b/docs/_javascript/notification_builder.js index 1526e88c2..5673de894 100644 --- a/docs/_javascript/notification_builder.js +++ b/docs/_javascript/notification_builder.js @@ -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; }; diff --git a/docs/notifications.md b/docs/notifications.md index b281e0400..4b7fabb74 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -74,7 +74,8 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud. -
The melody plays on this device. No contact or room key is needed to +
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.
Timing here is a visual approximation. Firmware schedules outputs using millisecond timestamps without blocking radio work; loop and hardware diff --git a/mkdocs.yml b/mkdocs.yml index 70ba0c5ed..6c38fe820 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/test/test_notification_builder.js b/test/test_notification_builder.js index a9772555d..358972166 100644 --- a/test/test_notification_builder.js +++ b/test/test_notification_builder.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); diff --git a/test/test_notification_preview.py b/test/test_notification_preview.py index f54ad9080..108d731a8 100644 --- a/test/test_notification_preview.py +++ b/test/test_notification_preview.py @@ -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)