Add voice memos to live journal and event log.

Record short E2E-encrypted audio attachments from the live log, link them to events via __live:voice markers, and play them back in the stream and chronological event table.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 14:52:12 +02:00
parent f83d67b527
commit 975c7a2e40
30 changed files with 1155 additions and 14 deletions
+39
View File
@@ -0,0 +1,39 @@
export const VOICE_MEMO_MAX_DURATION_SEC = 60
export const VOICE_MEMO_MAX_BLOB_BYTES = 800_000
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
export function pickMediaRecorderMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') return undefined
for (const mime of MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mime)) return mime
}
return undefined
}
export function blobToAudioDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('audio_read_failed'))
reader.readAsDataURL(blob)
})
}
export function formatVoiceDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
export function assertVoiceMemoBlobSize(blob: Blob): void {
if (blob.size > VOICE_MEMO_MAX_BLOB_BYTES) {
throw new Error('VOICE_MEMO_TOO_LARGE')
}
}
@@ -7,6 +7,8 @@ import {
liveSogRemark,
parseLiveCommentRemark,
livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
parseLiveSailsRemark
} from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js'
@@ -28,6 +30,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_voice_entry_plain': 'Voice memo',
'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
@@ -59,6 +62,12 @@ describe('liveEventCodes', () => {
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
})
it('parses voice remark with uuid', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
expect(parseLiveVoiceRemark(liveVoiceRemark(id))).toBe(id)
expect(parseLiveVoiceRemark('__live:voice:not-a-uuid')).toBeNull()
})
})
describe('formatEventSummary', () => {
@@ -130,4 +139,10 @@ describe('formatEventSummary', () => {
})
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
})
it('formats voice memo entry', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
const event = normalizeLogEvent({ time: '12:00', remarks: liveVoiceRemark(id) })
expect(formatEventSummary(event, t)).toBe('Voice memo')
})
})
+6
View File
@@ -5,6 +5,7 @@ import {
parseLiveCommentRemark,
parseLiveFuelRemark,
parseLivePhotoRemark,
parseLiveVoiceRemark,
parseLivePrecipRemark,
parseLiveSailsRemark,
parseLiveSogRemark,
@@ -34,6 +35,11 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
: t('logs.live_photo_entry_plain')
}
const voiceId = parseLiveVoiceRemark(code)
if (voiceId) {
return t('logs.live_voice_entry_plain')
}
const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp })
+15
View File
@@ -50,6 +50,21 @@ export function parseLivePhotoRemark(remarks: string): string | null {
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
const VOICE_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function liveVoiceRemark(audioId: string): string {
return `__live:voice:${audioId}`
}
export function parseLiveVoiceRemark(remarks: string): string | null {
const trimmed = remarks.trim()
const prefix = '__live:voice:'
if (!trimmed.startsWith(prefix)) return null
const id = trimmed.slice(prefix.length)
return VOICE_UUID_RE.test(id) ? id : null
}
export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}`
}