Skip to content

Commit 3835de5

Browse files
Eric Migicovskyclaude
andcommitted
qemu-wasm: readable console, notifications, voice dictation
- pulse-console.js: full PULSEv2 client. Frame layer (0x55 flags, COBS, 0x55<->0x00 transparency, CRC-32 residue check), PPP control FSMs for LCP and the reliable-transport NCP, and a stop-and-wait LAPB client for the TRAIN transport. Logs (push 0x5021 port 3, the 29-byte <c16sccQH record) render as [time] <lvl> file:line: message -- the qemu build is plaintext PULSE, not hashed, so no dictionary is needed. The console input line drives the dbgserial prompt over the reliable transport (port 0x3e20). - phone-extras.js: notifications and voice. buildNotification serializes a 46-byte SerializedTimelineItemHeader plus attribute and action lists (status must be 0, payload_length exact) into a BlobDB insert on the notifs db; the default notification carries a Response action with canned replies so the Reply -> Voice path works. VoiceService accepts dictation session setups on endpoint 11000, counts the (silent) Speex frames on 10000, and on stop returns a one-sentence transcription from browser speech recognition with a typed-prompt fallback, echoing app_initiated flags and app uuid. Verified in Chromium end to end: parsed log lines, a 'version' prompt round trip, a notification banner with working action menu, and Reply -> Voice dictation displaying the browser-provided transcript. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pi1WErefJ7c6kXodajm25M Signed-off-by: Eric Migicovsky <eric@repebble.com>
1 parent cde681f commit 3835de5

6 files changed

Lines changed: 1332 additions & 64 deletions

File tree

tools/qemu-wasm/site-dist/index.html

Lines changed: 117 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -488,66 +488,50 @@ <h1>PebbleOS Emulator</h1>
488488
// ================================================================
489489
var serialPos = 0;
490490

491-
function pollSerial() {
492-
if (!qemu) return;
491+
// The PULSE client (module script) pulls new UART2 bytes from here.
492+
window.__readUart2 = function () {
493+
if (!qemu) return null;
493494
try {
494495
var stat = qemu.FS.stat('/tmp/uart2.log');
495-
if (stat.size <= serialPos) return;
496+
if (stat.size <= serialPos) return null;
496497
var stream = qemu.FS.open('/tmp/uart2.log', 'r');
497498
var chunk = new Uint8Array(stat.size - serialPos);
498499
qemu.FS.read(stream, chunk, 0, chunk.length, serialPos);
499500
qemu.FS.close(stream);
500501
serialPos = stat.size;
501-
var text = '';
502-
for (var i = 0; i < chunk.length; i++) {
503-
var b = chunk[i];
504-
if ((b >= 32 && b <= 126) || b === 9 || b === 10 || b === 13) {
505-
text += String.fromCharCode(b);
506-
}
507-
}
508-
if (text) log(text.replace(/\r/g, ''));
502+
return chunk;
509503
} catch (e) {
510-
// File doesn't exist until QEMU opens the chardev.
504+
return null; // file doesn't exist until QEMU opens the chardev
511505
}
512-
}
513-
setInterval(pollSerial, 200);
506+
};
514507

515508
// ================================================================
516509
// Console input: pebble_simple_uart exports a browser->guest ring
517510
// for the dbgserial prompt (UART2). Enter sends the line + CRLF.
518511
// ================================================================
519512
var consoleCtrlAddr = 0;
520-
var consoleInput = document.getElementById('console-input');
521513

522-
function consoleSend(text) {
523-
if (!qemu) return;
514+
// Raw byte writer into the dbgserial RX ring; the PULSE client in
515+
// the module script frames its traffic through this.
516+
window.__consoleWriteBytes = function (bytes) {
517+
if (!qemu) return false;
524518
if (!consoleCtrlAddr) {
525-
if (typeof qemu._pebble_wasm_console_ctrl !== 'function') return;
519+
if (typeof qemu._pebble_wasm_console_ctrl !== 'function') return false;
526520
consoleCtrlAddr = Number(qemu._pebble_wasm_console_ctrl());
527-
if (!consoleCtrlAddr) return;
521+
if (!consoleCtrlAddr) return false;
528522
}
529523
var base = consoleCtrlAddr >> 2;
530524
var u32 = qemu.HEAPU32;
531525
var buf = u32[base], size = u32[base + 1];
532526
var head = Atomics.load(u32, base + 2);
533527
var tail = Atomics.load(u32, base + 3);
534-
var bytes = [];
535-
for (var i = 0; i < text.length; i++) bytes.push(text.charCodeAt(i) & 0xff);
536-
bytes.push(13, 10);
537-
if (size - (head - tail) < bytes.length) return; // ring full
528+
if (size - (head - tail) < bytes.length) return false;
538529
for (var j = 0; j < bytes.length; j++) {
539530
qemu.HEAPU8[buf + ((head + j) % size)] = bytes[j];
540531
}
541532
Atomics.store(u32, base + 2, (head + bytes.length) >>> 0);
542-
}
543-
544-
consoleInput.addEventListener('keydown', function (e) {
545-
if (e.key !== 'Enter') return;
546-
var text = consoleInput.value;
547-
consoleInput.value = '';
548-
log('> ' + text);
549-
consoleSend(text);
550-
});
533+
return true;
534+
};
551535

