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
+5 -8
View File
@@ -58,7 +58,6 @@ import { PiPedalModelFactory, PiPedalModel, State, ZoomedControlInfo, wantsReloa
import ZoomedUiControl from './ZoomedUiControl'
import MainPage from './MainPage';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import ListSubheader from '@mui/material/ListSubheader';
import { BankIndex, BankIndexEntry } from './Banks';
@@ -1086,13 +1085,11 @@ export
aria-describedby="alert-dialog-description"
>
<DialogContent>
<DialogContentText id="alert-dialog-description">
<Typography variant="body2">
{
this.state.alertDialogMessage
}
</Typography>
</DialogContentText>
<Typography variant="body2" id="alert-dialog-description">
{
this.state.alertDialogMessage
}
</Typography>
</DialogContent>
<DialogActions>
<Button variant="dialogPrimary" onClick={this.handleCloseAlert} color="primary" autoFocus>
+17 -2
View File
@@ -724,7 +724,7 @@ export default withStyles(
if (this.state.previousSelection == selectedItem) {
return;
}
if (!this.isTextFile(selectedItem) && !this.isFolderArtwork(selectedItem)) {
if (!this.isTextFile(selectedItem) && !this.isFolderArtwork(selectedItem) && !this.isPdfFile(selectedItem)) {
this.props.onApply(fileProperty, selectedItem);
}
this.setState({ previousSelection: selectedItem });
@@ -1812,6 +1812,19 @@ export default withStyles(
return false;
}
isPdfFile(fileName: string) {
let extension = pathExtension(fileName);
if (extension === ".pdf") {
return true;
}
return false;
}
handleShowPdfFile(fileName: string) {
// open pdf in new tab
this.model.displayMediaFile(fileName);
}
handleShowTextFile(fileName: string) {
this.setState({ textFileName: fileName });
}
@@ -1827,7 +1840,9 @@ export default withStyles(
this.requestFiles(this.state.selectedFile);
this.setState({ navDirectory: this.state.selectedFile });
} else {
if (this.isTextFile(this.state.selectedFile)) {
if (this.isPdfFile(this.state.selectedFile)) {
this.handleShowPdfFile(this.state.selectedFile);
} else if (this.isTextFile(this.state.selectedFile)) {
this.handleShowTextFile(this.state.selectedFile);
} else {
this.props.onOk(this.props.fileProperty, this.state.selectedFile);
+94 -31
View File
@@ -27,7 +27,7 @@ import { Pedalboard, PedalboardItem, ControlValue, Snapshot } from './Pedalboard
import PluginClass from './PluginClass';
import ScreenOrientation from './ScreenOrientation';
import PiPedalSocket, { PiPedalMessageHeader } from './PiPedalSocket';
import {Tone3000DownloadHandler} from './Tone3000Dialog';
import { Tone3000DownloadHandler } from './Tone3000Downloader';
import Tone3000DownloadType from './Tone3000DownloadType';
import { nullCast } from './Utility'
import { JackConfiguration, JackChannelSelection } from './Jack';
@@ -51,6 +51,7 @@ import { getDefaultModGuiPreference } from './ModGuiHost';
import ChannelRouterSettings from './ChannelRouterSettings';
import Tone3000DownloadProgress from './Tone3000DownloadProgress';
export enum State {
Loading,
Ready,
@@ -64,6 +65,15 @@ export enum State {
HotspotChanging,
};
export interface Tone3000PkceParams {
publishableKey: string;
redirectUrl: string;
codeVerifier: string;
codeChallenge: string;
state: string;
}
export function getErrorMessage(error: any) {
if (error instanceof Error) {
return (error as Error).message;
@@ -87,6 +97,38 @@ export enum ReconnectReason {
HotspotChanging
};
export interface Tone3000ModelInfo {
url: string;
name: string;
};
export interface Tone3000DownloadRequest {
downloadType: Tone3000DownloadType;
downloadPath: string;
appId: string;
state: string;
codeChallenge: string;
codeVerifier: string;
authToken: string;
id: number,
title: string;
description: string;
imageUrl: string;
models: Tone3000ModelInfo[];
updated_at: string;
user: string;
sizes: string[];
platform: string;
gear: string;
license: string;
links: string[];
};
export class HostVersion {
constructor(hostVersion: string) {
this.versionString = hostVersion;
@@ -488,6 +530,7 @@ export class PiPedalModel //implements PiPedalModel
serverVersion?: PiPedalVersion;
countryCodes: { [Name: string]: string } = {};
serverUrl: string = "";
socketServerUrl: string = "";
varServerUrl: string = "";
modResourcesUrl: string = "";
@@ -557,8 +600,8 @@ export class PiPedalModel //implements PiPedalModel
);
zoomedUiControl: ObservableProperty<ZoomedControlInfo | undefined> = new ObservableProperty<ZoomedControlInfo | undefined>(undefined);
tone3000Downloading : ObservableProperty<boolean> = new ObservableProperty<boolean>(false);
tone3000DownloadProgress : ObservableProperty<Tone3000DownloadProgress | null> = new ObservableProperty<Tone3000DownloadProgress | null>(null);
tone3000Downloading: ObservableProperty<boolean> = new ObservableProperty<boolean>(false);
tone3000DownloadProgress: ObservableProperty<Tone3000DownloadProgress | null> = new ObservableProperty<Tone3000DownloadProgress | null>(null);
uiPluginsByUri: Map<string, UiPlugin> = new Map<string, UiPlugin>();
@@ -664,7 +707,7 @@ export class PiPedalModel //implements PiPedalModel
}
async pingTone3000Server() : Promise<boolean> {
async pingTone3000Server(): Promise<boolean> {
if (this.webSocket === undefined) {
return false;
@@ -677,41 +720,39 @@ export class PiPedalModel //implements PiPedalModel
return false;
}
}
async downloadModelsFromTone3000(
tone3000DownloadUrl: string,
downloadPath: string,
downloadType: Tone3000DownloadType
downloadRequest: Tone3000DownloadRequest
): Promise<void> {
try {
if (!this.webSocket) {
return;
}
this.webSocket.send("downloadModelsFromTone3000",
{
downloadPath: downloadPath,
tone3000Url: tone3000DownloadUrl,
downloadType: downloadType
});
} catch (error) {
this.webSocket.send("downloadModelsFromTone3000", downloadRequest);
} catch (error) {
this.showAlert(getErrorMessage(error));
}
}
private cancelTone3000Download(): void {
let downloadProgress = this.tone3000DownloadProgress.get();
if (downloadProgress !== null) {
if (downloadProgress.handle !== -1) {
if (!this.webSocket) {
this.tone3000DownloadProgress.set(null);
this.tone3000Downloading.set(false);
return;
}
this.webSocket.send("cancelTone3000Download",downloadProgress.handle);
if (this.tone3000DownloadHandler) {
this.tone3000DownloadHandler.cancelDownload(this.tone3000DownloadProgress.get()?.handle ?? -1);
}
// if (downloadProgress.handle !== -1) {
// if (!this.webSocket) {
// this.tone3000DownloadProgress.set(null);
// this.tone3000Downloading.set(false);
// return;
// }
// this.webSocket.send("cancelTone3000Download", downloadProgress.handle);
// }
}
}
showTone3000DownloadPopup(
downloadType: Tone3000DownloadType,
downloadType: Tone3000DownloadType,
downloadPath: string
): void {
if (this.tone3000DownloadHandler === null) {
@@ -1084,34 +1125,34 @@ export class PiPedalModel //implements PiPedalModel
// this.webSocket?.reconnect(); // let the server do it for us.
}
// TONE3000 download notification handlers (stub implementations)
private onTone3000DownloadStarted(handle: number, title: string): void {
onTone3000DownloadStarted(handle: number, title: string): void {
this.tone3000Downloading.set(true);
}
private onTone3000DownloadProgress(progress: Tone3000DownloadProgress): void {
onTone3000DownloadProgress(progress: Tone3000DownloadProgress): void {
this.tone3000Downloading.set(true);
this.tone3000DownloadProgress.set(progress);
}
private onTone3000DownloadComplete(resultPath: string): void {
onTone3000DownloadComplete(resultPath: string): void {
this.tone3000Downloading.set(false);
this.tone3000DownloadProgress.set(null);
this.onTone3000DownloadCompleteEvent.fire(resultPath);
}
private onTone3000DownloadError(handle: number, errorMessage: string): void {
onTone3000DownloadError(handle: number, errorMessage: string): void {
console.error(`TONE3000 download error: handle=${handle}, error=${errorMessage}`);
this.tone3000Downloading.set(false);
this.tone3000DownloadProgress.set(null);
this.onTone3000DownloadCompleteEvent.fire("");
setTimeout(() => {
this.showAlert(errorMessage);
}, 100);
}
setError(message: string): void {
this.errorMessage.set(message);
this.setState(State.Error);
@@ -1220,7 +1261,9 @@ export class PiPedalModel //implements PiPedalModel
}
private makeVarServerUrl(protocol: string, hostName: string, port: number): string {
return protocol + "://" + hostName + ":" + port + "/var/";
}
private makeServerUrl(protocol: string, hostName: string, port: number): string {
return protocol + "://" + hostName + ":" + port;
}
private makeModResourceUrl(protocol: string, hostName: string, port: number): string {
return protocol + "://" + hostName + ":" + port + "/resources/";
@@ -1303,6 +1346,7 @@ export class PiPedalModel //implements PiPedalModel
this.socketServerUrl = socket_server;
this.varServerUrl = var_server_url;
this.serverUrl = this.makeServerUrl("http", socket_server_address, socket_server_port);
this.maxFileUploadSize = parseInt(max_upload_size);
} catch (error: any) {
this.setError("Can't connect to server. " + getErrorMessage(error));
@@ -2511,7 +2555,7 @@ export class PiPedalModel //implements PiPedalModel
}
}).catch((error) => {
// failed to subscribe.
this.vuSubscriptions[instanceId] = undefined;
this.vuSubscriptions[instanceId] = undefined;
});
this.vuSubscriptions[instanceId] = newTarget;
@@ -2866,6 +2910,10 @@ export class PiPedalModel //implements PiPedalModel
this.webSocket?.send("cancelListenForMidiEvent", listenHandle._handle);
}
displayMediaFile(filePath: string) {
let url = this.varServerUrl + "displayMediaFile?path=" + encodeURIComponent(filePath);
window.open(url, "_blank");
}
downloadAudioFile(filePath: string) {
let downloadUrl = this.varServerUrl + "downloadMediaFile?path=" + encodeURIComponent(filePath);
@@ -3803,6 +3851,21 @@ export class PiPedalModel //implements PiPedalModel
this.webSocket.send("setChannelRouterSettings", settings);
}
}
DownloadModelsFromTone3000(
responseUri: string,
tone3000PckceParams: Tone3000PkceParams,
downloadPath: string,
downloadType: Tone3000DownloadType
): void {
if (this.webSocket) {
this.webSocket.send("DownloadModelsFromTone3000", {
responseUri: responseUri,
tone3000PckceParams: tone3000PckceParams,
downloadPath: downloadPath,
downloadType: downloadType === Tone3000DownloadType.Nam ? 0: 1
});
}
}
};
+3 -1
View File
@@ -68,6 +68,7 @@ export function safeFilenameEncode(filename: string): string {
let result = "";
for (let i = 0; i < filename.length; i++) {
let c = filename[i];
let charCode = filename.charCodeAt(i);
// Handle UTF-16 surrogate pairs
@@ -98,7 +99,8 @@ export function safeFilenameEncode(filename: string): string {
}
// Handle control characters (< 0x20)
if (charCode < 0x20) {
if (charCode < 0x20 || c === '/' || c === '\\' || c === ':')
{
result += '%' + charCode.toString(16).padStart(2, '0');
}
// Handle ASCII printable characters (0x20-0x7F), space is NOT encoded
+4 -181
View File
@@ -1,5 +1,5 @@
import { JSX } from "@emotion/react/jsx-dev-runtime";
import { PiPedalModel, State, getErrorMessage } from "./PiPedalModel";
import { PiPedalModel, State } from "./PiPedalModel";
import { Button, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import LinearProgress from "@mui/material/LinearProgress";
@@ -7,189 +7,9 @@ import Tone3000DownloadProgress from "./Tone3000DownloadProgress";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Tone3000DownloadType from "./Tone3000DownloadType";
export type OnTone3000DownloadHandler = (tone3000DownloadUrl: string) => void;
// used to check for online status.
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
export class Tone3000DownloadHandler {
private async handleTone3000Download(tone3000DownloadUrl: string, downloadType: Tone3000DownloadType): Promise<void> {
try {
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType);
return;
} catch (e) {
this.handleTone3000DownloadError(getErrorMessage(e));
return;
}
}
private messageEventListener: ((event: MessageEvent) => void) | null = null;
private model: PiPedalModel;
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
public constructor(model: PiPedalModel) {
this.model = model;
this.messageEventListener = (event: MessageEvent) => {
if (event.data.type !== "tone3000Download") {
return;
}
this.handleTone3000Download(event.data.toneUrl as string, this.downloadType);
};
window.addEventListener("message", this.messageEventListener);
}
public dispose(): void {
if (this.messageEventListener) {
window.removeEventListener("message", this.messageEventListener);
this.messageEventListener = null;
}
}
private popupWindow: Window | null = null;
private appId: string = "pipedal_app4";
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}/public/handleTone3000download.html`;
}
private tone3000SelectUrl(downloadType: Tone3000DownloadType): string {
let redirectUrl_ = this.redirectUrl();
let result = `https://www.tone3000.com/api/v1/select?app_id=${this.appId}&redirect_url=${redirectUrl_}`;
if (downloadType === Tone3000DownloadType.CabIr) {
result += "&platform=ir";
} else if (downloadType === Tone3000DownloadType.Nam) {
result += "&platform=nam";
}
return result;
}
private handleTone3000DownloadComplete() {
if (this.popupWindow) {
if (!this.popupWindow.closed) {
this.popupWindow.close();
}
this.popupWindow = null;
}
this.model.hideTone3000DownloadStatus();
}
closeTone3000Popup() {
this.handleTone3000DownloadComplete();
}
private handleTone3000DownloadError(errorMessage: string) {
if (this.open)
{
this.handleTone3000DownloadComplete();
this.model.showAlert(errorMessage);
}
}
private downloadPath: string = "";
private open = false;
private launchInstance = 0;
public async launchTone3000Popup(
downloadType: Tone3000DownloadType,
downloadPath: string,
): Promise<void> {
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.open = true;
this.model.showTone3000DownloadStatus();
let launchInstance = ++this.launchInstance;
let cancelled = () => {
return (launchInstance !== this.launchInstance) || this.popupWindow == null || this.popupWindow.closed;
}
try {
this.popupWindow =
window.open(
this.tone3000SelectUrl(downloadType),
"popup",
`innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes`
);
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));
}
}
public closeTone3000Dialog(): void {
this.handleTone3000DownloadComplete();
}
}
export function Tone3000DownloadStaus(
@@ -225,6 +45,9 @@ export function Tone3000DownloadStaus(
if (filename === "") {
filename = "\u00A0";
}
if (progress && progress.total !== 0) {
filename = `(${progress.progress}/${progress.total}) ${filename}`;
}
return (
<Dialog
open={open}
+18 -5
View File
@@ -22,9 +22,22 @@
*/
export default interface Tone3000DownloadProgress {
handle: number;
title: string;
progress: number;
total: number;
export default class Tone3000DownloadProgress {
clone() {
let copy = new Tone3000DownloadProgress();
copy.handle = this.handle;
copy.title = this.title;
copy.progress = this.progress;
copy.total = this.total;
copy.cancelled = this.cancelled;
copy.transferring = this.transferring;
return copy;
}
handle: number = 0;
title: string = ""
progress: number = 0;
total: number = 0;
cancelled: boolean = false;
transferring: boolean = false;
};
+2 -2
View File
@@ -1,8 +1,8 @@
// Must agree with Tone3000DownloadType.hpp
enum Tone3000DownloadType {
Nam = "nam",
CabIr = "cabir",
Nam = 0,
CabIr = 1,
}
+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();
}
}
}
}
+222
View File
@@ -0,0 +1,222 @@
import { PiPedalModel, getErrorMessage, Tone3000PkceParams } from "./PiPedalModel";
import Tone3000DownloadType from "./Tone3000DownloadType";
import { PkceParams, buildPkceParams } from "./t3k/tone3000-client.ts";
import { Platform } from "./t3k/types.ts";
import { PUBLISHABLE_KEY } from './t3k/config.ts';
import { startSelectFlowPopup} from "./t3k/tone3000-client.ts";
// const T3K_API = "https://www.tone3000.com";
// used to check for online status.
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
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 model: PiPedalModel;
pkceParams: PkceParams | null = null;
public constructor(model: PiPedalModel) {
this.model = model;
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)
.then(() => { })
.catch((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,
): 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.model.DownloadModelsFromTone3000(uri, tone3000PckceParams, this.downloadPath, this.downloadType);
}
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}/t3k_response.html`;
}
private handleTone3000DownloadComplete() {
if (this.popupWindow) {
if (!this.popupWindow.closed) {
this.popupWindow.close();
}
this.popupWindow = null;
}
this.model.hideTone3000DownloadStatus();
}
closeTone3000Popup() {
this.handleTone3000DownloadComplete();
}
private handleTone3000DownloadError(errorMessage: string) {
if (this.open) {
this.handleTone3000DownloadComplete();
this.model.showAlert(errorMessage);
}
}
private downloadPath: string = "";
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
private open = false;
private launchInstance = 0;
public async launchTone3000Popup(
downloadType: Tone3000DownloadType,
downloadPath: string,
): Promise<void> {
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.open = true;
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));
}
}
public closeTone3000Dialog(): void {
this.handleTone3000DownloadComplete();
}
}
+20
View File
@@ -0,0 +1,20 @@
// config.ts — TONE3000 API configuration
// T3K_API points to production. VITE_T3K_API_DOMAIN can override for development.
// Trailing slashes are stripped so `${T3K_API}/api/...` never produces a double slash —
// Vercel 308-redirects double-slash paths, and redirects drop CORS headers.
export const T3K_API = (
(import.meta.env.VITE_T3K_API_DOMAIN as string | undefined) ?? 'https://www.tone3000.com'
).replace(/\/+$/, '');
export const PUBLISHABLE_KEY = import.meta.env.VITE_PUBLISHABLE_KEY as string;
// Per-demo keys — fall back to the shared PUBLISHABLE_KEY for local dev
export const PUBLISHABLE_KEY_SELECT =
(import.meta.env.VITE_PUBLISHABLE_KEY_SELECT as string | undefined) ?? PUBLISHABLE_KEY;
export const PUBLISHABLE_KEY_LOAD =
(import.meta.env.VITE_PUBLISHABLE_KEY_LOAD as string | undefined) ?? PUBLISHABLE_KEY;
export const PUBLISHABLE_KEY_FULL =
(import.meta.env.VITE_PUBLISHABLE_KEY_FULL as string | undefined) ?? PUBLISHABLE_KEY;
export const REDIRECT_URI =
(import.meta.env.VITE_REDIRECT_URI as string | undefined) ?? 'http://localhost:3001';
+713
View File
@@ -0,0 +1,713 @@
/**
* tone3000-client.ts — TONE3000 OAuth + API client
*
* A zero-dependency helper for integrating with the TONE3000 API.
* Uses built-in WebCrypto and fetch — no npm install required.
*
* Quick start:
* 1. Import the flow initiator for your use case
* 2. Call it when the user triggers the integration (e.g. clicks "Browse Tones")
* 3. In your callback handler, call handleOAuthCallback()
* 4. Use T3KClient to make authenticated API requests
*/
import { T3K_API } from './config';
import type {
User, Tone, Model, PublicUser,
PaginatedResponse, SearchTonesParams, ListUsersParams,
} from './types';
// ─── Public types ─────────────────────────────────────────────────────────────
export interface T3KTokens {
access_token: string;
refresh_token: string;
/** Unix timestamp (ms) when the access token expires. */
expires_at: number;
}
/** Result of handleOAuthCallback(). Always check `ok` before using fields. */
export type OAuthCallbackResult =
| { ok: true; tokens: T3KTokens; toneId?: string; modelId?: string; canceled?: boolean }
| { ok: false; error: string };
// ─── Internal PKCE helpers ────────────────────────────────────────────────────
async function randomBase64url(bytes: number): Promise<string> {
const buf = crypto.getRandomValues(new Uint8Array(bytes));
return btoa(String.fromCharCode(...buf))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function sha256Base64url(input: string): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
export interface PkceParams {
codeVerifier: string;
codeChallenge: string;
state: string;
}
export async function buildPkceParams(): Promise<PkceParams> {
const codeVerifier = await randomBase64url(32);
const [codeChallenge, state] = await Promise.all([
sha256Base64url(codeVerifier),
randomBase64url(16),
]);
sessionStorage.setItem('t3k_code_verifier', codeVerifier);
sessionStorage.setItem('t3k_state', state);
return { codeVerifier, codeChallenge, state };
}
function buildAuthorizeUrl(
publishableKey: string,
redirectUri: string,
extra: Record<string, string>,
pkce: { codeChallenge: string; state: string }
): string {
const url = new URL(`${T3K_API}/api/v1/oauth/authorize`);
url.searchParams.set('client_id', publishableKey);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('code_challenge', pkce.codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', pkce.state);
for (const [k, v] of Object.entries(extra)) url.searchParams.set(k, v);
return url.toString();
}
// ─── Flow initiators ──────────────────────────────────────────────────────────
/**
* **Select Flow** — Send the user to TONE3000 to browse and pick a tone.
*
* Use this when your app wants to let users discover tones from the TONE3000
* catalog. After the user selects a tone, they're redirected back to your app
* with an authorization code and the selected `tone_id`.
*
* @param gears - Optional underscore-separated gear filter (e.g. 'amp_pedal')
* @param platform - Optional platform filter (e.g. 'nam', 'aida-x')
*/
export async function startSelectFlow(
publishableKey: string,
redirectUri: string,
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
): Promise<void> {
const pkce = await buildPkceParams();
const extra: Record<string, string> = { prompt: 'select_tone' };
if (options?.gears) extra.gears = options.gears;
if (options?.platform) extra.platform = options.platform;
if (options?.menubar) extra.menubar = 'true';
if (options?.loginHint) extra.login_hint = options.loginHint;
window.location.href = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
}
/**
* **Select Flow (Popup)** — Open TONE3000 tone browsing and selection in a popup window.
*
* Same as `startSelectFlow` but opens in a popup. The user stays on your app while
* browsing TONE3000. When a tone is selected, the popup relays the result back via
* `postMessage` or `BroadcastChannel` — handle it with `handleOAuthCallbackFromPopup`.
*/
export async function startSelectFlowPopup(
pkce: PkceParams,
publishableKey: string,
redirectUri: string,
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string, architecture?: number
width?: number, height?: number
}
): Promise<Window | null> {
// Set before window.open so the popup inherits this flag via sessionStorage copy;
// remove it from the parent immediately so only the popup retains it.
sessionStorage.setItem('t3k_popup_mode', '1');
const extra: Record<string, string> = { prompt: 'select_tone' };
if (options?.gears) extra.gears = options.gears;
if (options?.platform) extra.platform = options.platform;
if (options?.menubar) extra.menubar = 'true';
if (options?.loginHint) extra.login_hint = options.loginHint;
if (options?.architecture) extra.architecture = options.architecture.toString();
const url = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
const width = options?.width ?? 480;
const height = options?.height ?? 700;
const left = Math.round(window.screenX + (window.outerWidth - width) / 2);
const top = Math.round(window.screenY + (window.outerHeight - height) / 2);
const popup = window.open(url, 't3k_select', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no,resizable=yes,scrollbars=yes`);
sessionStorage.removeItem('t3k_popup_mode');
return popup;
}
/**
* Handle an OAuth callback relayed from a select popup.
*
* Pass events from both a `message` listener and a `BroadcastChannel('t3k_oauth')`
* listener to this function. Returns `null` if the event is not a TONE3000 callback.
* Verifies state, exchanges the code for tokens, and returns the same result shape
* as `handleOAuthCallback`.
*/
export async function handleOAuthCallbackFromPopup(
publishableKey: string,
redirectUri: string,
event: MessageEvent
): Promise<OAuthCallbackResult | null> {
if (event.data?.type !== 't3k_oauth_callback') return null;
const { code, state: returnedState, error, tone_id: toneId, model_id: modelId, canceled } = event.data;
const storedState = sessionStorage.getItem('t3k_state');
const codeVerifier = sessionStorage.getItem('t3k_code_verifier');
sessionStorage.removeItem('t3k_state');
sessionStorage.removeItem('t3k_code_verifier');
if (returnedState !== storedState) return { ok: false, error: 'state_mismatch' };
// User closed without signing in — no code to exchange
if (canceled && !code) return { ok: false, error: 'canceled' };
if (error) return { ok: false, error };
if (!code || !codeVerifier) return { ok: false, error: 'missing_code' };
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
client_id: publishableKey,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return { ok: false, error: (err as any).error ?? 'token_exchange_failed' };
}
const data = await res.json();
const tokens: T3KTokens = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000,
};
return { ok: true, tokens, toneId, modelId, ...(canceled ? { canceled: true } : {}) };
}
/**
* **Load Tone Flow** — Send the user to TONE3000 to authenticate and load a specific tone.
*
* Use this when your app already has a `tone_id` and wants to ensure the user
* is authenticated and has access to that tone. TONE3000 handles the auth check
* and redirects back immediately — no tone browsing required.
*
* If the tone is private or has been deleted, TONE3000 shows an error page
* where the user can browse for a replacement. In that case, the `tone_id` in
* the callback may differ from the one you requested. Any `gears` or `platform`
* filters you pass are applied to that replacement browse view.
*
* @param gears - Optional underscore-separated gear filter (e.g. 'amp_full-rig')
* @param platform - Optional platform filter (e.g. 'nam', 'aida-x')
*/
export async function startLoadToneFlow(
publishableKey: string,
redirectUri: string,
toneId: number | string,
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
): Promise<void> {
const pkce = await buildPkceParams();
const extra: Record<string, string> = { prompt: 'load_tone', tone_id: String(toneId) };
if (options?.gears) extra.gears = options.gears;
if (options?.platform) extra.platform = options.platform;
if (options?.menubar) extra.menubar = 'true';
if (options?.loginHint) extra.login_hint = options.loginHint;
window.location.href = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
}
/**
* **Load Tone Flow (Popup)** — Open TONE3000 in a popup to authenticate and load a specific tone.
*
* Same as `startLoadToneFlow` but opens in a popup. When the flow completes, the
* popup relays the result back via `postMessage` or `BroadcastChannel` — handle it
* with `handleOAuthCallbackFromPopup`. Any `gears` or `platform` filters you pass
* are applied if the user needs to browse for a replacement tone.
*/
export async function startLoadToneFlowPopup(
publishableKey: string,
redirectUri: string,
toneId: number | string,
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
): Promise<Window | null> {
// Set before window.open so the popup inherits this flag via sessionStorage copy;
// remove it from the parent immediately so only the popup retains it.
sessionStorage.setItem('t3k_popup_mode', '1');
const pkce = await buildPkceParams();
const extra: Record<string, string> = { prompt: 'load_tone', tone_id: String(toneId) };
if (options?.gears) extra.gears = options.gears;
if (options?.platform) extra.platform = options.platform;
if (options?.menubar) extra.menubar = 'true';
if (options?.loginHint) extra.login_hint = options.loginHint;
const url = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
const width = 480;
const height = 700;
const left = Math.round(window.screenX + (window.outerWidth - width) / 2);
const top = Math.round(window.screenY + (window.outerHeight - height) / 2);
const popup = window.open(url, 't3k_load_tone', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no,resizable=yes,scrollbars=yes`);
sessionStorage.removeItem('t3k_popup_mode');
return popup;
}
/**
* **Load Tone Flow (Popup, model_id variant)** — Open TONE3000 in a popup to
* authenticate and load a tone resolved from a specific model.
*/
export async function startLoadToneFlowPopupByModelId(
publishableKey: string,
redirectUri: string,
modelId: number | string,
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
): Promise<Window | null> {
sessionStorage.setItem('t3k_popup_mode', '1');
const pkce = await buildPkceParams();
const extra: Record<string, string> = { prompt: 'load_tone', model_id: String(modelId) };
if (options?.gears) extra.gears = options.gears;
if (options?.platform) extra.platform = options.platform;
if (options?.menubar) extra.menubar = 'true';
if (options?.loginHint) extra.login_hint = options.loginHint;
const url = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
const width = 480;
const height = 700;
const left = Math.round(window.screenX + (window.outerWidth - width) / 2);
const top = Math.round(window.screenY + (window.outerHeight - height) / 2);
const popup = window.open(url, 't3k_load_tone', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no,resizable=yes,scrollbars=yes`);
sessionStorage.removeItem('t3k_popup_mode');
return popup;
}
/**
* **Load Model Flow** — Send the user to TONE3000 to authenticate and load a specific model.
*
* Use this when your app has a `model_id` and wants to load that exact model.
* Unlike the Load Tone flow, if the model is inaccessible, TONE3000 redirects
* back to your app with `error=access_denied` rather than offering a replacement.
* Your callback handler must check for this error.
*/
export async function startLoadModelFlow(
publishableKey: string,
redirectUri: string,
modelId: number | string
): Promise<void> {
const pkce = await buildPkceParams();
window.location.href = buildAuthorizeUrl(
publishableKey, redirectUri,
{ prompt: 'load_model', model_id: String(modelId) },
pkce
);
}
/**
* **Standard Flow** — Send the user to TONE3000 to connect their account.
*
* Use this when your app wants long-lived access to the TONE3000 API without
* having the user browse or select a tone during auth. After connecting, your
* app can fetch any tone by ID using the access token.
*/
export async function startStandardFlow(
publishableKey: string,
redirectUri: string,
options?: { loginHint?: string }
): Promise<void> {
const pkce = await buildPkceParams();
const extra: Record<string, string> = {};
if (options?.loginHint) extra.login_hint = options.loginHint;
window.location.href = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
}
/**
* **LAN-relay Flow** — For headless devices on a LAN. The "device" (here, the
* laptop's Vite dev server) opens an HTTP listener at an RFC1918 address; the
* user scans a QR with their phone, completes auth in the phone browser, and
* the OAuth code lands at the device's LAN listener via tone3000's bridge.
*
* This helper only generates the authorize URL — actually receiving the
* callback requires a real LAN listener (see vite-plugin-lan-bridge.ts in this
* repo for the dev-time implementation, or your device firmware in
* production). PKCE state is stored in sessionStorage as with the other
* flows; pair this call with `exchangeCode()` once the listener captures
* code+state.
*
* @param lanCallbackUri The redirect_uri the device's listener will receive.
* Must be `http://` to RFC1918 / link-local
* (10/8, 172.16-31, 192.168/16, 169.254/16).
*/
export async function startLanRelayFlow(
publishableKey: string,
lanCallbackUri: string,
): Promise<{ authorizeUrl: string; state: string }> {
const pkce = await buildPkceParams();
const authorizeUrl = buildAuthorizeUrl(publishableKey, lanCallbackUri, {}, pkce);
return { authorizeUrl, state: pkce.state };
}
/**
* Exchange an authorization code for tokens. Used by `handleOAuthCallback`
* (URL-driven callbacks) and by the LAN-relay demo (callbacks that arrive via
* the LAN listener and are forwarded to the React UI by the dev plugin).
*
* Verifies that `returnedState` matches the value `buildPkceParams()` stored
* in sessionStorage, then redeems the code with the verifier. The PKCE
* values are cleared from sessionStorage regardless of outcome.
*/
export async function exchangeCode(
publishableKey: string,
redirectUri: string,
code: string,
returnedState: string,
): Promise<OAuthCallbackResult> {
const storedState = sessionStorage.getItem('t3k_state');
const codeVerifier = sessionStorage.getItem('t3k_code_verifier');
sessionStorage.removeItem('t3k_state');
sessionStorage.removeItem('t3k_code_verifier');
if (returnedState !== storedState) return { ok: false, error: 'state_mismatch' };
if (!codeVerifier) return { ok: false, error: 'missing_verifier' };
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
client_id: publishableKey,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return { ok: false, error: (err as { error?: string }).error ?? 'token_exchange_failed' };
}
const data = await res.json();
return {
ok: true,
tokens: {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000,
},
};
}
// ─── Callback handler ─────────────────────────────────────────────────────────
/**
* Handle the OAuth callback after TONE3000 redirects back to your app.
*
* Call this once when your callback page loads and detects a `?code=` or
* `?error=` query parameter. It verifies the state, exchanges the code for
* tokens, and returns a typed result object.
*
* Always check `result.ok` before using the tokens. A `result.ok === false`
* with `error === 'access_denied'` is expected for the Load Model flow when
* the model is private — handle it by showing the user an appropriate error UI.
*/
export async function handleOAuthCallback(
publishableKey: string,
redirectUri: string,
responseUri: string = window.location.href
): Promise<OAuthCallbackResult> {
// Make URLSearchParams from the responseUri (not necessarily window.location) to support LAN-relay flow where the code lands at a different URL
const url = new URL(responseUri);
const params = new URLSearchParams(url.search);
const code = params.get('code');
const error = params.get('error');
const returnedState = params.get('state');
const toneId = params.get('tone_id') ?? undefined;
const modelId = params.get('model_id') ?? undefined;
const canceled = params.get('canceled') === 'true';
const storedState = sessionStorage.getItem('t3k_state');
const codeVerifier = sessionStorage.getItem('t3k_code_verifier');
// Clean up PKCE state regardless of outcome
sessionStorage.removeItem('t3k_state');
sessionStorage.removeItem('t3k_code_verifier');
// Verify state to prevent CSRF
if (returnedState !== storedState) {
return { ok: false, error: 'state_mismatch' };
}
// User closed without signing in — no code to exchange
if (canceled && !code) {
return { ok: false, error: 'canceled' };
}
// Access denied — e.g. model is private and user clicked "Back"
if (error) {
return { ok: false, error };
}
if (!code || !codeVerifier) {
return { ok: false, error: 'missing_code' };
}
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
client_id: publishableKey,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return { ok: false, error: (err as any).error ?? 'token_exchange_failed' };
}
const data = await res.json();
const tokens: T3KTokens = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000,
};
return { ok: true, tokens, toneId, modelId, ...(canceled ? { canceled: true } : {}) };
}
// ─── Token refresh ────────────────────────────────────────────────────────────
/** Exchange a refresh token for a new access token. */
export async function refreshTokens(
refreshToken: string,
publishableKey: string
): Promise<T3KTokens> {
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: publishableKey,
}),
});
if (!res.ok) throw new Error('Token refresh failed');
const data = await res.json();
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000,
};
}
// ─── Authenticated API client ─────────────────────────────────────────────────
const STORAGE_KEY = 't3k_tokens';
/**
* T3KClient — Authenticated API client with automatic token refresh.
*
* Create one instance at module scope. Tokens are stored in sessionStorage
* by default — they survive page refreshes within a tab but are cleared when
* the tab closes. For cross-session persistence without re-auth, store the
* refresh token server-side and call POST /api/v1/oauth/token on page load.
*
* @param publishableKey - Your `t3k_pub_` key (same as `client_id` in OAuth)
* @param onAuthRequired - Called when tokens are missing or expired beyond refresh.
* Typically you'd call startStandardFlow() here to silently
* re-authenticate (the user won't see a login screen if
* they still have an active TONE3000 session).
*/
export class T3KClient {
private refreshPromise: Promise<T3KTokens> | null = null;
constructor(
private readonly publishableKey: string,
private readonly onAuthRequired: () => void
) {}
setTokens(tokens: T3KTokens): void {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
}
getTokens(): T3KTokens | null {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as T3KTokens) : null;
}
clearTokens(): void {
sessionStorage.removeItem(STORAGE_KEY);
}
isConnected(): boolean {
return this.getTokens() !== null;
}
async getAccessToken(): Promise<string> {
const tokens = this.getTokens();
if (!tokens) {
this.onAuthRequired();
throw new Error('Not authenticated');
}
// Proactively refresh 60 s before expiry to avoid mid-request failures
if (Date.now() > tokens.expires_at - 60_000) {
if (!this.refreshPromise) {
this.refreshPromise = refreshTokens(tokens.refresh_token, this.publishableKey)
.then((t) => { this.setTokens(t); this.refreshPromise = null; return t; })
.catch((err) => {
this.clearTokens();
this.refreshPromise = null;
this.onAuthRequired();
throw err;
});
}
return (await this.refreshPromise).access_token;
}
return tokens.access_token;
}
/** Make an authenticated request to the TONE3000 API. */
async fetch(path: string, init?: RequestInit): Promise<Response> {
const token = await this.getAccessToken();
const res = await globalThis.fetch(`${T3K_API}${path}`, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${token}` },
});
// Retry once on 401 — handles expiry race conditions between refresh check and request
if (res.status === 401) {
const stored = this.getTokens();
if (stored) {
this.setTokens({ ...stored, expires_at: 0 }); // force a refresh on next call
const retryToken = await this.getAccessToken();
return globalThis.fetch(`${T3K_API}${path}`, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${retryToken}` },
});
}
}
return res;
}
// ── Resource methods ──────────────────────────────────────────────────────────
/** Get the authenticated user's profile. */
async getUser(): Promise<User> {
const res = await this.fetch('/api/v1/user');
if (!res.ok) throw new Error(`getUser failed: ${res.status}`);
return res.json();
}
/**
* Get a tone by ID. Returns tone metadata only — models are not embedded.
* To get download URLs, call `listModels(tone.id)` after fetching the tone.
*/
async getTone(id: number | string): Promise<Tone> {
const res = await this.fetch(`/api/v1/tones/${id}`);
if (!res.ok) throw new Error(`getTone failed: ${res.status}`);
return res.json();
}
/** Get a model by ID. */
async getModel(id: number | string): Promise<Model> {
const res = await this.fetch(`/api/v1/models/${id}`);
if (!res.ok) throw new Error(`getModel failed: ${res.status}`);
return res.json();
}
/** Search and filter the TONE3000 tone catalog. */
async searchTones(params?: SearchTonesParams): Promise<PaginatedResponse<Tone>> {
const qs = new URLSearchParams();
if (params?.query) qs.set('query', params.query);
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.sort) qs.set('sort', params.sort);
if (params?.gears?.length) qs.set('gears', params.gears.join('_'));
if (params?.sizes?.length) qs.set('sizes', params.sizes.join('_'));
const res = await this.fetch(`/api/v1/tones/search?${qs}`);
if (!res.ok) throw new Error(`searchTones failed: ${res.status}`);
return res.json();
}
/** Get tones created by the authenticated user. */
async listCreatedTones(page = 1, pageSize = 10): Promise<PaginatedResponse<Tone>> {
const res = await this.fetch(`/api/v1/tones/created?page=${page}&page_size=${pageSize}`);
if (!res.ok) throw new Error(`listCreatedTones failed: ${res.status}`);
return res.json();
}
/** Get tones favorited by the authenticated user. */
async listFavoritedTones(page = 1, pageSize = 10): Promise<PaginatedResponse<Tone>> {
const res = await this.fetch(`/api/v1/tones/favorited?page=${page}&page_size=${pageSize}`);
if (!res.ok) throw new Error(`listFavoritedTones failed: ${res.status}`);
return res.json();
}
/** List models for a tone. */
async listModels(toneId: number | string, page = 1, pageSize = 10, architecture?: number): Promise<PaginatedResponse<Model>> {
let uri = `/api/v1/models?tone_id=${toneId}&page=${page}&page_size=${pageSize}`;
if (architecture != undefined) {
uri += `&architecture=${architecture.toString()}`;
}
if (architecture !== undefined) {
uri += `&architecture=${architecture}`;
}
const res = await this.fetch(uri);
if (!res.ok) throw new Error(`listModels failed: ${res.status}`);
return res.json();
}
/** Get public users, sortable by activity metrics. */
async listUsers(params?: ListUsersParams): Promise<PaginatedResponse<PublicUser>> {
const qs = new URLSearchParams();
if (params?.sort) qs.set('sort', params.sort);
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.query) qs.set('query', params.query);
const res = await this.fetch(`/api/v1/users?${qs}`);
if (!res.ok) throw new Error(`listUsers failed: ${res.status}`);
return res.json();
}
/**
* Download a model file and trigger a browser file download.
* The `model_url` from the API must be fetched with Bearer auth — use this
* method rather than calling fetch(model_url) directly.
*/
async downloadModel(modelUrl: string, name: string): Promise<void> {
// Strip the base URL so client.fetch() can prepend T3K_API + auth header
const path = modelUrl.replace(T3K_API, '');
const res = await this.fetch(path);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
// Use the extension from the storage URL with a human-readable name
const storageFilename = new URL(modelUrl).pathname.split('/').pop() ?? '';
const ext = storageFilename.includes('.') ? '.' + storageFilename.split('.').pop() : '';
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const filename = sanitized + ext;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url);
}
}
+152
View File
@@ -0,0 +1,152 @@
// types.ts — TONE3000 API type definitions
export type Demo = 'select' | 'load-tone' | 'load-model' | 'full-api' | 'lan-flow';
export enum Gear {
Amp = 'amp',
FullRig = 'full-rig',
Pedal = 'pedal',
Outboard = 'outboard',
Ir = 'ir',
}
export enum Platform {
Nam = 'nam',
Ir = 'ir',
AidaX = 'aida-x',
AaSnapshot = 'aa-snapshot',
Proteus = 'proteus',
}
export enum License {
T3k = 't3k',
CcBy = 'cc-by',
CcBySa = 'cc-by-sa',
CcByNc = 'cc-by-nc',
CcByNcSa = 'cc-by-nc-sa',
CcByNd = 'cc-by-nd',
CcByNcNd = 'cc-by-nc-nd',
Cco = 'cco',
}
export enum Size {
Standard = 'standard',
Lite = 'lite',
Feather = 'feather',
Nano = 'nano',
Custom = 'custom',
}
export enum TonesSort {
BestMatch = 'best-match',
Newest = 'newest',
Oldest = 'oldest',
Trending = 'trending',
DownloadsAllTime = 'downloads-all-time',
}
export enum UsersSort {
Tones = 'tones',
Downloads = 'downloads',
Favorites = 'favorites',
Models = 'models',
}
export interface EmbeddedUser {
id: string;
username: string;
avatar_url: string | null;
url: string;
}
export interface User extends EmbeddedUser {
bio: string | null;
links: string[] | null;
created_at: string;
updated_at: string;
}
export interface PublicUser {
id: number;
username: string;
bio: string | null;
links: string[] | null;
avatar_url: string | null;
downloads_count: number;
favorites_count: number;
models_count: number;
tones_count: number;
url: string;
}
export interface Make {
id?: number; // absent when returned via RPC (search results)
name: string;
}
export interface Tag {
id?: number; // absent when returned via RPC (search results)
name: string;
}
export interface Tone {
id: number;
user_id: string;
user: EmbeddedUser;
created_at?: string; // absent from GET /tones/{id} — present on search/created/favorited
updated_at?: string; // absent from GET /tones/{id} — present on search/created/favorited
title: string;
description: string | null;
gear: Gear;
images: string[] | null;
is_public: boolean | null;
links: string[] | null;
platform: Platform;
license: License;
sizes: Size[];
makes: Make[];
tags: Tag[];
models_count: number;
a1_models_count: number | undefined;
a2_models_count: number | undefined;
irs_count: number | undefined;
custom_models_count: number | undefined;
downloads_count: number;
favorites_count: number;
url: string;
}
export interface Model {
id: number;
created_at: string;
updated_at: string;
user_id: string;
model_url: string;
name: string;
size: Size;
tone_id: number;
}
export interface PaginatedResponse<T> {
data: T[];
page: number;
page_size: number;
total: number;
total_pages: number;
}
export interface SearchTonesParams {
query?: string;
page?: number;
pageSize?: number;
sort?: TonesSort;
gears?: Gear[];
sizes?: Size[];
}
export interface ListUsersParams {
sort?: UsersSort;
page?: number;
pageSize?: number;
query?: string;
}
+42
View File
@@ -0,0 +1,42 @@
//import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import React from 'react';
import './index.css'
// createRoot(document.getElementById('root')!).render(
// <StrictMode>
// <App />
// </StrictMode>,
// )
function ResponseComponent() {
React.useEffect(() => {
alert("yyy: Delete me");
debugger;
if (window.opener === null) {
console.error('No window.opener found for OAuth callback');
window.close();
return;
}
window.opener.postMessage(
{
type: "t3k_response",
uri: window.location.href,
storedState: sessionStorage.getItem('t3k_state'),
codeVerifier: sessionStorage.getItem('t3k_code_verifier')
}, '*'
);
}, []);
return <div style={{color: 'white'}}>Processing...</div>;
}
createRoot(document.getElementById('root')!).render(
<div>
<ResponseComponent />
</div>
)