Implement browser-based voice notes recording, standby flicking remembrance candle, and favorite memory pinning
This commit is contained in:
@@ -71,3 +71,54 @@ void Display_Memory_Photo(int memoryIndex) {
|
|||||||
snprintf(path, sizeof(path), "/photos/%s", g_memories[memoryIndex].photo);
|
snprintf(path, sizeof(path), "/photos/%s", g_memories[memoryIndex].photo);
|
||||||
Display_Image_From_SD(path);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,5 +8,8 @@
|
|||||||
|
|
||||||
void Display_Image_From_SD(const char* fullPath);
|
void Display_Image_From_SD(const char* fullPath);
|
||||||
|
|
||||||
// Convenience: display the photo for a given memory index
|
|
||||||
void Display_Memory_Photo(int memoryIndex);
|
void Display_Memory_Photo(int memoryIndex);
|
||||||
|
|
||||||
|
// Standby Remembrance Candle Flicker animation
|
||||||
|
void Draw_Candle_Body();
|
||||||
|
void Draw_Candle_Flicker();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ int g_tributeAlarmHour = 19;
|
|||||||
int g_tributeAlarmMinute = 0;
|
int g_tributeAlarmMinute = 0;
|
||||||
int g_timezoneOffset = 0;
|
int g_timezoneOffset = 0;
|
||||||
bool g_timeSynchronized = false;
|
bool g_timeSynchronized = false;
|
||||||
|
int g_favoriteMemoryId = -1;
|
||||||
|
|
||||||
// ── SD Init ───────────────────────────────────────────────────────────────────
|
// ── SD Init ───────────────────────────────────────────────────────────────────
|
||||||
void SD_Init() {
|
void SD_Init() {
|
||||||
@@ -87,6 +88,10 @@ bool Config_Load() {
|
|||||||
g_currentIndex = atoi(line + 6);
|
g_currentIndex = atoi(line + 6);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (strncmp(line, "FAVORITE|", 9) == 0) {
|
||||||
|
g_favoriteMemoryId = atoi(line + 9);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (strncmp(line, "ALARM|", 6) == 0) {
|
if (strncmp(line, "ALARM|", 6) == 0) {
|
||||||
// Parse: ALARM|enabled|hour|minute|timezone_offset
|
// Parse: ALARM|enabled|hour|minute|timezone_offset
|
||||||
char* tok = strtok(line + 6, "|");
|
char* tok = strtok(line + 6, "|");
|
||||||
@@ -139,6 +144,7 @@ bool Config_Save() {
|
|||||||
}
|
}
|
||||||
f.printf("VOLUME|%d\n", g_volume);
|
f.printf("VOLUME|%d\n", g_volume);
|
||||||
f.printf("INDEX|%d\n", g_currentIndex);
|
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);
|
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++) {
|
for (int i = 0; i < g_memoryCount; i++) {
|
||||||
Memory& m = g_memories[i];
|
Memory& m = g_memories[i];
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ extern int g_tributeAlarmHour;
|
|||||||
extern int g_tributeAlarmMinute;
|
extern int g_tributeAlarmMinute;
|
||||||
extern int g_timezoneOffset;
|
extern int g_timezoneOffset;
|
||||||
extern bool g_timeSynchronized;
|
extern bool g_timeSynchronized;
|
||||||
|
extern int g_favoriteMemoryId;
|
||||||
|
|
||||||
// SD init
|
// SD init
|
||||||
void SD_Init();
|
void SD_Init();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ bool uploadSuccess = false;
|
|||||||
String uploadedFilename = "";
|
String uploadedFilename = "";
|
||||||
volatile int g_webDisplayRequest = -1;
|
volatile int g_webDisplayRequest = -1;
|
||||||
volatile bool g_webDisplayDirty = false;
|
volatile bool g_webDisplayDirty = false;
|
||||||
|
|
||||||
static String buildPage(const String& status, const String& file);
|
static String buildPage(const String& status, const String& file);
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -150,6 +151,31 @@ h1 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 1;
|
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 {
|
.play-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -199,7 +225,7 @@ h1 {
|
|||||||
.upload-box {
|
.upload-box {
|
||||||
border: 2px dashed rgba(255, 179, 0, 0.3);
|
border: 2px dashed rgba(255, 179, 0, 0.3);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 32px;
|
padding: 24px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -295,13 +321,22 @@ input[type=range] { width: 100%; }
|
|||||||
|
|
||||||
<!-- Step 2: Audio (Optional Choice) -->
|
<!-- Step 2: Audio (Optional Choice) -->
|
||||||
<div id="step2" class="wizard-step">
|
<div id="step2" class="wizard-step">
|
||||||
<div class="step-label">Step 2 of 3 — Choose a Soundbite (Optional)</div>
|
<div class="step-label">Step 2 of 3 — Choose or Record a Soundbite (Optional)</div>
|
||||||
|
|
||||||
|
<!-- Record Mic Box -->
|
||||||
|
<div class="upload-box" id="recordBox" onclick="toggleRecord()" style="border-color:var(--accent-orange); background:rgba(245,124,0,0.03);">
|
||||||
|
<div class="icon" id="recordIcon">🎙️</div>
|
||||||
|
<p id="recordTxt">Tap to **Record Voice Note** directly from mic</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Select Box -->
|
||||||
<div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()">
|
<div class="upload-box" id="audioBox" onclick="document.getElementById('audioFile').click()">
|
||||||
<div class="icon">🎙️</div>
|
<div class="icon">📁</div>
|
||||||
<p>Tap to attach a voice note or song<br><strong>.mp3 or .wav file</strong></p>
|
<p>Tap to upload an existing sound clip<br><strong>.mp3 or .wav file</strong></p>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="audioFile" accept=".mp3,.wav" onchange="onAudioSelected(this)">
|
<input type="file" id="audioFile" accept=".mp3,.wav" onchange="onAudioSelected(this)">
|
||||||
<audio id="previewAudio" class="preview-audio" controls></audio>
|
<audio id="previewAudio" class="preview-audio" controls></audio>
|
||||||
|
|
||||||
<div id="progress2" class="progress-wrap">
|
<div id="progress2" class="progress-wrap">
|
||||||
<div class="progress-bar"><div class="progress-fill" id="fill2"></div></div>
|
<div class="progress-bar"><div class="progress-fill" id="fill2"></div></div>
|
||||||
<div class="progress-txt" id="txt2">Uploading audio...</div>
|
<div class="progress-txt" id="txt2">Uploading audio...</div>
|
||||||
@@ -368,7 +403,7 @@ input[type=range] { width: 100%; }
|
|||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
<button id="nav-settings" onclick="goPage('settings')">
|
<button id="nav-settings" onclick="goPage('settings')">
|
||||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38(1.03).7-1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -379,6 +414,16 @@ var memories = [];
|
|||||||
var uploadedPhoto = '';
|
var uploadedPhoto = '';
|
||||||
var uploadedAudio = '';
|
var uploadedAudio = '';
|
||||||
var currentStep = 1;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||||
function goPage(name) {
|
function goPage(name) {
|
||||||
@@ -402,10 +447,14 @@ function openAdd() {
|
|||||||
|
|
||||||
// ── Gallery ─────────────────────────────────────────────────────────────────
|
// ── Gallery ─────────────────────────────────────────────────────────────────
|
||||||
function loadGallery() {
|
function loadGallery() {
|
||||||
|
// 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){
|
fetch('/api/memories').then(function(r){return r.json()}).then(function(data){
|
||||||
memories = data;
|
memories = data;
|
||||||
renderGallery();
|
renderGallery();
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
|
}).catch(function(){});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGallery() {
|
function renderGallery() {
|
||||||
@@ -423,8 +472,15 @@ function renderGallery() {
|
|||||||
var imgHtml = imgSrc
|
var imgHtml = imgSrc
|
||||||
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
|
? '<img class="mem-thumb" src="'+imgSrc+'" loading="lazy" onerror="this.style.display=\'none\'">'
|
||||||
: '<div class="mem-thumb no-img">🌻</div>';
|
: '<div class="mem-thumb no-img">🌻</div>';
|
||||||
|
|
||||||
|
// Check if favorite
|
||||||
|
var isFav = (m.id === favoriteId);
|
||||||
|
var favClass = isFav ? 'mem-fav active' : 'mem-fav';
|
||||||
|
var favStar = isFav ? '★' : '☆';
|
||||||
|
|
||||||
var html = '<div class="mem-card">'
|
var html = '<div class="mem-card">'
|
||||||
+ imgHtml
|
+ imgHtml
|
||||||
|
+ '<button class="'+favClass+'" onclick="toggleFavorite('+m.id+',event)" title="Favorite">'+favStar+'</button>'
|
||||||
+ '<button class="mem-del" onclick="deleteMemory('+m.id+',event)" title="Delete">✕</button>'
|
+ '<button class="mem-del" onclick="deleteMemory('+m.id+',event)" title="Delete">✕</button>'
|
||||||
+ '<div class="mem-info">'
|
+ '<div class="mem-info">'
|
||||||
+ '<div class="mem-name">'+escHtml(m.name)+'</div>'
|
+ '<div class="mem-name">'+escHtml(m.name)+'</div>'
|
||||||
@@ -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) {
|
function playMemory(id) {
|
||||||
fetch('/api/display?id='+id, {method:'POST'})
|
fetch('/api/display?id='+id, {method:'POST'})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
@@ -450,7 +524,7 @@ function deleteMemory(id, e) {
|
|||||||
|
|
||||||
// ── Wizard ───────────────────────────────────────────────────────────────────
|
// ── Wizard ───────────────────────────────────────────────────────────────────
|
||||||
function resetWizard() {
|
function resetWizard() {
|
||||||
uploadedPhoto=''; uploadedAudio='';
|
uploadedPhoto=''; uploadedAudio=''; recordedBlob=null;
|
||||||
document.getElementById('photoFile').value='';
|
document.getElementById('photoFile').value='';
|
||||||
document.getElementById('audioFile').value='';
|
document.getElementById('audioFile').value='';
|
||||||
document.getElementById('previewImg').style.display='none';
|
document.getElementById('previewImg').style.display='none';
|
||||||
@@ -459,6 +533,12 @@ function resetWizard() {
|
|||||||
document.getElementById('toast').style.display='none';
|
document.getElementById('toast').style.display='none';
|
||||||
document.getElementById('nextStep1').disabled=true;
|
document.getElementById('nextStep1').disabled=true;
|
||||||
document.getElementById('nextStep2').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);
|
goStep(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,11 +559,123 @@ function onPhotoSelected(input) {
|
|||||||
|
|
||||||
function onAudioSelected(input) {
|
function onAudioSelected(input) {
|
||||||
if(!input.files.length) return;
|
if(!input.files.length) return;
|
||||||
|
recordedBlob = null; // Clear mic recording if file is chosen
|
||||||
var f = input.files[0];
|
var f = input.files[0];
|
||||||
var url = URL.createObjectURL(f);
|
var url = URL.createObjectURL(f);
|
||||||
var aud = document.getElementById('previewAudio');
|
var aud = document.getElementById('previewAudio');
|
||||||
aud.src = url; aud.style.display='block';
|
aud.src = url; aud.style.display='block';
|
||||||
document.getElementById('nextStep2').disabled=false;
|
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 = '<span style="color:#ef4444; font-weight:bold; animation:pulse 1.5s infinite">Recording...</span><br>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)<br><strong>Tap to re-record</strong>';
|
||||||
|
|
||||||
|
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) {
|
function uploadFile(fileInput, endpoint, progressWrap, fill, txt, onDone) {
|
||||||
@@ -612,19 +804,62 @@ function uploadPhoto() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uploadAudio() {
|
function uploadAudio() {
|
||||||
document.getElementById('nextStep2').disabled=true;
|
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(
|
uploadFile(
|
||||||
document.getElementById('audioFile'),
|
document.getElementById('audioFile'),
|
||||||
'/api/upload/audio',
|
'/api/upload/audio',
|
||||||
document.getElementById('progress2'),
|
progressWrap,
|
||||||
document.getElementById('fill2'),
|
fill,
|
||||||
document.getElementById('txt2'),
|
txt,
|
||||||
function(filename){
|
function(filename){
|
||||||
if(filename){ uploadedAudio=filename; goStep(3); }
|
if(filename){ uploadedAudio=filename; goStep(3); }
|
||||||
else showToast('Audio upload failed. Try again.', false);
|
else showToast('Audio upload failed. Try again.', false);
|
||||||
document.getElementById('nextStep2').disabled=false;
|
document.getElementById('nextStep2').disabled=false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipAudio() {
|
function skipAudio() {
|
||||||
@@ -795,6 +1030,7 @@ void initWebServer() {
|
|||||||
j += ",\"alarmHour\":"; j += g_tributeAlarmHour;
|
j += ",\"alarmHour\":"; j += g_tributeAlarmHour;
|
||||||
j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute;
|
j += ",\"alarmMinute\":"; j += g_tributeAlarmMinute;
|
||||||
j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false";
|
j += ",\"timeSynced\":"; j += g_timeSynchronized ? "true" : "false";
|
||||||
|
j += ",\"favoriteId\":"; j += g_favoriteMemoryId;
|
||||||
j += "}";
|
j += "}";
|
||||||
server.send(200, "application/json", j);
|
server.send(200, "application/json", j);
|
||||||
});
|
});
|
||||||
@@ -829,6 +1065,12 @@ void initWebServer() {
|
|||||||
g_tributeAlarmMinute = body.substring(am + 14).toInt();
|
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();
|
Config_Save();
|
||||||
server.send(200, "application/json", "{\"ok\":true}");
|
server.send(200, "application/json", "{\"ok\":true}");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ static bool g_tributeActive = false;
|
|||||||
static int g_tributeNextIndex = 0;
|
static int g_tributeNextIndex = 0;
|
||||||
static uint32_t lastTributeCheck = 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 ──────────────────────────────────────────────
|
// ── Core function: show a memory ──────────────────────────────────────────────
|
||||||
void showMemory(int index) {
|
void showMemory(int index) {
|
||||||
if (g_memoryCount == 0) return;
|
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);
|
printf("Showing memory %d: '%s'\r\n", index, g_memories[index].name);
|
||||||
|
|
||||||
|
g_standbyActive = false;
|
||||||
|
lastInteractionTime = millis();
|
||||||
|
|
||||||
stopAudio();
|
stopAudio();
|
||||||
Display_Memory_Photo(index);
|
Display_Memory_Photo(index);
|
||||||
playMemoryAudio(index);
|
playMemoryAudio(index);
|
||||||
@@ -110,13 +118,27 @@ void setup() {
|
|||||||
initAudioPlayer();
|
initAudioPlayer();
|
||||||
setPlayerVolume(g_volume);
|
setPlayerVolume(g_volume);
|
||||||
|
|
||||||
// Show first memory (or error screen)
|
// Show favorite memory or first memory on startup
|
||||||
if (g_memoryCount == 0) {
|
if (g_memoryCount == 0) {
|
||||||
printf("No memories found. Add some via the web app.\r\n");
|
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 {
|
||||||
|
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 {
|
} else {
|
||||||
showMemory(g_currentIndex);
|
showMemory(g_currentIndex);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start WiFi + web server
|
// Start WiFi + web server
|
||||||
initWebServer();
|
initWebServer();
|
||||||
@@ -160,6 +182,8 @@ void loop() {
|
|||||||
// ── Button edge detection ─────────────────────────────────────────────────
|
// ── Button edge detection ─────────────────────────────────────────────────
|
||||||
if (btn == LOW && lastButtonState == HIGH) {
|
if (btn == LOW && lastButtonState == HIGH) {
|
||||||
// Pressed down
|
// Pressed down
|
||||||
|
lastInteractionTime = now;
|
||||||
|
g_standbyActive = false;
|
||||||
buttonPressStartTime = now;
|
buttonPressStartTime = now;
|
||||||
isButtonPressed = true;
|
isButtonPressed = true;
|
||||||
longPressTriggered = false;
|
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;
|
lastButtonState = btn;
|
||||||
|
|
||||||
// ── Web server ────────────────────────────────────────────────────────────
|
// ── Web server ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user