552536
// ================================================================
553537
// Persistence: the 32 MB SPI flash (apps, settings) is saved to
@@ -712,6 +696,8 @@ <h1>PebbleOS Emulator</h1>
712696
import { fetchPbwFromStore } from './store.js';
713697
import { AppMessageClient } from './appmessage.js';
714698
import { PkjsRuntime, makeIframeSandbox } from './pkjs-runtime.js';
699+
import { PulseConsole } from './pulse-console.js';
700+
import { NotificationSender, VoiceService } from './phone-extras.js';
715701

716702
const input = document.getElementById('install-input');
717703
const btn = document.getElementById('btn-install');
@@ -871,9 +857,108 @@ <h1>PebbleOS Emulator</h1>
871857
setInterval(() => phone.poll(), 20);
872858
wireSensors();
873859
restorePkjsApps();
860+
wireConsole();
861+
wireNotifications();
862+
wireVoice();
874863
clearInterval(attach);
875864
}, 500);
876865

866+
// ---- PULSE console: readable logs + interactive dbgserial prompt ----
867+
let pulse = null;
868+
function wireConsole() {
869+
const pad = (n, w) => String(n).padStart(w, '0');
870+
pulse = new PulseConsole((bytes) => window.__consoleWriteBytes(bytes), {
871+
onLog: (r) => {
872+
const d = new Date(r.timeMs);
873+
const lvl = r.levelName ? ' <' + r.levelName + '>' : '';
874+
window.log(`[${pad(d.getHours(),2)}:${pad(d.getMinutes(),2)}:${pad(d.getSeconds(),2)}.${pad(d.getMilliseconds(),3)}]${lvl} ${r.file}:${r.line}: ${r.message}`);
875+
},
876+
onPrompt: (text, done) => { if (text) window.log(' ' + text); },
877+
onRaw: (text) => window.log(text),
878+
log: (m) => console.log('[pulse] ' + m),
879+
});
880+
setInterval(() => {
881+
const bytes = window.__readUart2();
882+
if (bytes) pulse.feed(bytes);
883+
}, 100);
884+
885+
const input = document.getElementById('console-input');
886+
input.addEventListener('keydown', (e) => {
887+
if (e.key !== 'Enter') return;
888+
const text = input.value.trim();
889+
input.value = '';
890+
if (!text) return;
891+
window.log('> ' + text);
892+
if (!pulse.promptReady) window.log(' (prompt still connecting — command queued)');
893+
pulse.command(text);
894+
});
895+
}
896+
897+
// ---- notifications ----
898+
function wireNotifications() {
899+
const sender = new NotificationSender(phone, (m) => say(m));
900+
const btn2 = document.getElementById('btn-notify');
901+
btn2.disabled = false;
902+
btn2.addEventListener('click', async () => {
903+
btn2.disabled = true;
904+
try {
905+
await sender.send({
906+
title: document.getElementById('notif-title').value || 'Claude',
907+
body: document.getElementById('notif-body').value || 'Hello!',
908+
});
909+
say('Notification sent — check the watch (Reply → Voice tests dictation).');
910+
} catch (e) {
911+
say('Notification failed: ' + e.message);
912+
} finally {
913+
btn2.disabled = false;
914+
}
915+
});
916+
}
917+
918+
// ---- voice dictation: browser speech recognition, typed fallback ----
919+
function wireVoice() {
920+
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
921+
let recog = null;
922+
let transcript = '';
923+
let recogDone = null;
924+
925+
const voice = new VoiceService(phone, async () => {
926+
if (recog) {
927+
try { recog.stop(); } catch (e) { /* already stopped */ }
928+
// give the recognizer a beat to finalize
929+
const settled = await new Promise((res) => {
930+
recogDone = res;
931+
setTimeout(() => res(transcript), 3000);
932+
});
933+
recog = null;
934+
if (settled && settled.trim()) return settled;
935+
}
936+
return window.prompt('Dictation: say what the watch heard', transcript || 'Hello from the browser');
937+
}, (m) => { console.log('[voice] ' + m); if (window.log) window.log('[voice] ' + m); });
938+
939+
voice.onSessionStart = () => {
940+
transcript = '';
941+
if (!SR) { say('Dictation started — no speech recognition in this browser, you\'ll get a text box.'); return; }
942+
try {
943+
recog = new SR();
944+
recog.continuous = true;
945+
recog.interimResults = true;
946+
recog.lang = navigator.language || 'en-US';
947+
recog.onresult = (e) => {
948+
let t = '';
949+
for (const res of e.results) t += res[0].transcript;
950+
transcript = t;
951+
};
952+
recog.onend = () => { if (recogDone) recogDone(transcript); };
953+
recog.onerror = (e) => { console.log('[voice] recognition error: ' + e.error); };
954+
recog.start();
955+
say('Dictation: speak into your microphone now…');
956+
} catch (e) {
957+
recog = null;
958+
}
959+
};
960+
}
961+
877962
// ---- sensors & phone panel: QemuProtocol injections ----
878963
function wireSensors() {
879964
const battSlider = document.getElementById('batt-slider');

0 commit comments

Comments
 (0)