Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/hooks/useWakeLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useRef } from 'react';

// Minimal typing so we don't depend on the DOM lib shipping WakeLock types.
interface WakeLockSentinelLike { release(): Promise<void> }
interface WakeLockLike { request(type: 'screen'): Promise<WakeLockSentinelLike> }
function getWakeLock(): WakeLockLike | undefined {
return (navigator as unknown as { wakeLock?: WakeLockLike }).wakeLock;
}

/**
* Keep the screen awake during a long operation (e.g. model download / inference)
* so the phone doesn't auto-lock — which on mobile can discard the tab and wipe
* in-memory state. The lock is auto-released by the browser when the page is
* hidden, so we re-acquire it on return to the foreground.
*/
export function useWakeLock() {
const sentinelRef = useRef<WakeLockSentinelLike | null>(null);
const wantedRef = useRef(false);

const acquire = useCallback(async () => {
const wl = getWakeLock();
if (!wl || sentinelRef.current) return;
try { sentinelRef.current = await wl.request('screen'); } catch { /* denied/unsupported */ }
}, []);

const request = useCallback(async () => { wantedRef.current = true; await acquire(); }, [acquire]);

const release = useCallback(() => {
wantedRef.current = false;
sentinelRef.current?.release().catch(() => {});
sentinelRef.current = null;
}, []);

useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible' && wantedRef.current && !sentinelRef.current) void acquire();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
document.removeEventListener('visibilitychange', onVisible);
sentinelRef.current?.release().catch(() => {});
sentinelRef.current = null;
};
}, [acquire]);

return { request, release };
}
23 changes: 21 additions & 2 deletions src/islands/media/VoiceToText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { ProgressBar } from '@/components/ui/ProgressBar';
import { CopyButton } from '@/components/ui/CopyButton';
import { downloadService } from '@/services/download';
import { useAudioRecorder } from '@/hooks/useAudioRecorder';
import { useWakeLock } from '@/hooks/useWakeLock';
import { decodeToMono16k } from '@/tools/media/stt-audio.lib';
import { transcribeInWorker } from '@/tools/media/stt.client';
import { saveRecording, loadRecording } from '@/tools/media/recording-store';
import { type SttModelId } from '@/tools/media/stt.engine';
import {
segmentsToText,
Expand Down Expand Up @@ -56,6 +58,8 @@ type Tab = 'text' | 'timestamped' | 'subtitles';

export default function VoiceToText() {
const recorder = useAudioRecorder();
const wakeLock = useWakeLock();
const [restored, setRestored] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string>('');
const [model, setModel] = useState<SttModelId>('onnx-community/whisper-tiny.en');
Expand Down Expand Up @@ -94,16 +98,26 @@ export default function VoiceToText() {
return () => clearInterval(id);
}, [transcribing]);

const setAudio = (blob: Blob) => {
const setAudio = (blob: Blob, persist = true) => {
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
const url = URL.createObjectURL(blob);
urlRef.current = url;
setAudioUrl(url);
setAudioBlob(blob);
setSegments(null);
setError('');
if (persist) { setRestored(false); void saveRecording(blob); }
};

// Restore the last recording (survives a mobile tab discard / reload).
useEffect(() => {
let cancelled = false;
loadRecording().then(blob => {
if (!cancelled && blob) { setAudio(blob, false); setRestored(true); }
});
return () => { cancelled = true; };
}, []);

const onDrop = (files: File[]) => {
const f = files.find(x => x.type.startsWith('audio/') || x.type.startsWith('video/'));
if (f) setAudio(f);
Expand Down Expand Up @@ -138,6 +152,7 @@ export default function VoiceToText() {
setSegments(null);
setTranscribing(true);
setModelProgress(null); // only shows once real download progress fires (first load)
void wakeLock.request(); // keep the screen on so the phone doesn't lock + discard the tab
try {
const audio = await decodeToMono16k(audioBlob);
const isMultilingual = MODELS.find(m => m.value === model)?.multilingual;
Expand All @@ -156,6 +171,7 @@ export default function VoiceToText() {
} finally {
setTranscribing(false);
setModelProgress(null);
wakeLock.release();
}
};

Expand Down Expand Up @@ -200,7 +216,10 @@ export default function VoiceToText() {
{recorder.error && <Alert variant="error">{recorder.error.message}</Alert>}

{audioUrl && (
<audio ref={audioRef} controls src={audioUrl} onLoadedMetadata={fixAudioDuration} className="w-full" />
<div className="space-y-1">
<audio ref={audioRef} controls src={audioUrl} onLoadedMetadata={fixAudioDuration} className="w-full" />
{restored && <p className="text-xs text-muted-foreground">Restored your last recording.</p>}
</div>
)}

{/* Model + run */}
Expand Down
58 changes: 58 additions & 0 deletions src/tools/media/recording-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Persist the last recorded/loaded audio clip in IndexedDB so it survives a page
// reload — e.g. when a phone locks during a long transcription and the browser
// discards the tab. Blobs can't go in localStorage, hence IndexedDB.

const DB_NAME = 'gwt-voice';
const STORE = 'recordings';
const KEY = 'last';

function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}

export async function saveRecording(blob: Blob): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(blob, KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch { /* best-effort */ }
}

export async function loadRecording(): Promise<Blob | null> {
try {
const db = await openDb();
const blob = await new Promise<Blob | null>((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(KEY);
req.onsuccess = () => resolve((req.result as Blob) ?? null);
req.onerror = () => reject(req.error);
});
db.close();
return blob instanceof Blob ? blob : null;
} catch {
return null;
}
}

export async function clearRecording(): Promise<void> {
try {
const db = await openDb();
await new Promise<void>(resolve => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(KEY);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
db.close();
} catch { /* best-effort */ }
}
Loading