NAM A2 Sync

This commit is contained in:
Robin E.R. Davies
2026-05-12 12:31:21 -04:00
parent 44db94a955
commit c456e5187b
39 changed files with 5086 additions and 2206 deletions
+546
View File
@@ -0,0 +1,546 @@
import { PiPedalModel, getErrorMessage, Tone3000PkceParams } from "./PiPedalModel";
import Tone3000DownloadType from "./Tone3000DownloadType";
import { PkceParams, buildPkceParams, handleOAuthCallback } from "./t3k/tone3000-client.ts";
import { Model, PaginatedResponse, Platform, Tone } from "./t3k/types.ts";
import { PUBLISHABLE_KEY } from './t3k/config.ts';
import { startSelectFlowPopup, T3KClient } from "./t3k/tone3000-client.ts";
import Tone3000DownloadProgress from './Tone3000DownloadProgress';
import { safeFilenameEncode } from "./SafeFilename";
// const T3K_API = "https://www.tone3000.com";
// used to check for online status.
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
async function asyncSleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
const RUN_THROTTLER_TEST = true;
const ALLOWED_DOWNLOADS_PER_MINUTE = 25;
const THROTTLING_TRIGGGER_LEVEL = Math.floor(ALLOWED_DOWNLOADS_PER_MINUTE/2);
class DownloadThrottler {
private throttleActive: boolean = false;
private fifo: number[] = [];
private downloadCount: number = 0;
resetThrottling(now: number = Date.now()): void {
// clean out entries that are no longer counted by the Ton3000 throttler.
while (this.fifo.length > 0 && this.fifo[0] + 60000 <= now) {
this.fifo.shift();
}
if (this.fifo.length > THROTTLING_TRIGGGER_LEVEL) {
this.throttleActive = true;
} else {
this.throttleActive = false;
}
}
getThrottleDelay(now : number = Date.now()): number {
while (this.fifo.length > 0 && this.fifo[0] + 60000 <= now) {
this.fifo.shift();
}
let downloadCount = this.downloadCount;
let delay: number;
if (downloadCount < THROTTLING_TRIGGGER_LEVEL) {
delay = 0;
} else if (downloadCount < ALLOWED_DOWNLOADS_PER_MINUTE) {
delay = Math.ceil((60000 / (ALLOWED_DOWNLOADS_PER_MINUTE-THROTTLING_TRIGGGER_LEVEL)));
} else {
delay = Math.ceil(60000 / (ALLOWED_DOWNLOADS_PER_MINUTE-1));
}
this.fifo.push(now + delay);
console.debug(` ${now}: Download: ${downloadCount} Delay: ${delay} ms Throttle count: ${this.fifo.length}`);
++this.downloadCount;
return delay;
}
}
function runThrottlerTest() {
// just dump the delay times for a few downloads to the debugger console, to verify that the throttler is working as expected;
let throttler = new DownloadThrottler();
let download = 0;
let now = 0;
console.debug("Tone3000 Throttler Test");
while (download < ALLOWED_DOWNLOADS_PER_MINUTE *4) {
let delay = throttler.getThrottleDelay(now);
now += delay;
++download;
}
}
export class Tone3000DownloadHandler {
// private async handleTone3000Download(
// code: string, toneId: String, downloadType: Tone3000DownloadType): Promise<void>
// {
// const {access_token} = await exchangeCode(code);
// try {
// const xxx;
// await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType);
// return;
// } catch (e) {
// this.handleTone3000DownloadError(getErrorMessage(e));
// return;
// }
// }
private messageEventListener: ((event: MessageEvent) => void) | null = null;
private t3kClient: T3KClient;
private throttler: DownloadThrottler = new DownloadThrottler();
private model: PiPedalModel;
pkceParams: PkceParams | null = null;
public constructor(model: PiPedalModel) {
if (RUN_THROTTLER_TEST) {
runThrottlerTest();
}
this.model = model;
this.t3kClient = new T3KClient(
PUBLISHABLE_KEY,
() => {
model.showAlert("TONE3000 authentication failed. Please try again.");
}
);
let this_ = this;
this.messageEventListener = (event: MessageEvent) => {
if (event.data?.type === "t3k_response") {
let uri = event.data.uri;
this.handleTone3000DownloadComplete();
if (uri) {
this_.handleT3kSelectResponse(uri, event.data.storedState, event.data.codeVerifier)
.then(() => { })
.catch((error) => {
model.showAlert(getErrorMessage(error));
});
}
}
};
window.addEventListener("message", this.messageEventListener);
}
public dispose(): void {
if (this.messageEventListener) {
window.removeEventListener("message", this.messageEventListener);
this.messageEventListener = null;
}
}
private async handleT3kSelectResponse(
uri: string,
popupStoredState: string | null,
popupCodeVerifier: string | null
): Promise<void> {
if (this.pkceParams === null) {
this.model.showAlert("Invalid PKCE parameters. Please try again.");
return;
}
let tone3000PckceParams: Tone3000PkceParams = {
publishableKey: PUBLISHABLE_KEY,
redirectUrl: this.redirectUrl(),
codeChallenge: this.pkceParams.codeChallenge,
codeVerifier: this.pkceParams.codeVerifier,
state: this.pkceParams.state
};
this.DownloadModelsFromTone3000(uri, tone3000PckceParams, this.downloadPath, this.downloadType);
}
private handle = 0;
private nextHandle(): number {
return ++this.handle;
}
private progress: Tone3000DownloadProgress = new Tone3000DownloadProgress();
private async throttleRequests(): Promise<void> {
let now = Date.now();
let delay = this.throttler.getThrottleDelay(now);
while (delay > 0) {
await asyncSleep(250);
if (this.checkForCancel())
{
return;
}
delay -= 250;
}
}
private onTone3000DownloadStarted() {
this.throttler.resetThrottling(); // allow full-rate burst of downloads at the start of each tone download.
this.progress.handle = this.nextHandle();
this.progress.title = "";
this.progress.progress = 0;
this.progress.total = 0;
this.progress.cancelled = false;
this.progress.transferring = true;
this.model.onTone3000DownloadStarted(this.progress.handle, this.progress.title);
}
private onTone3000DownloadProgress(progress: Tone3000DownloadProgress) {
let newProgress = progress.clone();
this.model.onTone3000DownloadProgress(newProgress);
}
private onTone3000DownloadError(errorMessage: string) {
this.progress.transferring = false;
this.model.onTone3000DownloadError(this.progress.handle, errorMessage);
}
private onTone3000DownloadComplete(resultPath: string) {
this.progress.transferring = false;
this.model.onTone3000DownloadComplete(resultPath);
}
private async DownloadModelsFromTone3000(
responseUri: string,
tone3000PckceParams: Tone3000PkceParams,
downloadPath: string,
downloadType: Tone3000DownloadType
): Promise<void> {
try {
this.onTone3000DownloadStarted();
this.progress.title = "Authenticating..."
this.onTone3000DownloadProgress(this.progress);
if (this.checkForCancel()) {
return;
}
await this.throttleRequests();
let tokenResponse = await handleOAuthCallback(
PUBLISHABLE_KEY,
this.redirectUrl(),
responseUri);
if (!tokenResponse.ok) {
;
throw new Error(tokenResponse.error);
}
if (this.checkForCancel()) {
return;
}
this.t3kClient.setTokens(tokenResponse.tokens);
if (tokenResponse.toneId == undefined) {
throw new Error("No tone selected. Please try again.");
}
let toneId = tokenResponse.toneId;
this.progress.title = "Fetching tone information...";
this.onTone3000DownloadProgress(this.progress);
await this.throttleRequests();
let tone: Tone = await this.t3kClient.getTone(tokenResponse.toneId);
if (this.checkForCancel()) {
return;
}
this.progress.title = tone.title;
this.onTone3000DownloadProgress(this.progress);
let page = 1;
let models: Model[] = [];
let architectureFilter: number | undefined = undefined;
// Take A2 models only for NAM downloads, and only if the tone actually has A2 models.
if (downloadType === Tone3000DownloadType.Nam && !!tone.a2_models_count) {
architectureFilter = 2; // NAM models only
}
while (true) {
await this.throttleRequests();
if (this.checkForCancel()) {
return;
}
let modelsThisTime: PaginatedResponse<Model> = await this.t3kClient.listModels(toneId, page, 800, architectureFilter);
models.push(...modelsThisTime.data);
if (modelsThisTime.total_pages <= page) {
break;
}
++page;
}
let uploadPath = this.downloadPath;
let extension: string;
switch (tone.platform) {
case Platform.Nam:
extension = ".nam";
break;
case Platform.Ir:
extension = ".wav";
break;
case Platform.Proteus:
extension = ".json";
break;
default:
throw new Error("Unsupported platform: " + tone.platform);
break;
}
let toneUploadPath = uploadPath + "/" + safeFilenameEncode(tone.title) + "/";
this.progress.total = models.length;
this.onTone3000DownloadProgress(this.progress);
let lastUpdateTime = Date.now();
for (const model of models) {
this.progress.title = model.name;;
if (Date.now() - lastUpdateTime > 250) {
this.onTone3000DownloadProgress(this.progress);
lastUpdateTime = Date.now();
}
await this.throttleRequests();
if (!model.model_url) {
throw new Error("Model " + model.name + " does not have a model URL.");
}
let modelUploadPath = toneUploadPath + safeFilenameEncode(model.name) + extension;
let accessToken = await this.t3kClient.getAccessToken();
let modelResult = await fetch(model.model_url,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
duplex: 'half',
} as RequestInit & { duplex: 'half' }
);
if (!modelResult.ok) {
throw new Error(`Model download failed: ${modelResult.status} ${modelResult.statusText}`);
}
if (this.checkForCancel()) {
return;
}
let serverUrl = this.model.varServerUrl + "t3k_uploadAsset?path="
+ encodeURIComponent(modelUploadPath);
// get the content length of modelResult from modelResult headers (modelresult is the return value from fetch())
const strContentLength = modelResult.headers.get('Content-Length');
let contentLength: number = 0;
if (strContentLength) {
contentLength = parseInt(strContentLength);
if (isNaN(contentLength)) {
throw new Error("File download from Tone3000 server failed: Content-Length header is invalid.");
}
}
// POST binary modelResult to serverUrl.
// Prefer streaming request body when available, else fall back to blob().
const uploadBody: Blob =
await modelResult.blob();
const uploadResponse = await fetch(serverUrl, {
method: 'POST',
body: uploadBody,
headers: {
'Content-Type': modelResult.headers.get('Content-Type') ?? 'application/octet-stream',
"Content-Length": contentLength.toString(),
"Transfer-Encoding": "chunked"
},
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
// discard the response body, if any, to free up memory
let uploadResult: any = await uploadResponse.json();
if (!uploadResult.ok === true) {
throw new Error(`Upload failed: ${uploadResult.error ?? "Unknown error."}`);
}
this.progress.progress++;
}
// yyy: get the image file.
// yyy: write the reaadme file.
this.onTone3000DownloadComplete(uploadPath);
} catch (error) {
this.onTone3000DownloadError(getErrorMessage(error)
+ " " + this.progress.progress.toString() + "/" + this.progress.total.toString() + " models downloaded.");
return;
}
}
private popupWindow: Window | null = null;
private redirectUrl(): string {
let hostname = window.location.hostname;
let port = window.location.port;
let protocol = window.location.protocol;
let serverUrl: string;
if (protocol === "http:" && (port === "80" || port === "")) {
serverUrl = `${protocol}//${hostname}`;
} else if (protocol === "https:" && (port === "443" || port === "")) {
serverUrl = `${protocol}//${hostname}`;
} else {
serverUrl = `${protocol}//${hostname}:${port}`;
}
return `${serverUrl}/html/t3k_response.html`;
// return `${serverUrl}/t3k_response.html`; // for a debuggable react version of the page
}
private handleTone3000DownloadComplete() {
if (this.popupWindow) {
if (!this.popupWindow.closed) {
this.popupWindow.close();
}
this.popupWindow = null;
}
this.progress.transferring = false;
this.model.hideTone3000DownloadStatus();
}
closeTone3000Popup() {
this.handleTone3000DownloadComplete();
}
private handleTone3000DownloadError(errorMessage: string) {
this.progress.transferring = false;
this.handleTone3000DownloadComplete();
this.model.showAlert(errorMessage);
}
private downloadPath: string = "";
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
private checkForCancel(): boolean {
if (this.progress.cancelled) {
this.handleTone3000DownloadComplete();
return true;
}
return false;
}
private launchInstance = 0;
public async launchTone3000Popup(
downloadType: Tone3000DownloadType,
downloadPath: string,
): Promise<void> {
if (this.progress.transferring) {
return;
}
this.closeTone3000Dialog();
// online
this.downloadPath = downloadPath;
this.downloadType = downloadType;
let popupWidth = Math.floor(window.innerWidth * 0.8);
let popupHeight = Math.floor(window.innerHeight * 0.8);
if (window.innerWidth < 410) {
popupWidth = 400;
}
this.model.showTone3000DownloadStatus();
let launchInstance = ++this.launchInstance;
let cancelled = () => {
return (launchInstance !== this.launchInstance) || this.popupWindow == null || this.popupWindow.closed;
}
try {
let pkceParams = await buildPkceParams();
this.pkceParams = pkceParams;
this.popupWindow = await startSelectFlowPopup(
pkceParams,
PUBLISHABLE_KEY,
this.redirectUrl(),
{
architecture: downloadType === Tone3000DownloadType.CabIr ? undefined : 2,
platform: downloadType === Tone3000DownloadType.CabIr ? Platform.Ir : Platform.Nam,
menubar: true,
width: popupWidth,
height: popupHeight
},
);
if (!this.popupWindow) {
console.error("Failed to open TONE3000 dialog popup window.");
throw new Error("Cannot open popup window.");
}
// No procedure for detecting 404, so let's ping the server to see if it's reachable, and cancel if it's not.
try {
let response = await fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" });
if (cancelled()) return;
if (!response.ok) {
// offline
throw new Error("Unable to connect to the TONE3000 server.");
}
} catch (error) {
throw new Error("Not online. Unable to connect to the TONE3000 server. Your client device must have access to the internet to use this feature..");
};
let pipedalServerCanReachTone3000 = false;
// check to see that the PiPedal server can reach TONE3000.
try {
pipedalServerCanReachTone3000 = await this.model.pingTone3000Server();
if (cancelled()) return;
} catch (error) {
}
if (!pipedalServerCanReachTone3000) {
throw new Error("The PiPedal server cannot reach a TONE3000 server. The PiPedal server must have access to the internet to use this feature.");
}
while (true) {
await new Promise(resolve => setTimeout(resolve, 500));
if (launchInstance != this.launchInstance) {
// A new dialog launch has been initiated. Stop monitoring this one.
return;
}
if (this.popupWindow === null || this.popupWindow.closed) {
this.closeTone3000Dialog();
return;
}
}
} catch (error) {
this.handleTone3000DownloadError(getErrorMessage(error));
}
}
cancelDownload(handle: number): void {
this.closeTone3000Dialog();
if (this.progress.handle == handle) {
this.progress.cancelled = true;
}
}
public closeTone3000Dialog(): void {
if (this.popupWindow !== null) {
this.popupWindow.close();
this.popupWindow = null;
if (!this.progress.transferring) {
this.handleTone3000DownloadComplete();
}
}
}
}