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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user