From 9186bccd805194496ca8c07f697fbd6a7455ac27 Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 4 Jul 2026 02:38:18 -0700 Subject: [PATCH] Implement browser-based voice notes recording, standby flicking remembrance candle, and favorite memory pinning --- sound_picture_app/Display_PNG.cpp | 51 +++++ sound_picture_app/Display_PNG.h | 5 +- sound_picture_app/SD_Card.cpp | 6 + sound_picture_app/SD_Card.h | 1 + sound_picture_app/Web_Server.cpp | 286 ++++++++++++++++++++++-- sound_picture_app/sound_picture_app.ino | 48 +++- 6 files changed, 371 insertions(+), 26 deletions(-) diff --git a/sound_picture_app/Display_PNG.cpp b/sound_picture_app/Display_PNG.cpp index b09249a..b80f4b3 100644 --- a/sound_picture_app/Display_PNG.cpp +++ b/sound_picture_app/Display_PNG.cpp @@ -71,3 +71,54 @@ void Display_Memory_Photo(int memoryIndex) { snprintf(path, sizeof(path), "/photos/%s", g_memories[memoryIndex].photo); Display_Image_From_SD(path); } + +void Draw_Candle_Body() { + clearScreen(); + + // Draw candle body (ivory column): x from 76 to 96 (width 21), y from 175 to 250 (height 75) + uint16_t body[21 * 75]; + for (int i = 0; i < 21 * 75; i++) body[i] = 0xF7BE; // Warm Ivory / light cream + LCD_addWindow(76, 175, 96, 249, body); + + // Draw candle wick: x = 86, y from 168 to 175 + uint16_t wick[1 * 8]; + for (int i = 0; i < 8; i++) wick[i] = 0x2104; // Dark gray + LCD_addWindow(86, 168, 86, 175, wick); +} + +void Draw_Candle_Flicker() { + static uint32_t lastFlickerTime = 0; + static uint8_t flickerState = 0; + uint32_t now = millis(); + if (now - lastFlickerTime < 180) return; // flicker every 180ms + lastFlickerTime = now; + flickerState = (flickerState + 1) % 4; + + // Bounding box: width 31, height 43 + static uint16_t canvas[31 * 43]; + memset(canvas, 0, sizeof(canvas)); // fill black + + int hOffset = (flickerState % 2 == 0) ? 0 : ((flickerState == 1) ? 1 : -1); + int vHeight = 22 + (flickerState % 3); // flame height varies + + for (int y = 0; y < 43; y++) { + for (int x = 0; x < 31; x++) { + int dx = abs(x - 15 - hOffset); + int dy = 35 - y; + + if (dy >= 0 && dy < vHeight) { + int maxWidth = (dy < 8) ? (dy * 1.2) : (vHeight - dy) * 0.4; + if (maxWidth < 1) maxWidth = 1; + if (dx <= maxWidth) { + if (dx <= maxWidth / 2 && dy < vHeight - 4) { + canvas[y * 31 + x] = 0xFFE0; // Yellow + } else { + canvas[y * 31 + x] = 0xFD20; // Orange + } + } + } + } + } + + LCD_addWindow(71, 125, 101, 167, canvas); +} diff --git a/sound_picture_app/Display_PNG.h b/sound_picture_app/Display_PNG.h index d73034b..6b83bd5 100644 --- a/sound_picture_app/Display_PNG.h +++ b/sound_picture_app/Display_PNG.h @@ -8,5 +8,8 @@ void Display_Image_From_SD(const char* fullPath); -// Convenience: display the photo for a given memory index void Display_Memory_Photo(int memoryIndex); + +// Standby Remembrance Candle Flicker animation +void Draw_Candle_Body(); +void Draw_Candle_Flicker(); diff --git a/sound_picture_app/SD_Card.cpp b/sound_picture_app/SD_Card.cpp index f8e3db7..67a8e71 100644 --- a/sound_picture_app/SD_Card.cpp +++ b/sound_picture_app/SD_Card.cpp @@ -14,6 +14,7 @@ int g_tributeAlarmHour = 19; int g_tributeAlarmMinute = 0; int g_timezoneOffset = 0; bool g_timeSynchronized = false; +int g_favoriteMemoryId = -1; // ── SD Init ─────────────────────────────────────────────────────────────────── void SD_Init() { @@ -87,6 +88,10 @@ bool Config_Load() { g_currentIndex = atoi(line + 6); continue; } + if (strncmp(line, "FAVORITE|", 9) == 0) { + g_favoriteMemoryId = atoi(line + 9); + continue; + } if (strncmp(line, "ALARM|", 6) == 0) { // Parse: ALARM|enabled|hour|minute|timezone_offset char* tok = strtok(line + 6, "|"); @@ -139,6 +144,7 @@ bool Config_Save() { } f.printf("VOLUME|%d\n", g_volume); f.printf("INDEX|%d\n", g_currentIndex); + f.printf("FAVORITE|%d\n", g_favoriteMemoryId); f.printf("ALARM|%d|%d|%d|%d\n", g_tributeAlarmEnabled ? 1 : 0, g_tributeAlarmHour, g_tributeAlarmMinute, g_timezoneOffset); for (int i = 0; i < g_memoryCount; i++) { Memory& m = g_memories[i]; diff --git a/sound_picture_app/SD_Card.h b/sound_picture_app/SD_Card.h index 9619f19..eb8d62a 100644 --- a/sound_picture_app/SD_Card.h +++ b/sound_picture_app/SD_Card.h @@ -35,6 +35,7 @@ extern int g_tributeAlarmHour; extern int g_tributeAlarmMinute; extern int g_timezoneOffset; extern bool g_timeSynchronized; +extern int g_favoriteMemoryId; // SD init void SD_Init(); diff --git a/sound_picture_app/Web_Server.cpp b/sound_picture_app/Web_Server.cpp index c729c2e..4bf63fd 100644 --- a/sound_picture_app/Web_Server.cpp +++ b/sound_picture_app/Web_Server.cpp @@ -11,6 +11,7 @@ bool uploadSuccess = false; String uploadedFilename = ""; volatile int g_webDisplayRequest = -1; volatile bool g_webDisplayDirty = false; + static String buildPage(const String& status, const String& file); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -150,6 +151,31 @@ h1 { align-items: center; justify-content: center; line-height: 1; + z-index: 10; +} +.mem-fav { + position: absolute; + top: 8px; + left: 8px; + background: rgba(0,0,0,.6); + border: 1px solid rgba(255,179,0,0.3); + color: var(--accent); + border-radius: 50%; + width: 28px; + height: 28px; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + z-index: 10; + transition: .2s; +} +.mem-fav.active { + background: rgba(255, 179, 0, 0.25); + border-color: var(--accent); + color: #fff; } .play-btn { width: 100%; @@ -199,7 +225,7 @@ h1 { .upload-box { border: 2px dashed rgba(255, 179, 0, 0.3); border-radius: var(--radius); - padding: 32px; + padding: 24px 16px; text-align: center; cursor: pointer; margin-bottom: 16px; @@ -295,13 +321,22 @@ input[type=range] { width: 100%; }
-
Step 2 of 3 — Choose a Soundbite (Optional)
+
Step 2 of 3 — Choose or Record a Soundbite (Optional)
+ + +
+
🎙️
+

Tap to **Record Voice Note** directly from mic

+
+ +
-
🎙️
-

Tap to attach a voice note or song
.mp3 or .wav file

+
📁
+

Tap to upload an existing sound clip
.mp3 or .wav file

+
Uploading audio...
@@ -368,7 +403,7 @@ input[type=range] { width: 100%; } Add @@ -379,6 +414,16 @@ var memories = []; var uploadedPhoto = ''; var uploadedAudio = ''; var currentStep = 1; +var favoriteId = -1; + +// Audio Recorder variables +var mediaStream = null; +var audioCtx = null; +var scriptProcessor = null; +var inputNode = null; +var audioBuffers = []; +var isRecording = false; +var recordedBlob = null; // ── Navigation ────────────────────────────────────────────────────────────── function goPage(name) { @@ -402,9 +447,13 @@ function openAdd() { // ── Gallery ───────────────────────────────────────────────────────────────── function loadGallery() { - fetch('/api/memories').then(function(r){return r.json()}).then(function(data){ - memories = data; - renderGallery(); + // Fetch settings to know the current favoriteId first, then load memories + fetch('/api/settings').then(function(r){return r.json()}).then(function(sData){ + favoriteId = sData.favoriteId !== undefined ? sData.favoriteId : -1; + fetch('/api/memories').then(function(r){return r.json()}).then(function(data){ + memories = data; + renderGallery(); + }).catch(function(){}); }).catch(function(){}); } @@ -423,8 +472,15 @@ function renderGallery() { var imgHtml = imgSrc ? '' : '
🌻
'; + + // Check if favorite + var isFav = (m.id === favoriteId); + var favClass = isFav ? 'mem-fav active' : 'mem-fav'; + var favStar = isFav ? '★' : '☆'; + var html = '
' + imgHtml + + '' + '' + '
' + '
'+escHtml(m.name)+'
' @@ -434,6 +490,24 @@ function renderGallery() { }); } +function toggleFavorite(id, e) { + e.stopPropagation(); + var nextFav = (id === favoriteId) ? -1 : id; + fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ favoriteId: nextFav }) + }) + .then(function(r){return r.json()}) + .then(function(d){ + if(d.ok) { + favoriteId = nextFav; + loadGallery(); + showToastGlobal(nextFav === -1 ? 'Favorite removed.' : 'Favorite keepsake pinned! ⭐', true); + } + }).catch(function(){}); +} + function playMemory(id) { fetch('/api/display?id='+id, {method:'POST'}) .then(function(r){return r.json()}) @@ -450,7 +524,7 @@ function deleteMemory(id, e) { // ── Wizard ─────────────────────────────────────────────────────────────────── function resetWizard() { - uploadedPhoto=''; uploadedAudio=''; + uploadedPhoto=''; uploadedAudio=''; recordedBlob=null; document.getElementById('photoFile').value=''; document.getElementById('audioFile').value=''; document.getElementById('previewImg').style.display='none'; @@ -459,6 +533,12 @@ function resetWizard() { document.getElementById('toast').style.display='none'; document.getElementById('nextStep1').disabled=true; document.getElementById('nextStep2').disabled=true; + + // Reset Record UI + isRecording = false; + document.getElementById('recordIcon').textContent = '🎙️'; + document.getElementById('recordTxt').innerHTML = 'Tap to **Record Voice Note** directly from mic'; + goStep(1); } @@ -479,11 +559,123 @@ function onPhotoSelected(input) { function onAudioSelected(input) { if(!input.files.length) return; + recordedBlob = null; // Clear mic recording if file is chosen var f = input.files[0]; var url = URL.createObjectURL(f); var aud = document.getElementById('previewAudio'); aud.src = url; aud.style.display='block'; document.getElementById('nextStep2').disabled=false; + document.getElementById('nextStep2').textContent = 'Upload Soundbite ➔'; +} + +// ── Audio Recording Wizardry (Record mono WAV on the fly) ─────────────────── +function toggleRecord() { + if (!isRecording) { + startRecording(); + } else { + stopRecording(); + } +} + +function startRecording() { + navigator.mediaDevices.getUserMedia({ audio: true }).then(function(s) { + mediaStream = s; + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + inputNode = audioCtx.createMediaStreamSource(mediaStream); + scriptProcessor = audioCtx.createScriptProcessor(4096, 1, 1); + audioBuffers = []; + + scriptProcessor.onaudioprocess = function(e) { + if (!isRecording) return; + var chunk = new Float32Array(e.inputBuffer.getChannelData(0)); + audioBuffers.push(chunk); + }; + + inputNode.connect(scriptProcessor); + scriptProcessor.connect(audioCtx.destination); + isRecording = true; + recordedBlob = null; + + // Update UI + document.getElementById('recordIcon').textContent = '🔴'; + document.getElementById('recordTxt').innerHTML = 'Recording...
Tap box again to STOP recording'; + }).catch(function(err) { + showToast('Microphone access denied or unsupported.', false); + }); +} + +function stopRecording() { + if (!isRecording) return; + isRecording = false; + + // Stop media tracks + if (mediaStream) { + mediaStream.getTracks().forEach(function(t){ t.stop(); }); + } + + // Disconnect web audio nodes + if (scriptProcessor) scriptProcessor.disconnect(); + if (inputNode) inputNode.disconnect(); + var sampleRate = audioCtx ? audioCtx.sampleRate : 44100; + if (audioCtx) audioCtx.close(); + + // Flatten audio buffers + var totalLength = audioBuffers.reduce(function(acc, b){ return acc + b.length; }, 0); + var result = new Float32Array(totalLength); + var offset = 0; + for (var i = 0; i < audioBuffers.length; i++) { + result.set(audioBuffers[i], offset); + offset += audioBuffers[i].length; + } + + // Encode to standard 16-bit PCM WAV + recordedBlob = encodeWAV(result, sampleRate); + + // Preview + var url = URL.createObjectURL(recordedBlob); + var aud = document.getElementById('previewAudio'); + aud.src = url; aud.style.display = 'block'; + + // Update UI + document.getElementById('recordIcon').textContent = '✅'; + document.getElementById('recordTxt').innerHTML = 'Voice Note recorded! (' + Math.round(totalLength/sampleRate) + ' sec)
Tap to re-record'; + + document.getElementById('nextStep2').disabled = false; + document.getElementById('nextStep2').textContent = 'Upload Recorded Voice Note ➔'; +} + +function encodeWAV(samples, sampleRate) { + var buffer = new ArrayBuffer(44 + samples.length * 2); + var view = new DataView(buffer); + + var writeString = function(view, offset, string) { + for (var i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + samples.length * 2, true); + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, 1, true); // Mono + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); // Byte rate + view.setUint16(32, 2, true); // Block align + view.setUint16(34, 16, true); // 16-bit + writeString(view, 36, 'data'); + view.setUint32(40, samples.length * 2, true); + + // Convert floats to 16-bit PCM signed integers + var offset = 44; + for (var i = 0; i < samples.length; i++, offset += 2) { + var s = Math.max(-1, Math.min(1, samples[i])); + view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); + } + + return new Blob([view], { type: 'audio/wav' }); } function uploadFile(fileInput, endpoint, progressWrap, fill, txt, onDone) { @@ -612,19 +804,62 @@ function uploadPhoto() { } function uploadAudio() { - document.getElementById('nextStep2').disabled=true; - uploadFile( - document.getElementById('audioFile'), - '/api/upload/audio', - document.getElementById('progress2'), - document.getElementById('fill2'), - document.getElementById('txt2'), - function(filename){ - if(filename){ uploadedAudio=filename; goStep(3); } - else showToast('Audio upload failed. Try again.', false); - document.getElementById('nextStep2').disabled=false; - } - ); + document.getElementById('nextStep2').disabled = true; + var progressWrap = document.getElementById('progress2'); + var fill = document.getElementById('fill2'); + var txt = document.getElementById('txt2'); + + // Check if we have a recorded blob + if (recordedBlob) { + var fd = new FormData(); + // Unique timestamped voice note name + var vnName = 'voice_note_' + Math.floor(Date.now() / 1000) + '.wav'; + fd.append('file', recordedBlob, vnName); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/upload/audio'); + progressWrap.style.display = 'block'; + xhr.upload.onprogress = function(e){ + if(e.lengthComputable){ + var p = Math.round(e.loaded/e.total*100); + fill.style.width=p+'%'; + txt.textContent='Uploading Voice note... '+p+'%'; + } + }; + xhr.onload = function(){ + progressWrap.style.display = 'none'; + try { + var d = JSON.parse(xhr.responseText); + if (d.ok && d.filename) { + uploadedAudio = d.filename; + goStep(3); + } else { + showToast('Voice note upload failed.', false); + } + } catch(e) { showToast('Voice note upload failed.', false); } + document.getElementById('nextStep2').disabled = false; + }; + xhr.onerror = function(){ + progressWrap.style.display = 'none'; + showToast('Voice note upload failed.', false); + document.getElementById('nextStep2').disabled = false; + }; + xhr.send(fd); + } else { + // Standard file upload + uploadFile( + document.getElementById('audioFile'), + '/api/upload/audio', + progressWrap, + fill, + txt, + function(filename){ + if(filename){ uploadedAudio=filename; goStep(3); } + else showToast('Audio upload failed. Try again.', false); + document.getElementById('nextStep2').disabled=false; + } + ); + } } function skipAudio() { @@ -795,6 +1030,7 @@ void initWebServer() { j += ",\"alarmHour\":"; j += g_tributeAlarmHour; j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute; j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false"; + j += ",\"favoriteId\":"; j += g_favoriteMemoryId; j += "}"; server.send(200, "application/json", j); }); @@ -829,6 +1065,12 @@ void initWebServer() { g_tributeAlarmMinute = body.substring(am + 14).toInt(); } + // Favorite Memory ID + int fav = body.indexOf("\"favoriteId\":"); + if (fav >= 0) { + g_favoriteMemoryId = body.substring(fav + 13).toInt(); + } + Config_Save(); server.send(200, "application/json", "{\"ok\":true}"); }); diff --git a/sound_picture_app/sound_picture_app.ino b/sound_picture_app/sound_picture_app.ino index 396cfc4..2ea97b1 100644 --- a/sound_picture_app/sound_picture_app.ino +++ b/sound_picture_app/sound_picture_app.ino @@ -28,6 +28,11 @@ static bool g_tributeActive = false; static int g_tributeNextIndex = 0; static uint32_t lastTributeCheck = 0; +// Standby Remembrance Candle State +static bool g_standbyActive = false; +static uint32_t lastInteractionTime = 0; +static const uint32_t STANDBY_TIMEOUT = 120000; // 2 minutes standby timeout + // ── Core function: show a memory ────────────────────────────────────────────── void showMemory(int index) { if (g_memoryCount == 0) return; @@ -36,6 +41,9 @@ void showMemory(int index) { printf("Showing memory %d: '%s'\r\n", index, g_memories[index].name); + g_standbyActive = false; + lastInteractionTime = millis(); + stopAudio(); Display_Memory_Photo(index); playMemoryAudio(index); @@ -110,12 +118,26 @@ void setup() { initAudioPlayer(); setPlayerVolume(g_volume); - // Show first memory (or error screen) + // Show favorite memory or first memory on startup if (g_memoryCount == 0) { printf("No memories found. Add some via the web app.\r\n"); - // Screen stays dark — user will add via web + g_standbyActive = true; + Draw_Candle_Body(); } else { - showMemory(g_currentIndex); + int favoriteIndex = -1; + if (g_favoriteMemoryId >= 0) { + for (int i = 0; i < g_memoryCount; i++) { + if (g_memories[i].id == g_favoriteMemoryId) { + favoriteIndex = i; + break; + } + } + } + if (favoriteIndex >= 0) { + showMemory(favoriteIndex); + } else { + showMemory(g_currentIndex); + } } // Start WiFi + web server @@ -160,6 +182,8 @@ void loop() { // ── Button edge detection ───────────────────────────────────────────────── if (btn == LOW && lastButtonState == HIGH) { // Pressed down + lastInteractionTime = now; + g_standbyActive = false; buttonPressStartTime = now; isButtonPressed = true; longPressTriggered = false; @@ -210,6 +234,24 @@ void loop() { } } + // ── Standby Candle Controller ───────────────────────────────────────────── + if (g_memoryCount == 0) { + if (!g_standbyActive) { + g_standbyActive = true; + Draw_Candle_Body(); + } + } else if (!g_standbyActive && !isAudioPlaying() && currentState == STATE_IDLE && !g_tributeActive) { + if (now - lastInteractionTime > STANDBY_TIMEOUT) { + g_standbyActive = true; + Draw_Candle_Body(); + printf("Standby: entering candle standby mode\r\n"); + } + } + + if (g_standbyActive) { + Draw_Candle_Flicker(); + } + lastButtonState = btn; // ── Web server ────────────────────────────────────────────────────────────