ADD - voice memo app

This commit is contained in:
Leon.Schmidt
2026-08-13 16:16:32 +02:00
parent 223cb4a600
commit f4ed86fd63
29 changed files with 3234 additions and 50 deletions
+225 -2
View File
@@ -1,3 +1,5 @@
const { randomUUID } = require('node:crypto')
const cors = require('cors')
const express = require('express')
@@ -5,7 +7,7 @@ const app = express()
const port = Number(process.argv[2]) || 3001
app.use(cors())
app.use(express.json())
app.use(express.json({ limit: '3mb' }))
const lifecycleEndpoints = new Set([
'camera:setActive',
@@ -38,6 +40,14 @@ function unixTime(offsetSeconds = 0) {
return Math.floor(Date.now() / 1000) + offsetSeconds
}
function memoWaveform(phase = 0) {
return Array.from({ length: 48 }, (_, index) =>
Number(
(0.12 + Math.abs(Math.sin((index + phase) * 0.67)) * 0.76).toFixed(3),
),
)
}
let authenticated = true
let draft = null
const radioData = {
@@ -1956,6 +1966,53 @@ let mockNotes = [
updatedAt: Date.now() - 172_800_000,
},
]
let mockMemos = [
{
createdAt: Date.now() - 25 * 60_000,
durationMs: 42_100,
id: '4b918e0e-840e-4f35-99d7-c93b047bc3f7',
mediaId: 9101,
mimeType: 'audio/ogg',
note: 'Patrol route via Mission Row, Pillbox Hill and the Vespucci canals.',
pinned: true,
revision: 2,
sizeBytes: 132_840,
title: 'Night shift briefing',
updatedAt: Date.now() - 18 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(1),
},
{
createdAt: Date.now() - 3 * 60 * 60_000,
durationMs: 18_600,
id: '7df6287a-a3f1-49cc-a722-a950fc9a5dd2',
mediaId: 9102,
mimeType: 'audio/ogg',
note: 'Check the paint, engine sound and service history before making an offer.',
pinned: false,
revision: 1,
sizeBytes: 61_920,
title: 'Sultan RS inspection',
updatedAt: Date.now() - 3 * 60 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(5),
},
{
createdAt: Date.now() - 26 * 60 * 60_000,
durationMs: 7_400,
id: '0b2a0d0d-3eb7-4623-873e-829a96a9d525',
mediaId: 9103,
mimeType: 'audio/ogg',
note: 'Repair kit, flashlight and two bottles of water.',
pinned: false,
revision: 1,
sizeBytes: 26_480,
title: 'Supply reminder',
updatedAt: Date.now() - 26 * 60 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(9),
},
]
let calendarEvents = [
{
endsAt: calendarTime(0, 10),
@@ -3479,8 +3536,16 @@ function companyWorkContext(testScenario = '') {
}
app.post('/api/:endpoint', async (request, response, next) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
console.log(
`[NUI] ${endpoint}`,
endpoint === 'memos:devCapture'
? {
...request.body,
audioDataUrl: `<${String(request.body.audioDataUrl ?? '').length} characters>`,
}
: request.body,
)
if (endpoint === 'music:bootstrap') {
response.json({ success: true, data: musicBootstrap() })
return
@@ -7715,6 +7780,7 @@ app.post('/api/:endpoint', (request, response) => {
name: 'Personal iFruit Phone',
sim: mockSim,
},
memos: mockMemos,
notes: mockNotes,
security: mockSecurity,
token: 'development',
@@ -8170,6 +8236,7 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'device:factory-reset') {
authenticated = false
linkedAccount = null
mockMemos = []
mockNotes = []
mockMedia = []
calendarEvents = []
@@ -8941,6 +9008,162 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'memos:list') {
response.json({ success: true, data: mockMemos })
return
}
if (endpoint === 'memos:devCapture') {
const correlationId =
typeof request.body.correlationId === 'string'
? request.body.correlationId
: ''
const title =
typeof request.body.title === 'string' ? request.body.title.trim() : ''
const note =
request.body.note === undefined
? ''
: typeof request.body.note === 'string'
? request.body.note.trim()
: null
const durationMs = Math.floor(Number(request.body.durationMs))
const mimeType =
request.body.mimeType === 'audio/ogg'
? 'audio/ogg'
: ['audio/webm', 'audio/webm;codecs=opus'].includes(
request.body.mimeType,
)
? 'audio/webm'
: null
const waveform = Array.isArray(request.body.waveform)
? request.body.waveform.map(Number)
: []
const audioDataUrl =
typeof request.body.audioDataUrl === 'string'
? request.body.audioDataUrl
: ''
const separator = audioDataUrl.indexOf(';base64,')
const audioMime =
separator > 5
? audioDataUrl.slice(5, separator).split(';', 1)[0].toLowerCase()
: ''
const encodedAudio = separator > 0 ? audioDataUrl.slice(separator + 8) : ''
const validAudio =
Boolean(mimeType) &&
audioMime === mimeType &&
encodedAudio.length > 0 &&
encodedAudio.length % 4 === 0 &&
/^[A-Za-z0-9+/]+={0,2}$/.test(encodedAudio)
const sizeBytes = validAudio
? Buffer.from(encodedAudio, 'base64').byteLength
: 0
if (
!correlationId ||
correlationId.length > 80 ||
!title ||
title.length > 120 ||
note === null ||
note.length > 2000 ||
!Number.isFinite(durationMs) ||
durationMs < 300 ||
durationMs > 300_000 ||
typeof request.body.pinned !== 'boolean' ||
waveform.length < 8 ||
waveform.length > 96 ||
waveform.some(
(sample) => !Number.isFinite(sample) || sample < 0 || sample > 1,
) ||
!validAudio ||
sizeBytes < 1 ||
sizeBytes > 2 * 1024 * 1024 ||
mockMemos.length >= 100
) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const now = Date.now()
const memo = {
createdAt: now,
durationMs,
id: randomUUID(),
mediaId:
Math.max(0, ...mockMemos.map((item) => Number(item.mediaId) || 0)) + 1,
mimeType,
note,
pinned: request.body.pinned,
revision: 1,
sizeBytes,
title,
updatedAt: now,
url: audioDataUrl,
waveform,
}
mockMemos.unshift(memo)
response.json({ success: true, data: memo })
return
}
if (endpoint === 'memos:update') {
const id = typeof request.body.id === 'string' ? request.body.id : ''
const title =
typeof request.body.title === 'string' ? request.body.title.trim() : ''
const note =
request.body.note === undefined
? ''
: typeof request.body.note === 'string'
? request.body.note.trim()
: null
const revision = Math.floor(Number(request.body.revision) || 0)
if (
id.length !== 36 ||
!title ||
title.length > 120 ||
note === null ||
note.length > 2000 ||
typeof request.body.pinned !== 'boolean' ||
revision < 1
) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const index = mockMemos.findIndex(
(memo) => memo.id === id && memo.revision === revision,
)
if (index < 0) {
response.json({ success: false, error: 'conflict', data: mockMemos })
return
}
const memo = {
...mockMemos[index],
note,
pinned: request.body.pinned,
revision: revision + 1,
title,
updatedAt: Date.now(),
}
mockMemos[index] = memo
mockMemos.sort(
(left, right) =>
Number(right.pinned) - Number(left.pinned) ||
right.updatedAt - left.updatedAt ||
right.id.localeCompare(left.id),
)
response.json({ success: true, data: memo })
return
}
if (endpoint === 'memos:delete') {
const id = typeof request.body.id === 'string' ? request.body.id : ''
if (id.length !== 36) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const index = mockMemos.findIndex((memo) => memo.id === id)
if (index < 0) {
response.json({ success: false, error: 'memo_not_found' })
return
}
mockMemos.splice(index, 1)
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'notes:list') {
response.json({ success: true, data: mockNotes })
return
+84
View File
@@ -46,6 +46,7 @@ const browserDataRequests = [
['marketplace:profile', {}],
['messages:conversations', {}],
['messages:gifs', { query: 'party' }],
['memos:list', {}],
['music:bootstrap', {}],
['notes:list', {}],
['pages:list', {}],
@@ -82,6 +83,89 @@ async function expectSuccess(baseUrl, endpoint, body = {}, data = false) {
}
async function verifyStatefulActions(baseUrl) {
const memoBootstrap = await expectSuccess(
baseUrl,
'development:bootstrap',
{},
true,
)
assert(
Array.isArray(memoBootstrap.memos) && memoBootstrap.memos.length >= 3,
'development:bootstrap did not include realistic memo data',
)
assert(
memoBootstrap.memos.every(
(memo) =>
typeof memo.url === 'string' &&
memo.url.length > 0 &&
Array.isArray(memo.waveform) &&
memo.waveform.length >= 8,
),
'development:bootstrap returned an invalid memo response shape',
)
let memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
const capturedMemo = await expectSuccess(
baseUrl,
'memos:devCapture',
{
audioDataUrl: 'data:audio/webm;base64,T2dnUw==',
correlationId: 'browser-smoke-memo',
durationMs: 1_250,
mimeType: 'audio/webm;codecs=opus',
note: 'Recorded in the browser preview.',
pinned: false,
title: 'Browser recording',
waveform: Array(16).fill(0.35),
},
true,
)
assert.equal(capturedMemo.title, 'Browser recording')
assert.equal(capturedMemo.mimeType, 'audio/webm')
assert.equal(capturedMemo.sizeBytes, 4)
assert.match(capturedMemo.id, /^[0-9a-f-]{36}$/)
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
assert(
memos.some((item) => item.id === capturedMemo.id),
'memos:devCapture did not persist',
)
await expectSuccess(baseUrl, 'memos:delete', { id: capturedMemo.id }, true)
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
const memo = memos.find((item) => !item.pinned)
assert(memo, 'memos:list did not return an editable memo')
const updatedMemo = await expectSuccess(
baseUrl,
'memos:update',
{
id: memo.id,
note: 'Updated by the browser mock smoke test.',
pinned: true,
revision: memo.revision,
title: 'Updated browser memo',
},
true,
)
assert.equal(updatedMemo.id, memo.id)
assert.equal(updatedMemo.title, 'Updated browser memo')
assert.equal(updatedMemo.note, 'Updated by the browser mock smoke test.')
assert.equal(updatedMemo.pinned, true)
assert.equal(updatedMemo.revision, memo.revision + 1)
assert(Array.isArray(updatedMemo.waveform) && updatedMemo.waveform.length > 0)
const deletedMemo = await expectSuccess(
baseUrl,
'memos:delete',
{ id: updatedMemo.id },
true,
)
assert.deepEqual(deletedMemo, { id: updatedMemo.id })
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
assert(
!memos.some((item) => item.id === updatedMemo.id),
'memos:delete did not persist',
)
const noteId = `browser-note-${Date.now()}`
let notes = await expectSuccess(
baseUrl,