diff --git a/components/DesktopAppBanner.tsx b/components/DesktopAppBanner.tsx new file mode 100644 index 0000000..e41e6d3 --- /dev/null +++ b/components/DesktopAppBanner.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { X } from "lucide-react"; +import { + downloadUrlFor, + isElectron, + PLATFORM_LABEL, + type Platform, +} from "@/lib/platform"; +import { usePlatform } from "@/hooks/usePlatform"; + +const DISMISSED_STORAGE_KEY = "rescript:desktop-banner-dismissed"; + +/** localStorage throws in private-mode Safari and when storage is full. */ +function readDismissed(): boolean { + try { + return window.localStorage.getItem(DISMISSED_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +function writeDismissed() { + try { + window.localStorage.setItem(DISMISSED_STORAGE_KEY, "1"); + } catch { + // Ignore — the banner simply comes back next session. + } +} + +function AppleIcon({ size }: { size: number }) { + return ( + + + + ); +} + +function WindowsIcon({ size }: { size: number }) { + return ( + + + + ); +} + +function LinuxIcon({ size }: { size: number }) { + return ( + + + + ); +} + +/** Nothing for an unrecognised desktop browser — its button reads "Download". */ +function PlatformIcon({ platform }: { platform: Platform }) { + if (platform === "mac-arm" || platform === "mac-intel") + return ; + if (platform === "windows") return ; + if (platform === "linux") return ; + return null; +} + +/** + * Web-only nudge toward the desktop build, which runs transcription and + * exports natively instead of through WebAssembly. Hidden inside the Electron + * shell, on phones and tablets (there is no mobile build to pitch), and once + * the user dismisses it. + */ +export default function DesktopAppBanner() { + const platform = usePlatform(); + const [dismissed, setDismissed] = useState( + () => isElectron || readDismissed(), + ); + + if (isElectron || dismissed || platform === "mobile") return null; + + const label = PLATFORM_LABEL[platform]; + + return ( +
+

+ + Want faster transcription and exports?{" "} + + Get the Rescript desktop app +

+ + + {label ? `Download for ${label}` : "Download"} + + +
+ ); +} diff --git a/components/Editor.tsx b/components/Editor.tsx index b5fa16d..8c4ceb9 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -6,6 +6,7 @@ import { useEditorStore } from "@/lib/store"; import { getCutRanges, isWordCutOut } from "@/lib/edits"; import { extractAudio, getFFmpeg, releaseFFmpeg } from "@/lib/ffmpeg"; import { VAD_SAMPLE_RATE } from "@/lib/vad"; +import { isNetworkError } from "@/lib/network"; import { isElectron } from "@/lib/platform"; import { reportError } from "@/lib/sentry"; import { startSessionReporting } from "@/lib/telemetry"; @@ -15,6 +16,7 @@ import { useIsDesktopLayout } from "@/hooks/useIsDesktopLayout"; import { useTranscriber } from "@/hooks/useTranscriber"; import { detectMediaKind, MEDIA_ACCEPT } from "@/lib/media"; import TopBar from "./TopBar"; +import DesktopAppBanner from "./DesktopAppBanner"; import UploadScreen from "./UploadScreen"; import TranscriptPanel from "./TranscriptPanel"; import MediaPreview from "./MediaPreview"; @@ -229,6 +231,16 @@ export default function Editor() { } } catch (err) { console.error("Processing pipeline failed:", err); + // Same reasoning as the worker's error path: a dropped connection while + // pulling the media engine is the user's network, not a bug, and + // "Failed to fetch" tells them nothing about what to do next. + if (isNetworkError(err)) { + s.setError( + "Couldn't load the media engine — the connection dropped. " + + "Check your internet and try again." + ); + return; + } reportError(err, "media-pipeline"); s.setError(err instanceof Error ? err.message : "Failed to process this file."); } @@ -333,6 +345,7 @@ export default function Editor() { return (
+ {status === "idle" ? ( <> {isElectron && diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 4717ebe..9077b51 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -277,7 +277,7 @@ export default function UploadScreen({
-
+
{MODEL_ORDER.map((id) => ( diff --git a/hooks/usePlatform.ts b/hooks/usePlatform.ts new file mode 100644 index 0000000..46162ef --- /dev/null +++ b/hooks/usePlatform.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { detectPlatform, type Platform } from "@/lib/platform"; + +// The OS never changes mid-session, so there is nothing to subscribe to. +const subscribe = () => () => {}; +const getServerSnapshot = (): Platform => "unknown"; + +/** + * Reads the visitor's platform without a hydration mismatch: the server (and + * the hydrating client render) sees "unknown", then React swaps in the real + * value. `detectPlatform` returns a string, so the snapshot is stable by value. + */ +export function usePlatform(): Platform { + return useSyncExternalStore(subscribe, detectPlatform, getServerSnapshot); +} diff --git a/hooks/useTranscriber.ts b/hooks/useTranscriber.ts index 3ccbeb2..a52fd14 100644 --- a/hooks/useTranscriber.ts +++ b/hooks/useTranscriber.ts @@ -69,9 +69,14 @@ export function useTranscriber() { break; case "error": s.setError(msg.message); - // Worker errors cross a postMessage boundary, so the original stack is - // already gone by here — send the message with a stage tag instead. - reportError(new Error(msg.message), "transcription"); + // A connection that dropped mid-download is the user's network, and + // the worker already retried it. There is no stack to act on, so + // reporting it only spends quota on an issue we cannot fix. + if (msg.cause !== "network") { + // Worker errors cross a postMessage boundary, so the original stack + // is already gone by here — send the message with a stage tag. + reportError(new Error(msg.message), "transcription"); + } break; } }; diff --git a/lib/network.ts b/lib/network.ts new file mode 100644 index 0000000..54480be --- /dev/null +++ b/lib/network.ts @@ -0,0 +1,111 @@ +/** + * Network-failure detection and download retries. + * + * Model weights are fetched from the Hub the first time a model runs — up to + * 1.3 GB for Parakeet fp16 — so a single dropped connection anywhere in that + * transfer used to abort the whole transcription with a bare "Failed to fetch". + */ + +/** + * True for failures that mean the request never completed — the network + * dropped, DNS failed, the connection reset — as opposed to a response that + * arrived and was unwelcome (those surface as an `ok: false` Response, not a + * throw). + * + * Matching on the message is the only option: fetch rejects with a bare + * `TypeError` carrying no code, and the wording is engine-specific — Chromium + * and Electron say "Failed to fetch", Firefox "NetworkError when attempting to + * fetch resource", WebKit just "Load failed". Chromium sometimes appends the + * underlying `net::ERR_*` instead, and onnxruntime / transformers.js wrap the + * original message in their own, so these are substring tests. + */ +export function isNetworkError(err: unknown): boolean { + const raw = err instanceof Error ? err.message : String(err ?? ""); + const msg = raw.toLowerCase().trim(); + if (!msg) return false; + // WebKit's entire message. Left as an exact match because "load failed" as a + // substring also describes plenty of non-network failures ("model load + // failed"), and mislabelling those as offline sends the user chasing their + // router instead of reporting a bug. + if (msg === "load failed") return true; + return ( + msg.includes("failed to fetch") || + msg.includes("networkerror when attempting to fetch") || + msg.includes("network request failed") || + msg.includes("the network connection was lost") || + msg.includes("err_internet_disconnected") || + msg.includes("err_network_changed") || + msg.includes("err_name_not_resolved") || + msg.includes("err_connection_") || + msg.includes("err_timed_out") || + msg.includes("err_address_unreachable") + ); +} + +/** Backoff before each retry; length also caps the number of attempts. */ +const RETRY_DELAYS_MS = [500, 2_000, 6_000]; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Wrap a scope's `fetch` so requests that fail at the transport layer are + * retried with backoff. + * + * Only network-class rejections are retried. An HTTP error is a real answer and + * is returned untouched; an abort is a decision, not a failure. Retries are + * limited to GET/HEAD, which are the only requests here (weights, ORT WASM) and + * the only ones safe to replay without a body to re-send. + * + * Idempotent per scope, so importing this from more than one place is harmless. + * Returns the wrapped function, for libraries that snapshot `globalThis.fetch` + * at import time and so never see the replacement (transformers.js keeps its own + * `env.fetch` this way). + */ +export function installFetchRetry(scope: { + fetch: typeof fetch; + __fetchRetryInstalled?: boolean; +}): typeof fetch { + if (scope.__fetchRetryInstalled) return scope.fetch; + scope.__fetchRetryInstalled = true; + + const original = scope.fetch.bind(scope) as typeof fetch; + + scope.fetch = async (input, init) => { + const method = ( + init?.method ?? + (typeof Request !== "undefined" && input instanceof Request + ? input.method + : "GET") + ).toUpperCase(); + const replayable = method === "GET" || method === "HEAD"; + + for (let attempt = 0; ; attempt++) { + try { + return await original(input, init); + } catch (err) { + const signal = + init?.signal ?? + (typeof Request !== "undefined" && input instanceof Request + ? input.signal + : undefined); + if ( + signal?.aborted || + !replayable || + !isNetworkError(err) || + attempt >= RETRY_DELAYS_MS.length + ) { + throw err; + } + const wait = RETRY_DELAYS_MS[attempt]; + console.warn( + `Network request failed; retrying in ${wait}ms ` + + `(attempt ${attempt + 2} of ${RETRY_DELAYS_MS.length + 1}).`, + err + ); + await sleep(wait); + } + } + }; + + return scope.fetch; +} diff --git a/lib/platform.ts b/lib/platform.ts index 25c259b..c7b8504 100644 --- a/lib/platform.ts +++ b/lib/platform.ts @@ -1,2 +1,61 @@ export const isElectron = typeof navigator !== "undefined" && /electron/i.test(navigator.userAgent); + +/** + * Desktop platforms the app ships builds for. Ported from the marketing site + * (getrescript.com `src/lib/platform.ts`) so the two agree on what `/download` + * expects in its `platform` query param. "mobile" is local to this app: the + * site never needs it, but the in-app banner must not pitch a desktop build to + * a phone. + */ +export type Platform = + | "mac-arm" + | "mac-intel" + | "windows" + | "linux" + | "mobile" + | "unknown"; + +/** Phones and tablets, including iPadOS, which reports a desktop Mac UA. */ +function isMobile(ua: string): boolean { + if (/android|iphone|ipod|ipad|windows phone|mobile/.test(ua)) return true; + // iPadOS 13+ masquerades as macOS; a touch-capable "Mac" is really an iPad. + return ( + ua.includes("mac") && + typeof navigator !== "undefined" && + navigator.maxTouchPoints > 1 + ); +} + +export function detectPlatform(): Platform { + if (typeof navigator === "undefined") return "unknown"; + const ua = navigator.userAgent.toLowerCase(); + const platform = (navigator.platform || "").toLowerCase(); + + if (isMobile(ua)) return "mobile"; + if (ua.includes("win") || platform.includes("win")) return "windows"; + if (ua.includes("linux") || platform.includes("linux")) return "linux"; + if (ua.includes("mac") || platform.includes("mac")) { + // Apple Silicon is the common default for recent Macs; the download page + // offers Intel as the alternate. + return "mac-arm"; + } + return "unknown"; +} + +export const DOWNLOAD_PAGE_URL = "https://www.getrescript.com/#download"; + +/** The site's `/download` route reads this param (see its parsePlatformParam). */ +export function downloadUrlFor(platform: Platform): string { + if (platform === "mobile" || platform === "unknown") return DOWNLOAD_PAGE_URL; + return `https://www.getrescript.com/download?platform=${platform}`; +} + +export const PLATFORM_LABEL: Record = { + "mac-arm": "Mac", + "mac-intel": "Mac", + windows: "Windows", + linux: "Linux", + mobile: "", + unknown: "", +}; diff --git a/lib/types.ts b/lib/types.ts index 51d82f7..cca974f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -87,7 +87,11 @@ export type WorkerResponse = | { type: "progress"; message: string; value: number | null } | { type: "partial"; text: string } | { type: "complete"; words: Word[] } - | { type: "error"; message: string }; + /** + * `cause` marks failures whose origin is the user's environment rather than + * the app, so the main thread can skip crash reporting for them. + */ + | { type: "error"; message: string; cause?: "network" }; export interface WorkerRequest { audio: Float32Array; diff --git a/tests/network-test.ts b/tests/network-test.ts new file mode 100644 index 0000000..62be196 --- /dev/null +++ b/tests/network-test.ts @@ -0,0 +1,145 @@ +import { isNetworkError, installFetchRetry } from "../lib/network"; + +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +{ + // The three engine wordings, as reported by RESCRIPT-9 (Electron/Windows) and + // their Firefox / WebKit equivalents. + assert(isNetworkError(new TypeError("Failed to fetch")), "chromium wording"); + assert( + isNetworkError( + new TypeError("NetworkError when attempting to fetch resource."), + ), + "firefox wording", + ); + assert(isNetworkError(new TypeError("Load failed")), "webkit wording"); + assert( + isNetworkError("net::ERR_INTERNET_DISCONNECTED"), + "chromium net error code", + ); + // transformers.js / onnxruntime wrap the original message in their own. + assert( + isNetworkError( + new Error( + "Error: no available backend found. ERR: [wasm] TypeError: Failed to fetch", + ), + ), + "wrapped message should still match", + ); +} + +{ + assert( + !isNetworkError(new Error("Parakeet WebGPU/fp16 model load failed")), + "'load failed' inside a longer message is not a network error", + ); + assert( + !isNetworkError(new Error("404 Not Found")), + "http status is a real answer", + ); + assert( + !isNetworkError(new Error("Unknown speech model: base")), + "unrelated error", + ); + assert(!isNetworkError(null), "null"); + assert(!isNetworkError(""), "empty"); +} + +// installFetchRetry: replays GETs that fail at the transport layer. Wrapped in +// a function because the retries are awaited and tsx compiles these to CJS. +async function retryChecks() { + { + let calls = 0; + const scope = { + fetch: (async () => { + calls += 1; + if (calls === 1) throw new TypeError("Failed to fetch"); + return { ok: true } as Response; + }) as unknown as typeof fetch, + }; + installFetchRetry(scope); + const res = await scope.fetch("https://huggingface.co/model.onnx"); + assert( + res.ok && calls === 2, + `transient failure should be retried (calls=${calls})`, + ); + + // Second install on the same scope must not double-wrap. + installFetchRetry(scope); + calls = 0; + await scope.fetch("https://huggingface.co/model.onnx"); + assert(calls === 2, `install should be idempotent (calls=${calls})`); + } + + { + // A non-network throw is passed straight through. + let calls = 0; + const scope = { + fetch: (async () => { + calls += 1; + throw new Error("Refused to connect: bad scheme"); + }) as unknown as typeof fetch, + }; + installFetchRetry(scope); + let threw = false; + try { + await scope.fetch("https://huggingface.co/model.onnx"); + } catch { + threw = true; + } + assert( + threw && calls === 1, + `non-network error should not retry (calls=${calls})`, + ); + } + + { + // Bodies cannot be replayed, so non-idempotent methods are left alone. + let calls = 0; + const scope = { + fetch: (async () => { + calls += 1; + throw new TypeError("Failed to fetch"); + }) as unknown as typeof fetch, + }; + installFetchRetry(scope); + try { + await scope.fetch("/api/telemetry", { method: "POST", body: "{}" }); + } catch { + // expected + } + assert(calls === 1, `POST should not retry (calls=${calls})`); + } + + { + // An abort is a decision, not a failure. + let calls = 0; + const scope = { + fetch: (async () => { + calls += 1; + throw new TypeError("Failed to fetch"); + }) as unknown as typeof fetch, + }; + installFetchRetry(scope); + const controller = new AbortController(); + controller.abort(); + try { + await scope.fetch("https://huggingface.co/model.onnx", { + signal: controller.signal, + }); + } catch { + // expected + } + assert(calls === 1, `aborted request should not retry (calls=${calls})`); + } +} + +retryChecks().then( + () => console.log("network-test: ok"), + (err) => { + console.error(err); + process.exit(1); + }, +); diff --git a/workers/transcription.worker.ts b/workers/transcription.worker.ts index 7a574dd..0cefed6 100644 --- a/workers/transcription.worker.ts +++ b/workers/transcription.worker.ts @@ -79,8 +79,21 @@ import { speechSegmentsFromFrames, type SpeechSegment, } from "@/lib/vad"; +import { isNetworkError, installFetchRetry } from "@/lib/network"; import { isWebGpuDeviceLostError } from "@/lib/webgpu"; +/** + * Weight downloads are the longest-running fetches in the app (over a gigabyte + * for Parakeet on WebGPU), so a momentary drop anywhere in one used to fail the + * whole transcription with a bare "Failed to fetch". Retry them. + * + * parakeet.js and onnxruntime call the global, which the install replaces. + * transformers.js does not: it binds `globalThis.fetch` into `env.fetch` when its + * module is first evaluated — which, imports being hoisted, is already done by + * the time this line runs — so it has to be pointed at the wrapper by hand. + */ +env.fetch = installFetchRetry(self as unknown as { fetch: typeof fetch }); + env.allowLocalModels = false; /** * Where {@link MODELS} entries flagged `local` are served from — an export that @@ -1359,6 +1372,22 @@ self.onmessage = async (event: MessageEvent) => { } catch (err) { console.error(err); cancelLive(); + if (isNetworkError(err)) { + // The retries in installFetchRetry are already spent by here, so this is + // a connection that stayed down. "Failed to fetch" is what the browser + // says and it means nothing to the person waiting on a transcript — name + // the download, and say that the finished files are kept so a retry + // resumes rather than starting the gigabyte over. + post({ + type: "error", + message: + "Couldn't finish downloading the speech model — the connection " + + "dropped. Check your internet and try again; the parts that " + + "finished downloading are kept.", + cause: "network", + }); + return; + } post({ type: "error", message: isWebGpuDeviceLostError(err)