feat(workspace): voice notes via /audio (M3)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m10s

- /audio (only in an empty bullet) shows a recording bubble: green round
  button starts, it turns red with a live wave and timer on its left, a
  tap saves the recording as an inline audio player. An audio file can be
  uploaded instead; Esc/✕ restores the empty bullet.
- Chrome's WebM recordings get their length measured, so the player shows
  the total time and seeking works.
- With the own Whisper server "Transkribieren" turns the recording into
  the (searchable) caption.
- Loaded files keep their Blob, as the CSP rightly blocks fetch() on blob:
  URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 23:23:54 +01:00
1 parent e8fa14a4cb
commit 3f5e3b2ac1
6 files changed
+378 -9

No files matched your search

+18 -7
View File
@@ -23,11 +23,11 @@ export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''):
return `${kind === 'image' ? '!' : ''}[${safe}](media:${id})`;
}
const objectURLs = new Map<string, Promise<string>>();
// Loaded files, once per page. The Blob is kept as well: the CSP does not allow fetch() on blob: URLs.
const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>();
/** blob: URL of a file; loaded once per page. */
export function mediaObjectURL(id: string): Promise<string> {
let p = objectURLs.get(id);
function loadMedia(id: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(id);
if (!p) {
p = (async () => {
const token = getToken();
@@ -35,14 +35,25 @@ export function mediaObjectURL(id: string): Promise<string> {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
return URL.createObjectURL(await res.blob());
const blob = await res.blob();
return { blob, url: URL.createObjectURL(blob) };
})();
p.catch(() => objectURLs.delete(id)); // try again next time (e.g. was offline)
objectURLs.set(id, p);
p.catch(() => loaded.delete(id)); // try again next time (e.g. was offline)
loaded.set(id, p);
}
return p;
}
/** blob: URL of a file for <img>/<audio>. */
export function mediaObjectURL(id: string): Promise<string> {
return loadMedia(id).then((m) => m.url);
}
/** The file itself, e.g. to send a recording to the speech recognition. */
export function mediaBlob(id: string): Promise<Blob> {
return loadMedia(id).then((m) => m.blob);
}
export interface UploadedMedia {
id: string;
kind: 'image' | 'audio';