mirror of
https://github.com/OpenWhispr/openwhispr.git
synced 2026-10-30 08:03:28 +08:00
* feat(dictation): stream Orukeet through managed Cloud sessions * Bound Orukeet capacity retry timers to the final deadline * Select fixed local retry delays for Orukeet capacity backoff * fix(dictation): Keep managed Orukeet rollouts recoverable on the desktop Four gaps would have made the staged rollout unsafe to operate: - Language gate: the managed route ignored the user's language and the wire protocol carries none, so a Japanese or Chinese user in the cohort would stream to a model that covers 25 languages. An explicitly selected language outside the registry list now takes the batch path, which honors the language end to end. "auto" keeps the model's own detection, as every other streaming provider does. - Start-time fallback: a refused session (403 FEATURE_NOT_ENABLED, 429 RATE_LIMITED, any 5xx) surfaced as a "Failed to start streaming" error and no recording started. It now starts a batch recording, the same path NO_API already took. Auth, policy, quota and upgrade denials still surface. Scoped to the managed Orukeet route. - Config refresh: the STT config was fetched once and pinned for the life of the window, so a server-side rollback never reached a running app. A copy older than 15 minutes is refreshed in the background at recording start, and a FEATURE_NOT_ENABLED denial drops it so the next start refetches before deciding. - Fallback telemetry: batch uploads from an eligible user carried no reason. They now send streamingFallbackReason (feature_disabled, rate_limited, session_unavailable, language_unsupported, stream_no_final) so the rollout's fallback rate is measurable. Backend contract this relies on: the session route's cohort denial is 403 with code FEATURE_NOT_ENABLED and its mint limit is 429 with code RATE_LIMITED. * fix(dictation): Keep streaming fallback telemetry through cloud cleanup With cloud cleanup on, the upload asks /api/transcribe not to log (sendLogs=false) because /api/reason writes the combined STT log, but the cleanup request never carried streamingFallbackReason, so every cleanup user's streaming-to-batch fallback went untracked. Forward the reason through cloud-reason into that combined log request. Translation alone no longer suppresses the transcribe log. It writes no combined STT log, so suppressing it dropped the row and its fallback reason along with it. * fix(dictation): Declare the Orukeet capability and refetch config uncached The backend (OpenWhispr/openwhispr-api#218) now advertises and mints Orukeet only for clients that send x-openwhispr-capabilities: orukeet, since a version number cannot prove a build ships the Orukeet client: a release without it would map the unknown provider to openai-realtime and silently reroute dictation. Send the capability on every policy request through withPolicyRequestHeaders. Fetch cloud config with cache: "no-store", so the refetch after a refused session cannot come back from Chromium's HTTP cache and re-advertise a route the server already withdrew. Tag stream_no_final only when the stream was Orukeet, so the rollout's fallback rate is not mixed with other providers'. The batch fallback itself still runs for every provider. * fix(dictation): Fall back to batch on any Orukeet start failure A managed Orukeet start only fell back to batch for FEATURE_NOT_ENABLED, RATE_LIMITED and 5xx. A network error, the 10 s session timeout, a WebSocket handshake failure or timeout, a rejected session body or a cookie-only session instead showed a "Streaming Error" toast and lost the recording, so an Orukeet outage past its token endpoint broke dictation for the whole cohort. Fall back as session_unavailable on every start failure except the denials the user has to act on: expired or missing sign-in, an account switch mid-start, account required, upgrade required, the word limit and any workspace policy. Still scoped to the managed Orukeet route, so no other provider changes. The fallback log line now carries the error message. * fix(dictation): Show the word limit and upgrade prompt on an Orukeet start At the weekly word limit the session route answers 429 with limitReached, but the desktop dropped that flag, so a cohort user saw "Streaming Error: Failed to start streaming: Weekly word limit reached" with no way to upgrade. Raise the same LIMIT_REACHED code the batch upload uses, carrying wordsUsed and limit, so the error card gets the batch limit title and the server's message, and open the existing upgrade prompt through notifyLimitReached. A 429 RATE_LIMITED from the mint cap is unchanged and still falls back to batch. * fix(dictation): Mint one Orukeet session per re-warm instead of two Every managed Orukeet connection spends a single-use GPU token, and after each dictation both re-warm paths (audioManager and useAudioRecording) call dictation-realtime-warmup at once. The warmup always tore down the current socket and reconnected, so the second call discarded the first's fresh socket and minted again: two tokens per dictation, which also let a normal dictation pace approach the 20-per-minute mint cap and fall back to batch. Run warmups one at a time and have a warmup reuse a connected Orukeet socket that has carried no audio and was opened for the same route, reporting alreadyWarm. A socket that has carried audio is never reused. Other providers keep reconnecting as before. The connection key that start, connect and warmup compare now comes from one helper. * Expose Orukeet audio language metadata for cloud fallback * Bound Orukeet idle sessions and support account-bound tokens --------- Co-authored-by: Chadpiha <chadpiha23@gmail.com>
119 lines
4.3 KiB
JavaScript
119 lines
4.3 KiB
JavaScript
"use strict";
|
|
|
|
// Operator-only: this combines backend token issuance and desktop streaming.
|
|
// The shipped desktop receives a session from OpenWhispr's authenticated backend.
|
|
const fs = require("node:fs");
|
|
const { createHash } = require("node:crypto");
|
|
const { performance } = require("node:perf_hooks");
|
|
const { setTimeout: sleep } = require("node:timers/promises");
|
|
const { issueSession } = require("../docs/integrations/orukeet/issue-session.cjs");
|
|
const { OrukeetStreaming } = require("../src/helpers/orukeetStreaming.js");
|
|
|
|
function readPcmWav(file) {
|
|
const data = fs.readFileSync(file);
|
|
if (
|
|
data.length < 12 ||
|
|
data.toString("ascii", 0, 4) !== "RIFF" ||
|
|
data.toString("ascii", 8, 12) !== "WAVE"
|
|
)
|
|
throw new Error("Expected a WAV file");
|
|
let format, pcm;
|
|
for (let at = 12; at + 8 <= data.length;) {
|
|
const id = data.toString("ascii", at, at + 4);
|
|
const length = data.readUInt32LE(at + 4);
|
|
const start = at + 8;
|
|
if (start + length > data.length) throw new Error("Truncated WAV chunk");
|
|
if (id === "fmt " && length >= 16) {
|
|
format = [
|
|
data.readUInt16LE(start),
|
|
data.readUInt16LE(start + 2),
|
|
data.readUInt32LE(start + 4),
|
|
data.readUInt16LE(start + 14),
|
|
];
|
|
}
|
|
if (id === "data") pcm = data.subarray(start, start + length);
|
|
at = start + length + (length % 2);
|
|
}
|
|
if (JSON.stringify(format) !== "[1,1,16000,16]" || !pcm?.length || pcm.length % 2) {
|
|
throw new Error("Expected nonempty mono 16 kHz signed PCM16 WAV");
|
|
}
|
|
return pcm;
|
|
}
|
|
|
|
async function main() {
|
|
if (!process.argv[2])
|
|
throw new Error("Usage: node scripts/benchmark-orukeet.cjs audio.wav [expected.json]");
|
|
const pcm = readPcmWav(process.argv[2]);
|
|
const expected = process.argv[3] ? JSON.parse(fs.readFileSync(process.argv[3])).text : null;
|
|
const runs = Number(process.env.SMOKE_RUNS || 3);
|
|
if (!Number.isInteger(runs) || runs < 1 || runs > 10) throw new Error("SMOKE_RUNS must be 1-10");
|
|
const results = [];
|
|
for (let run = 0; run < runs; run++) {
|
|
const client = new OrukeetStreaming();
|
|
const sessionStart = performance.now();
|
|
const session = await issueSession({ serviceKey: process.env.ORUKEET_SERVICE_KEY });
|
|
const tokenIssueMs = performance.now() - sessionStart;
|
|
let region;
|
|
try {
|
|
const connectStart = performance.now();
|
|
const connected = client.connect({
|
|
baseUrl: session.baseUrl,
|
|
clientToken: session.clientToken,
|
|
});
|
|
client.ws.on("upgrade", (response) => {
|
|
region = response.headers["x-orukeet-region"];
|
|
});
|
|
await connected;
|
|
const connectionReadyMs = performance.now() - connectStart;
|
|
const recordingStart = performance.now();
|
|
for (let at = 0; at < pcm.length; at += 3200) {
|
|
// Send each frame only when capture would have produced its last sample.
|
|
// The final PCM frame is therefore queued immediately before commit.
|
|
const target = recordingStart + Math.min(at + 3200, pcm.length) / 32;
|
|
await sleep(Math.max(0, target - performance.now()));
|
|
client.sendAudio(pcm.subarray(at, at + 3200));
|
|
}
|
|
const stop = performance.now();
|
|
const final = await client.finalize();
|
|
const commitToFinalMs = performance.now() - stop;
|
|
if (expected !== null && final.text !== expected)
|
|
throw new Error("Fixture transcript mismatch");
|
|
results.push({
|
|
run: run + 1,
|
|
region,
|
|
tokenIssueMs,
|
|
connectionReadyMs,
|
|
audioSeconds: pcm.length / 32000,
|
|
commitToFinalMs,
|
|
inferenceMs: final.inferenceMs,
|
|
serverMs: final.serverMs,
|
|
queueMs: final.queueMs,
|
|
transcriptMatches: expected === null ? null : true,
|
|
transcriptSha256: createHash("sha256").update(final.text).digest("hex"),
|
|
});
|
|
} finally {
|
|
await client.disconnect();
|
|
}
|
|
}
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
measuredAt: new Date().toISOString(),
|
|
method:
|
|
"Paced PCM16 using the PR adapter; commit-to-final excludes token/connection setup and recording",
|
|
fixtureSha256: createHash("sha256").update(fs.readFileSync(process.argv[2])).digest("hex"),
|
|
results,
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
}
|
|
|
|
if (require.main === module)
|
|
main().catch((error) => {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
});
|
|
module.exports = { readPcmWav };
|