+ );
+}
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 (
-
+
{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)