diff --git a/docs/_javascript/notification_builder.js b/docs/_javascript/notification_builder.js index 48d05bcc3..1526e88c2 100644 --- a/docs/_javascript/notification_builder.js +++ b/docs/_javascript/notification_builder.js @@ -33,6 +33,43 @@ return { ms, hz: match[2] === "p" ? 0 : 440 * Math.pow(2, (midi - 69) / 12) }; }); } + function melodyWav(text) { + const notes = melody(text), rate = 22050; + const frames = Math.round(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; + for (const note of notes) { + elapsed += note.ms; + const end = Math.round(elapsed * rate / 1000), count = end - start; + if (note.hz) { + // Match the buzzer's square wave, with short fades to avoid speaker clicks. + const fade = Math.min(rate * 0.003, count / 2); + for (let i = 0; i < count; i++) { + const envelope = Math.min(1, i / fade, (count - 1 - i) / fade); + const sample = Math.round(6553 * envelope) * (i * note.hz / rate % 1 < 0.5 ? 1 : -1); + view.setInt16(44 + (start + i) * 2, sample, true); + } + } + start = end; + } + return bytes; + } + function parseOutputs(config) { + const pulses = { vibration: pulse(config.vibration), led: pulse(config.led), gpio: [] }; + const notes = melody(config.sound); + if (!["inherit", "on", "off"].includes(config.screen)) throw new Error("Screen must be on, off, or inherit."); + if (config.gpio !== "off" && config.gpio !== "inherit") { + const match = config.gpio.match(/^(\d{1,2}):(.+)$/); + if (!match || Number(match[1]) > 63) throw new Error("GPIO needs pin:pattern, for example 22:50,300,50."); + pulses.gpio = pulse(match[2]); + } + return { pulses, notes }; + } function selector(config) { let target = "all"; if (config.kind === "contact" || config.kind === "room") { @@ -47,14 +84,7 @@ } function commands(config) { const target = selector(config), result = []; - for (const name of ["vibration", "led"]) pulse(config[name]); - melody(config.sound); - if (!["inherit", "on", "off"].includes(config.screen)) throw new Error("Screen must be on, off, or inherit."); - if (config.gpio !== "off" && config.gpio !== "inherit") { - const match = config.gpio.match(/^(\d{1,2}):(.+)$/); - if (!match || Number(match[1]) > 63) throw new Error("GPIO needs pin:pattern, for example 22:50,300,50."); - pulse(match[2]); - } + parseOutputs(config); if (config.repeat !== "forever" && (!/^\d+$/.test(config.repeat) || Number(config.repeat) < 1 || Number(config.repeat) > 65535)) throw new Error("Repeat must be 1-65535 or forever."); if (!/^\d+$/.test(config.gap) || Number(config.gap) < 1 || Number(config.gap) > 60000) throw new Error("Repeat gap must be 1-60000 ms."); if (!["button", "connected", "never"].includes(config.stop)) throw new Error("Unknown stop condition."); @@ -148,7 +178,8 @@ if (root.dataset.ready) return; root.dataset.ready = "true"; const find = role => root.querySelector('[data-role="' + role + '"]'); const form = find("form"), error = find("error"), output = find("commands"), log = find("device-log"); - let client = null, supported = null, previewTimer = null, audio = null, oscillators = []; + const audio = find("preview-audio"); + let client = null, supported = null, previewTimer = null, previewUrl = null, previewVersion = 0; const config = () => Object.fromEntries(new FormData(form)); function render() { try { @@ -163,38 +194,58 @@ for (const [key, value] of Object.entries(values)) if (form.elements[key]) form.elements[key].value = value; render(); } - function stopPreview() { + function stopPreview(status = "Stopped") { + previewVersion++; clearInterval(previewTimer); previewTimer = null; - for (const oscillator of oscillators) { try { oscillator.stop(); } catch (_) {} } oscillators = []; + audio.onerror = null; + audio.pause(); audio.removeAttribute("src"); audio.load(); + if (previewUrl) { global.URL.revokeObjectURL(previewUrl); previewUrl = null; } for (const item of root.querySelectorAll("[data-indicator]")) item.dataset.on = "false"; + find("preview-sound").textContent = status; } - function preview() { - stopPreview(); const c = config(); commands(c); - const pulses = { vibration: pulse(c.vibration), led: pulse(c.led), gpio: pulse(c.gpio.includes(":") ? c.gpio.split(":")[1] : c.gpio) }; - const notes = melody(c.sound), tuneMs = notes.reduce((n, item) => n + item.ms, 0); + async function preview() { + stopPreview(); const version = previewVersion, c = config(); + // A local preview does not need a contact/room key or a saved repeat policy. + const { pulses, notes } = parseOutputs(c), tuneMs = notes.reduce((n, item) => n + item.ms, 0); const duration = Math.max(tuneMs, ...Object.values(pulses).map(list => list.reduce((a, b) => a + b, 0))) || 1000; // Preview one cycle. Never creates an unbounded oscillator or vibration loop. - if (notes.length && (global.AudioContext || global.webkitAudioContext)) { - audio = audio || new (global.AudioContext || global.webkitAudioContext)(); audio.resume(); - let offset = 0; - for (const note of notes) { - if (note.hz) { - const oscillator = audio.createOscillator(), gain = audio.createGain(); - oscillator.type = "square"; oscillator.frequency.value = note.hz; gain.gain.value = 0.03; - oscillator.connect(gain); gain.connect(audio.destination); - oscillator.start(audio.currentTime + offset / 1000); oscillator.stop(audio.currentTime + (offset + note.ms) / 1000); - oscillators.push(oscillator); - } - offset += note.ms; + 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" })); + 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"; + audio.onerror = () => { + if (version !== previewVersion) return; + stopPreview("Sound unavailable"); error.textContent = "The browser could not play the sound preview."; + }; + try { await audio.play(); } + catch (e) { + // Stop can cancel a pending play() without showing a playback failure. + if (version !== previewVersion) return; + stopPreview("Sound unavailable"); throw new Error("Sound preview could not start: " + e.message); } + if (version !== previewVersion) return; + find("preview-sound").textContent = "Playing melody"; + } else { + find("preview-sound").textContent = c.sound === "inherit" ? "Choose a melody to preview inherited sound." : "Sound is off for this preview."; } - const started = performance.now(); + let finishedAt = null; + const started = performance.now() - (notes.length ? audio.currentTime * 1000 : 0); previewTimer = setInterval(() => { - const elapsed = performance.now() - started; + if (!root.isConnected) { stopPreview(); return; } + let elapsed = performance.now() - started; + if (notes.length) { + if (!audio.ended) elapsed = audio.currentTime * 1000; + else { + if (finishedAt === null) finishedAt = performance.now(); + elapsed = tuneMs + performance.now() - finishedAt; + } + } 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) stopPreview(); + if (elapsed >= duration && (!notes.length || audio.ended)) stopPreview("Cycle finished"); }, 10); } const append = text => { log.textContent += text + "\n"; log.scrollTop = log.scrollHeight; }; @@ -205,12 +256,12 @@ return reply; } async function act(action) { + if (action === "preview-stop") { stopPreview(); return; } error.textContent = ""; - const buttons = root.querySelectorAll("button[data-action]"); buttons.forEach(b => b.disabled = true); + const buttons = root.querySelectorAll("button[data-action]"); buttons.forEach(b => b.disabled = b.dataset.action !== "preview-stop"); try { if (action === "copy") { await navigator.clipboard.writeText(commands(config()).join("\n"));find("copy-status").textContent = "Copied"; } - if (action === "preview") preview(); - if (action === "preview-stop") stopPreview(); + if (action === "preview") await preview(); if (action === "connect") { if (!navigator.serial) throw new Error("USB testing requires Chrome or Edge with Web Serial support."); if (client) { await client.close(); client = null; } @@ -240,9 +291,9 @@ const example = event.target.closest("[data-example]"); if (example) preset(example.dataset.example); const button = event.target.closest("[data-action]"); if (button) act(button.dataset.action); }); - global.addEventListener("pagehide", stopPreview);preset("food"); + global.addEventListener("pagehide", () => stopPreview());preset("food"); } - const api = { pulse, melody, selector, commands, notificationText, level, encodeCommand, FrameDecoder, SerialClient, EXAMPLES, init }; + const api = { pulse, melody, melodyWav, parseOutputs, selector, commands, notificationText, level, encodeCommand, FrameDecoder, SerialClient, EXAMPLES, init }; if (typeof module !== "undefined" && module.exports) module.exports = api; global.MeshCoreNotifications = api; if (typeof document !== "undefined") { diff --git a/docs/notifications.md b/docs/notifications.md index c4bd5e980..b281e0400 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -70,8 +70,12 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
Ready
+Ready to play the melody
+ +The melody plays on this device. 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 latency can delay an edge. A haptic motor also takes time to start/stop.
diff --git a/mkdocs.yml b/mkdocs.yml index 0a452a773..70ba0c5ed 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 + - _javascript/notification_builder.js?v=20260930-audio - 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 566d17680..a9772555d 100644 --- a/test/test_notification_builder.js +++ b/test/test_notification_builder.js @@ -28,6 +28,41 @@ assert.equal(builder.notificationText(longPost).length, 151); assert.throws(() => builder.notificationText({...longPost, kind: "room"}), /150 characters for a room post/); assert.throws(() => builder.commands({...builder.EXAMPLES.food, remote: "on"})); assert.throws(() => builder.commands({...vip, id: "1234"})); +// Local examples must play before choosing a recipient. Saved rules still need a key. +assert.equal(builder.parseOutputs(builder.EXAMPLES.vip).notes.length, 4); +assert.throws(() => builder.commands(builder.EXAMPLES.vip), /full 64-digit/); +assert.throws(() => builder.parseOutputs({...vip, gpio: "64:50,50"}), /GPIO needs/); +assert.throws(() => builder.parseOutputs({...vip, screen: "invalid"}), /Screen must/); +// Verify the actual PCM file sent to the browser: note pitch, rests, and one cycle. +const wav = builder.melodyWav("test:d=4,o=5,b=120:a,p,a6"); +const waveView = new DataView(wav.buffer), rate = waveView.getUint32(24, true); +assert.equal(new TextDecoder().decode(wav.slice(0, 4)), "RIFF"); +assert.equal(new TextDecoder().decode(wav.slice(8, 12)), "WAVE"); +assert.equal(new TextDecoder().decode(wav.slice(36, 40)), "data"); +assert.equal(waveView.getUint32(4, true), wav.length - 8); +assert.equal(waveView.getUint16(20, true), 1); // PCM +assert.equal(waveView.getUint16(22, true), 1); // Mono +assert.equal(waveView.getUint16(34, true), 16); +assert.equal(waveView.getUint32(40, true), rate * 1.5 * 2); +assert.equal(wav.length, 44 + rate * 1.5 * 2); +function samples(from, to) { + return Array.from({length: Math.round((to - from) * rate)}, (_, i) => waveView.getInt16(44 + (Math.round(from * rate) + i) * 2, true)); +} +function frequency(values) { + const crossings = values.slice(1).filter((sample, i) => sample > 0 && values[i] <= 0).length; + return crossings * rate / values.length; +} +const firstNote = samples(0.01, 0.49), lastNote = samples(1.01, 1.49); +assert.ok(Math.abs(frequency(firstNote) - 880) < 3); +assert.ok(Math.abs(frequency(lastNote) - 1760) < 3); +assert.ok(Math.sqrt(firstNote.reduce((sum, sample) => sum + sample * sample, 0) / firstNote.length) > 6000); +assert.ok(samples(0.5, 1).every(sample => sample === 0)); +assert.equal(waveView.getInt16(44, true), 0); +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")); +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); assert.equal(new TextDecoder().decode(encoded.slice(4)), "A7|notify.stop"); diff --git a/test/test_notification_preview.py b/test/test_notification_preview.py new file mode 100644 index 000000000..f54ad9080 --- /dev/null +++ b/test/test_notification_preview.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Optional browser checks for notification audio. Requires Playwright and its browser. + +Use MESHCORE_PREVIEW_BROWSER=webkit to also check the Safari engine, or set +MESHCORE_NOTIFICATION_PREVIEW_URL to check the deployed editor. +""" + +import os +from pathlib import Path +import unittest + +try: + from playwright.sync_api import sync_playwright +except ImportError: + sync_playwright = None + + +ROOT = Path(__file__).resolve().parents[1] +TAP_AUDIO = """() => { + const audio = document.querySelector('[data-role="preview-audio"]'); + window.previewAudioStats = { plays: 0, rms: 0 }; + // Attach before playback: late MediaElementSource wiring is silent in WebKit. + const context = new AudioContext(), analyser = context.createAnalyser(); + context.createMediaElementSource(audio).connect(analyser); + analyser.connect(context.destination); + const samples = new Float32Array(analyser.fftSize); + setInterval(() => { + analyser.getFloatTimeDomainData(samples); + const rms = Math.sqrt(samples.reduce((sum, v) => sum + v * v, 0) / samples.length); + previewAudioStats.rms = Math.max(previewAudioStats.rms, rms); + }, 10); + audio.addEventListener('playing', () => { + previewAudioStats.plays++; + context.resume(); + }); +}""" + + +@unittest.skipUnless(sync_playwright, "Install Playwright to run browser audio checks") +class NotificationAudioTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.playwright = sync_playwright().start() + engine = getattr(cls.playwright, os.environ.get("MESHCORE_PREVIEW_BROWSER", "chromium")) + try: + cls.browser = engine.launch(headless=True) + except Exception: + cls.playwright.stop() + raise + + @classmethod + def tearDownClass(cls): + cls.browser.close() + cls.playwright.stop() + + def setUp(self): + self.page = self.browser.new_page() + self.errors = [] + self.page.on("pageerror", lambda error: self.errors.append(str(error))) + url = os.environ.get("MESHCORE_NOTIFICATION_PREVIEW_URL") + if url: + self.page.goto(url, wait_until="networkidle") + else: + source = (ROOT / "docs/notifications.md").read_text(encoding="utf-8") + editor = '