Complete except for throttling bug.

This commit is contained in:
Robin E.R. Davies
2026-02-10 19:59:05 -05:00
parent aec88fa9f1
commit 22b63a32db
42 changed files with 2597 additions and 564 deletions
+2 -1
View File
@@ -1039,6 +1039,7 @@ export
defaultName={this.model_.banks.get().getSelectedEntryName()}
title="Bank Name"
acceptActionName={this.state.renameBankDialogOpen ? "Rename" : "Save as"}
useSafeFilenames={false}
onClose={() => {
this.setState({
renameBankDialogOpen: false,
@@ -1100,7 +1101,7 @@ export
</DialogActions>
</DialogEx>
{/* Status of Tone3000 download */}
{/* Status of TONE3000 download */}
<Tone3000DownloadStatus zindex={2000}/>
{/* Fatal error mask */}
<Modal open={this.state.displayState === State.Error}
+1 -66
View File
@@ -23,6 +23,7 @@
import { pathConcat, pathParentDirectory, pathFileName } from "./FileUtils";
import { PiPedalModel } from "./PiPedalModel";
import { safeFilenameDecode } from "./SafeFilename";
export class ThumbnailType {
static Unknown = 0;
@@ -38,72 +39,6 @@ function getVersionSuffix(filePath: string): string | null {
return match ? match[1] : null;
}
export function safeFilenameDecode(filename: string): string {
let originalFilename = filename;
let pos = filename.indexOf('%');
if (pos < 0) {
return filename;
}
let result = "";
while (true) {
if (pos < 0) {
result += filename;
return result;
}
result += filename.substring(0, pos);
if (pos+3 >= filename.length) {
// invalid encoding. just use the original
return originalFilename;
}
let hex = filename.charAt(pos+1) + filename.charAt(pos+2);
let byte = parseInt(hex, 16);
// Handle UTF-8 to UTF-16 conversion
if (byte < 0x80) {
// Single-byte character (ASCII)
result += String.fromCharCode(byte);
filename = filename.substring(pos+3);
} else if ((byte & 0xE0) === 0xC0) {
// 2-byte sequence
if (pos+6 > filename.length || filename.charAt(pos+3) !== '%') {
return originalFilename; // invalid encoding
}
let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16);
let codePoint = ((byte & 0x1F) << 6) | (byte2 & 0x3F);
result += String.fromCharCode(codePoint);
filename = filename.substring(pos+6);
} else if ((byte & 0xF0) === 0xE0) {
// 3-byte sequence
if (pos+9 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%') {
return originalFilename; // invalid encoding
}
let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16);
let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16);
let codePoint = ((byte & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F);
result += String.fromCharCode(codePoint);
filename = filename.substring(pos+9);
} else if ((byte & 0xF8) === 0xF0) {
// 4-byte sequence (surrogate pair needed)
if (pos+12 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%' || filename.charAt(pos+9) !== '%') {
return originalFilename; // invalid encoding
}
let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16);
let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16);
let byte4 = parseInt(filename.charAt(pos+10) + filename.charAt(pos+11), 16);
let codePoint = ((byte & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F);
// Convert to UTF-16 surrogate pair
codePoint -= 0x10000;
result += String.fromCharCode(0xD800 + (codePoint >> 10));
result += String.fromCharCode(0xDC00 + (codePoint & 0x3FF));
filename = filename.substring(pos+12);
} else {
// Invalid UTF-8 start byte
return originalFilename;
}
pos = filename.indexOf('%');
}
}
export function getTrackTitle(pathname: string, metadata: AudioFileMetadata | null | undefined): string {
if (!metadata) {
return safeFilenameDecode(pathFileName(pathname));
+1
View File
@@ -469,6 +469,7 @@ const BankDialog = withStyles(
title={this.state.filenameSaveAs ? "Save Bank As" : "Bank Name"}
acceptActionName={this.state.filenameSaveAs ? "SAVE AS" : "RENAME"}
onClose={() => { this.setState({ filenameDialogOpen: false }) }}
useSafeFilenames={false}
onOk={(text: string) => {
if (this.state.filenameSaveAs) {
this.handleSaveAsOk(text);
+1 -1
View File
@@ -34,7 +34,7 @@ import ButtonBase from '@mui/material/ButtonBase'
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import { PedalboardItem } from './Pedalboard';
import { isDarkMode } from './DarkMode';
import {safeFilenameDecode} from './AudioFileMetadata';
import {safeFilenameDecode} from './SafeFilename';
import { withStyles } from "tss-react/mui";
+34 -16
View File
@@ -67,11 +67,17 @@ import OkCancelDialog from './OkCancelDialog';
import HomeIcon from '@mui/icons-material/Home';
import FilePropertyDirectorySelectDialog from './FilePropertyDirectorySelectDialog';
import { getAlbumArtUri, getTrackTitle } from './AudioFileMetadata';
import { Tone3000DownloadDialog } from './Tone3000Dialog';
import Tone3000DownloadType from './Tone3000DownloadType';
const ToobNamModelFileUrl = "http://two-play.com/plugins/toob-nam#modelFile";
const ToobMlModelFileUrl = "http://two-play.com/plugins/toob-ml#modelFile";
const ToobCabIrFileUrls = [
"http://two-play.com/plugins/toob-cab-ir#impulseFile",
"http://two-play.com/plugins/toob-cab-ir#impulseFile2",
"http://two-play.com/plugins/toob-cab-ir#impulseFile3",
];
const AUTOSCROLL_TICK_DELAY = 30;
const AUTOSCROLL_THRESHOLD = 48;
@@ -171,7 +177,6 @@ export interface FilePropertyDialogState {
previousSelection: string;
multiSelect: boolean,
selectedFiles: string[],
openTone3000Dialog: boolean,
openTone3000Help: boolean,
openGuitarMlHelp: boolean,
@@ -249,7 +254,6 @@ export default withStyles(
previousSelection: this.props.selectedFile,
multiSelect: false,
selectedFiles: [],
openTone3000Dialog: false,
openTone3000Help: false,
openGuitarMlHelp: false
};
@@ -1086,7 +1090,21 @@ export default withStyles(
handleTone3000Dialog(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation();
e.preventDefault();
this.setState({ openTone3000Dialog: true });
// Popup launch MUST be done from javascript, not react to avoid default popup blockers in browers.
// The dialog just provides a blocker dialog to cancel the popup if the TONE3000 Select APIs escape.
let downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
if (this.props.fileProperty.patchProperty === ToobNamModelFileUrl) {
downloadType = Tone3000DownloadType.Nam;
} else if (ToobCabIrFileUrls.includes(this.props.fileProperty.patchProperty))
{
downloadType = Tone3000DownloadType.CabIr;
} else {
throw new Error("Unsupported file property for Tone3000 dialog");
}
this.model.showTone3000DownloadPopup(
downloadType,
this.state.currentDirectory);
}
@@ -1110,6 +1128,7 @@ export default withStyles(
render() {
const isTracksDirectory = this.isTracksDirectory();
const isToobNamModelFile = this.props.fileProperty.patchProperty === ToobNamModelFileUrl;
const isToobCabIrFile = ToobCabIrFileUrls.includes(this.props.fileProperty.patchProperty);
const isToobMLModelFile = this.props.fileProperty.patchProperty === ToobMlModelFileUrl;
const classes = withStyles.getClasses(this.props);
@@ -1532,7 +1551,7 @@ export default withStyles(
<>
<DialogActions style={{ justifyContent: "stretch", width: "100%" }}>
<div style={{ display: "flex", flexFlow: "column nowrap", width: "100%", alignItems: "stretch", }}>
{isToobNamModelFile && (
{(isToobNamModelFile || isToobCabIrFile) && (
<div style={{
display: "flex", flexFlow: "row nowrap", justifyContent: "center",
alignItems: "center", width: "100%"
@@ -1540,7 +1559,12 @@ export default withStyles(
<Button
onClick={(e) => { this.handleTone3000Dialog(e); }}
>
Download model files from Tone3000
{
isToobNamModelFile
? "Download model files from TONE3000"
: "Download I/R files from TONE3000"
}
</Button>
<IconButtonEx tooltip="Help"
onClick={(e) => { this.handleTone3000Help(e); }} aria-label="help" edge="end" color="inherit" style={{ opacity: 0.6, marginLeft: 8 }}
@@ -1711,6 +1735,7 @@ export default withStyles(
onClose={() => { this.setState({ newFolderDialogOpen: false }); }}
title="New folder"
acceptActionName="OK"
useSafeFilenames={true}
/>
)
}
@@ -1721,6 +1746,7 @@ export default withStyles(
onOk={(newName) => { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }}
onClose={() => { this.setState({ renameDialogOpen: false }); }}
acceptActionName="OK"
useSafeFilenames={true}
/>
)
@@ -1733,6 +1759,7 @@ export default withStyles(
open={this.state.moveDialogOpen || this.state.copyDialogOpen}
dialogTitle={this.state.moveDialogOpen ? "Move to" : "Copy Link to"}
uiFileProperty={this.props.fileProperty}
useSafeFilenames={true}
defaultPath={this.getDefaultPath()}
selectedFile={this.state.selectedFile}
excludeDirectory={
@@ -1755,20 +1782,11 @@ export default withStyles(
)
)
}
{this.state.openTone3000Dialog && (
<Tone3000DownloadDialog
onClose={() => this.setState({ openTone3000Dialog: false })}
downloadPath={this.state.currentDirectory}
onDownloadComplete={() => {
this.setState({ openTone3000Dialog: false });
this.requestFiles(this.state.navDirectory);
}}
/>
)}
{this.state.openTone3000Help && (
<Tone3000HelpDialog
open={this.state.openTone3000Help}
onClose={() => this.setState({ openTone3000Help: false })}
downloadType={isToobNamModelFile ? Tone3000DownloadType.Nam : Tone3000DownloadType.CabIr}
/>
)}
{this.state.openGuitarMlHelp && (
@@ -35,6 +35,7 @@ import FolderIcon from '@mui/icons-material/Folder';
import HomeIcon from '@mui/icons-material/Home';
import { isDarkMode } from './DarkMode';
import IconButton from '@mui/material/IconButton';
import {safeFilenameDecode} from './SafeFilename';
@@ -126,6 +127,7 @@ export interface FilePropertyDirectorySelectDialogProps {
defaultPath: string,
excludeDirectory: string,
selectedFile: string,
useSafeFilenames: boolean,
onOk: (path: string) => void,
onClose: () => void
};
@@ -260,6 +262,11 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC
selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)";
}
let showToggle = directoryTree.canExpand() && directoryTree.path.length !== 0;
let name = directoryTree.name === "" ? "Home" : directoryTree.name;
if (this.props.useSafeFilenames) {
name = safeFilenameDecode(name);
}
return (
<div key={directoryTree.path} style={{flexGrow: 1}}>
<div style={{ width: "100%", display: "flex", flexFlow: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "center" }}>
@@ -292,7 +299,7 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC
)
}
<Typography noWrap style={{ marginLeft: 8, marginTop: 8, marginBottom: 8, textAlign: "left", flexGrow: 1, flexShrink: 1 }} variant="body2">{directoryTree.name === "" ? "Home" : directoryTree.name}</Typography>
<Typography noWrap style={{ marginLeft: 8, marginTop: 8, marginBottom: 8, textAlign: "left", flexGrow: 1, flexShrink: 1 }} variant="body2">{name}</Typography>
</div>
</ButtonBase>
</div>
+3
View File
@@ -60,6 +60,9 @@ export class ObservableProperty<VALUE_TYPE> {
set(value: VALUE_TYPE) : void
{
if (this._value_type === value) {
return;
}
this._value_type = value;
let t = this._on_changed_handlers; // take an copy in case removes happen while iteratiing.
+61 -54
View File
@@ -28,6 +28,7 @@ import PluginClass from './PluginClass';
import ScreenOrientation from './ScreenOrientation';
import PiPedalSocket, { PiPedalMessageHeader } from './PiPedalSocket';
import {Tone3000DownloadHandler} from './Tone3000Dialog';
import Tone3000DownloadType from './Tone3000DownloadType';
import { nullCast } from './Utility'
import { JackConfiguration, JackChannelSelection } from './Jack';
import { BankIndex } from './Banks';
@@ -660,9 +661,23 @@ export class PiPedalModel //implements PiPedalModel
}
async pingTone3000Server() : Promise<boolean> {
if (this.webSocket === undefined) {
return false;
}
try {
let result = await this.webSocket.request<boolean>("pingTone3000Server", {});
return result;
} catch (error) {
console.log("Error pinging TONE3000 Server on PiPedal server: " + getErrorMessage(error));
return false;
}
}
async downloadModelsFromTone3000(
tone3000DownloadUrl: string,
downloadPath: string
downloadPath: string,
downloadType: Tone3000DownloadType
): Promise<void> {
try {
if (!this.webSocket) {
@@ -671,50 +686,59 @@ export class PiPedalModel //implements PiPedalModel
this.webSocket.send("downloadModelsFromTone3000",
{
downloadPath: downloadPath,
tone3000Url: tone3000DownloadUrl
tone3000Url: tone3000DownloadUrl,
downloadType: downloadType
});
} catch (error) {
this.showAlert(getErrorMessage(error));
}
}
cancelTone3000Download(): void {
private cancelTone3000Download(): void {
let downloadProgress = this.tone3000DownloadProgress.get();
if (downloadProgress === null) {
return;
}
if (downloadProgress.handle !== -1) {
if (!this.webSocket) {
return;
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);
}
this.webSocket.send("cancelTone3000Download",downloadProgress.handle);
}
}
showTone3000DownloadDialog(
downloadPath: string,
onClosed: () => void,
onDownloadStarted: () => void,
onDownloadComplete: () => void
showTone3000DownloadPopup(
downloadType: Tone3000DownloadType,
downloadPath: string
): void {
if (this.tone3000DownloadHandler === null) {
this.tone3000DownloadHandler = new Tone3000DownloadHandler(this);
}
this.tone3000DownloadHandler.launchTone3000Dialog(
this.tone3000DownloadHandler.launchTone3000Popup(
downloadType,
downloadPath,
onClosed,
(errorMessage: string) => {
this.showAlert(errorMessage);
onClosed();
},
onDownloadStarted,
onDownloadComplete
);
};
closeTone3000DownloadDialog(): void {
showTone3000DownloadStatus(): void {
this.tone3000Downloading.set(true);
this.tone3000DownloadProgress.set(null);
console.log("Showing TONE3000 download status popup");
}
hideTone3000DownloadStatus(): void {
this.tone3000Downloading.set(false);
this.tone3000DownloadProgress.set(null);
console.log("Hiding TONE3000 download status popup");
}
closeTone3000DownloadPopup(): void {
this.cancelTone3000Download();
this.tone3000Downloading.set(false);
this.tone3000DownloadProgress.set(null);
if (this.tone3000DownloadHandler) {
this.tone3000DownloadHandler.closeTone3000Dialog();
this.tone3000DownloadHandler.closeTone3000Popup();
}
}
@@ -1057,7 +1081,7 @@ export class PiPedalModel //implements PiPedalModel
}
// Tone3000 download notification handlers (stub implementations)
// TONE3000 download notification handlers (stub implementations)
private onTone3000DownloadStarted(handle: number, title: string): void {
this.tone3000Downloading.set(true);
}
@@ -1069,12 +1093,14 @@ export class PiPedalModel //implements PiPedalModel
private onTone3000DownloadComplete(resultPath: string): void {
this.tone3000Downloading.set(false);
this.tone3000DownloadProgress.set(null);
this.onTone3000DownloadCompleteEvent.fire(resultPath);
}
private onTone3000DownloadError(handle: number, errorMessage: string): void {
console.error(`Tone3000 download error: handle=${handle}, error=${errorMessage}`);
console.error(`TONE3000 download error: handle=${handle}, error=${errorMessage}`);
this.tone3000Downloading.set(false);
this.tone3000DownloadProgress.set(null);
this.onTone3000DownloadCompleteEvent.fire("");
setTimeout(() => {
@@ -1090,9 +1116,12 @@ export class PiPedalModel //implements PiPedalModel
return "var/" + url;
}
setState(state: State) {
private setState(state: State) {
if (this.state.get() !== state) {
this.state.set(state);
if (state === State.Error) {
this.closeTone3000DownloadPopup();
}
}
}
@@ -1397,7 +1426,7 @@ export class PiPedalModel //implements PiPedalModel
m = message.toString();
}
this.errorMessage.set(m);
this.state.set(State.Error);
this.setState(State.Error);
}
@@ -1458,6 +1487,7 @@ export class PiPedalModel //implements PiPedalModel
return false;
}
initialize(): void {
this.setError("");
this.setState(State.Loading);
@@ -1465,7 +1495,7 @@ export class PiPedalModel //implements PiPedalModel
this.requestConfig()
.then((succeeded) => {
if (succeeded) {
this.state.set(State.Ready);
this.setState(State.Ready);
if (this.androidHost) {
this.hostVersion = new HostVersion(this.androidHost.getHostVersion());
if (!this.hostVersion.lessThan(1, 1, 16)) {
@@ -2857,29 +2887,6 @@ export class PiPedalModel //implements PiPedalModel
document.body.removeChild(link);
}
async uploadTone3000File(url: string) : Promise<boolean> {
try {
let response = await fetch(
url,
{
method: "GET",
}
);
let json = await response.json();
if (json.success === true) {
return true;
}
if (json.errorMessage !== undefined)
{
throw new Error(json.errorMessage);
}
throw new Error("Invalid server response.");
} catch (error) {
throw new Error("Upload to PiPedal server failed. " + getErrorMessage(error));
}
}
uploadUserFile(uploadPage: string, file: File, contentType: string = "application/octet-stream", abortController?: AbortController): Promise<string> {
let result = new Promise<string>((resolve, reject) => {
try {
@@ -338,6 +338,7 @@ const PluginPresetSelector =
title="Rename"
defaultName={this.state.renameDialogDefaultName}
acceptActionName={this.state.renameDialogActionName}
useSafeFilenames={false}
onClose={() => this.handleRenameDialogClose()}
onOk={(name: string) => this.handleRenameDialogOk(name)} />
</div>
+1
View File
@@ -416,6 +416,7 @@ const PluginPresetsDialog = withStyles(
title="Rename"
open={this.state.renameOpen}
defaultName={this.getSelectedName()}
useSafeFilenames={false}
acceptActionName={"Rename"}
onClose={() => { this.setState({ renameOpen: false }) }}
onOk={(text: string) => {
+1
View File
@@ -526,6 +526,7 @@ const PresetDialog = withStyles(
title="Rename"
open={this.state.renameOpen}
defaultName={this.getSelectedName()}
useSafeFilenames={false}
acceptActionName={"Rename"}
onClose={() => { this.setState({ renameOpen: false }) }}
onOk={(text: string) => {
+1
View File
@@ -467,6 +467,7 @@ const PresetSelector =
title={this.state.renameDialogTitle}
defaultName={this.state.renameDialogDefaultName}
acceptActionName={this.state.renameDialogActionName}
useSafeFilenames={false}
onClose={() => this.handleRenameDialogClose()}
onOk={(name: string) => this.handleRenameDialogOk(name)} />
<UploadPresetDialog
+21 -7
View File
@@ -28,10 +28,12 @@ import ResizeResponsiveComponent from './ResizeResponsiveComponent';
import {PiPedalModel,PiPedalModelFactory} from './PiPedalModel';
//import TextFieldEx from './TextFieldEx';
import TextField from '@mui/material/TextField';
import {safeFilenameEncode, safeFilenameDecode} from "./SafeFilename";
export interface RenameDialogProps {
open: boolean,
useSafeFilenames: boolean,
defaultName: string,
acceptActionName: string,
title: string,
@@ -95,6 +97,12 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
let props = this.props;
let { open, defaultName, acceptActionName, onClose, onOk } = props;
let name=defaultName;
if (props.useSafeFilenames)
{
name = safeFilenameDecode(name);
}
const handleClose = () => {
onClose();
};
@@ -102,13 +110,19 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
const handleOk = () => {
let text = nullCast(this.refText.current).value;
text = text.trim();
try {
this.checkForIllegalCharacters(text);
} catch (e:any)
if (props.useSafeFilenames)
{
let model:PiPedalModel = PiPedalModelFactory.getInstance();
model.showAlert(e.toString());
return;
text = safeFilenameEncode(text);
}
else {
try {
this.checkForIllegalCharacters(text);
} catch (e:any)
{
let model:PiPedalModel = PiPedalModelFactory.getInstance();
model.showAlert(e.toString());
return;
}
}
if (text.length === 0 && props.allowEmpty !== true) return;
onOk(text);
@@ -154,7 +168,7 @@ export default class RenameDialog extends ResizeResponsiveComponent<RenameDialog
type="text"
label={props.label ?? "Name"}
fullWidth
defaultValue={defaultName}
defaultValue={name}
inputRef={this.refText}
/>
</DialogContent>
+127
View File
@@ -0,0 +1,127 @@
export function safeFilenameDecode(filename: string): string {
let originalFilename = filename;
let pos = filename.indexOf('%');
if (pos < 0) {
return filename;
}
let result = "";
while (true) {
if (pos < 0) {
result += filename;
return result;
}
result += filename.substring(0, pos);
if (pos+3 >= filename.length) {
// invalid encoding. just use the original
return originalFilename;
}
let hex = filename.charAt(pos+1) + filename.charAt(pos+2);
let byte = parseInt(hex, 16);
// Handle UTF-8 to UTF-16 conversion
if (byte < 0x80) {
// Single-byte character (ASCII)
result += String.fromCharCode(byte);
filename = filename.substring(pos+3);
} else if ((byte & 0xE0) === 0xC0) {
// 2-byte sequence
if (pos+6 > filename.length || filename.charAt(pos+3) !== '%') {
return originalFilename; // invalid encoding
}
let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16);
let codePoint = ((byte & 0x1F) << 6) | (byte2 & 0x3F);
result += String.fromCharCode(codePoint);
filename = filename.substring(pos+6);
} else if ((byte & 0xF0) === 0xE0) {
// 3-byte sequence
if (pos+9 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%') {
return originalFilename; // invalid encoding
}
let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16);
let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16);
let codePoint = ((byte & 0x0F) << 12) | ((byte2 & 0x3F) << 6) | (byte3 & 0x3F);
result += String.fromCharCode(codePoint);
filename = filename.substring(pos+9);
} else if ((byte & 0xF8) === 0xF0) {
// 4-byte sequence (surrogate pair needed)
if (pos+12 > filename.length || filename.charAt(pos+3) !== '%' || filename.charAt(pos+6) !== '%' || filename.charAt(pos+9) !== '%') {
return originalFilename; // invalid encoding
}
let byte2 = parseInt(filename.charAt(pos+4) + filename.charAt(pos+5), 16);
let byte3 = parseInt(filename.charAt(pos+7) + filename.charAt(pos+8), 16);
let byte4 = parseInt(filename.charAt(pos+10) + filename.charAt(pos+11), 16);
let codePoint = ((byte & 0x07) << 18) | ((byte2 & 0x3F) << 12) | ((byte3 & 0x3F) << 6) | (byte4 & 0x3F);
// Convert to UTF-16 surrogate pair
codePoint -= 0x10000;
result += String.fromCharCode(0xD800 + (codePoint >> 10));
result += String.fromCharCode(0xDC00 + (codePoint & 0x3FF));
filename = filename.substring(pos+12);
} else {
// Invalid UTF-8 start byte
return originalFilename;
}
pos = filename.indexOf('%');
}
}
export function safeFilenameEncode(filename: string): string {
let result = "";
for (let i = 0; i < filename.length; i++) {
let charCode = filename.charCodeAt(i);
// Handle UTF-16 surrogate pairs
if (charCode >= 0xD800 && charCode <= 0xDBFF) {
// High surrogate - must be followed by low surrogate
if (i + 1 < filename.length) {
let lowSurrogate = filename.charCodeAt(i + 1);
if (lowSurrogate >= 0xDC00 && lowSurrogate <= 0xDFFF) {
// Combine surrogates to get the code point
let codePoint = 0x10000 + ((charCode & 0x3FF) << 10) + (lowSurrogate & 0x3FF);
// Encode as 4-byte UTF-8
let byte1 = 0xF0 | ((codePoint >> 18) & 0x07);
let byte2 = 0x80 | ((codePoint >> 12) & 0x3F);
let byte3 = 0x80 | ((codePoint >> 6) & 0x3F);
let byte4 = 0x80 | (codePoint & 0x3F);
result += '%' + byte1.toString(16).padStart(2, '0');
result += '%' + byte2.toString(16).padStart(2, '0');
result += '%' + byte3.toString(16).padStart(2, '0');
result += '%' + byte4.toString(16).padStart(2, '0');
i++; // Skip the low surrogate
continue;
}
}
// Invalid surrogate pair - encode as-is (shouldn't happen in valid strings)
}
// Handle control characters (< 0x20)
if (charCode < 0x20) {
result += '%' + charCode.toString(16).padStart(2, '0');
}
// Handle ASCII printable characters (0x20-0x7F), space is NOT encoded
else if (charCode <= 0x7F) {
result += filename.charAt(i);
}
// Handle 2-byte UTF-8 (0x80-0x7FF)
else if (charCode <= 0x7FF) {
let byte1 = 0xC0 | ((charCode >> 6) & 0x1F);
let byte2 = 0x80 | (charCode & 0x3F);
result += '%' + byte1.toString(16).padStart(2, '0');
result += '%' + byte2.toString(16).padStart(2, '0');
}
// Handle 3-byte UTF-8 (0x800-0xFFFF)
else {
let byte1 = 0xE0 | ((charCode >> 12) & 0x0F);
let byte2 = 0x80 | ((charCode >> 6) & 0x3F);
let byte3 = 0x80 | (charCode & 0x3F);
result += '%' + byte1.toString(16).padStart(2, '0');
result += '%' + byte2.toString(16).padStart(2, '0');
result += '%' + byte3.toString(16).padStart(2, '0');
}
}
return result;
}
+24 -6
View File
@@ -27,7 +27,7 @@ import { PiPedalModel, PiPedalModelFactory, State } from './PiPedalModel';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ReactMarkdown from 'react-markdown';
import ReactMarkdown, { defaultUrlTransform, UrlTransform } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, {defaultSchema} from 'rehype-sanitize';
import rehypeExternalLinks from 'rehype-external-links'
@@ -37,10 +37,28 @@ import { PiPedalError } from './PiPedalError';
// Extend the default schema to allow target and rel attributes on anchor tags
const extendedSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
a: [...(defaultSchema.attributes?.a || []), 'target', 'rel']
tagNames: Array.from(new Set([...(defaultSchema.tagNames ?? []), 'img'])),
protocols: {
...defaultSchema.protocols,
src: [...(defaultSchema.protocols?.src ?? []), ''],
},
};
const urlTransform: UrlTransform = (url, key, node) => {
if (key === 'src' && node.tagName === 'img' && url.startsWith('data:')) {
const allowedPrefixes = ['data:image/jpeg', 'data:image/png'];
for (const prefix of allowedPrefixes) {
if (url.startsWith(prefix)) {
const nextChar = url.charAt(prefix.length);
if (nextChar === ';' || nextChar === ',') {
return url;
}
}
}
return '';
}
return defaultUrlTransform(url);
};
import DialogEx from './DialogEx';
import ResizeResponsiveComponent from './ResizeResponsiveComponent';
@@ -181,10 +199,10 @@ const TextInfoDialog = class extends ResizeResponsiveComponent<TextInfoDialogPro
}}>
<div style={{ flex: "1 1 0px" }} />
<div className="text-info-dialog-content" style={{ flex: "1 1 auto",maxWidth: 650 }} >
<ReactMarkdown rehypePlugins={
<ReactMarkdown urlTransform={urlTransform} rehypePlugins={
[
[remarkGfm],
rehypeRaw,
[rehypeRaw],
[rehypeSanitize, extendedSchema],
[rehypeExternalLinks, {target: '_blank'}]
]}>
+118 -196
View File
@@ -1,34 +1,27 @@
import { JSX } from "@emotion/react/jsx-dev-runtime";
import { PiPedalModel, State, getErrorMessage } from "./PiPedalModel";
import DialogEx from "./DialogEx";
import { Button, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import LinearProgress from "@mui/material/LinearProgress";
import Tone3000DownloadProgress from "./Tone3000DownloadProgress";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
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): Promise<void> {
private async handleTone3000Download(tone3000DownloadUrl: string, downloadType: Tone3000DownloadType): Promise<void> {
try {
// Dialog can close. We'll use the following await to manage
// lifecycle from here onward.
this.stopTone3000DialogMonitor();
this.handleTone3000DownloadComplete();
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath);
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType);
return;
} catch (e) {
this.handleTone3000DownloadError(getErrorMessage(e));
@@ -39,6 +32,7 @@ export class Tone3000DownloadHandler {
private messageEventListener: ((event: MessageEvent) => void) | null = null;
private model: PiPedalModel;
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
public constructor(model: PiPedalModel) {
this.model = model;
@@ -46,7 +40,7 @@ export class Tone3000DownloadHandler {
if (event.data.type !== "tone3000Download") {
return;
}
this.handleTone3000Download(event.data.toneUrl as string);
this.handleTone3000Download(event.data.toneUrl as string, this.downloadType);
};
window.addEventListener("message", this.messageEventListener);
@@ -61,7 +55,7 @@ export class Tone3000DownloadHandler {
private popupWindow: Window | null = null;
private appId: string = "pipedal_app3";
private appId: string = "pipedal_app4";
private redirectUrl(): string {
let hostname = window.location.hostname;
let port = window.location.port;
@@ -77,217 +71,136 @@ export class Tone3000DownloadHandler {
return `${serverUrl}/public/handleTone3000download.html`;
}
private tone3000SelectUrl(): string {
return `https://www.tone3000.com/api/v1/select?app_id=${this.appId}&redirect_url=${this.redirectUrl()}`;
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 += "&gear=ir";
}
return result;
}
private handleTone3000DialogClosed() {
let t = this.onClosedCallback;
this.onClosedCallback = undefined;
this.onErrorCallback = undefined;
this.onDownloadCompleteCallback = undefined;
if (t) {
t();
}
this.stopTone3000DialogMonitor();
}
private handleTone3000Downloadstarted() {
if (this.onDownloadStartedCallback) {
this.onDownloadStartedCallback();
}
}
private handleTone3000DownloadComplete() {
let t = this.onDownloadCompleteCallback;
this.onClosedCallback = undefined;
this.onErrorCallback = undefined;
this.onDownloadCompleteCallback = undefined;
if (t) {
t();
if (this.popupWindow) {
if (!this.popupWindow.closed) {
this.popupWindow.close();
}
this.popupWindow = null;
}
this.stopTone3000DialogMonitor();
this.model.hideTone3000DownloadStatus();
}
closeTone3000Popup() {
this.handleTone3000DownloadComplete();
}
private handleTone3000DownloadError(errorMessage: string) {
let t = this.onErrorCallback;
this.onClosedCallback = undefined;
this.onErrorCallback = undefined;
this.onDownloadCompleteCallback = undefined;
if (t) {
t(errorMessage);
}
this.stopTone3000DialogMonitor();
}
private tone3000DialogTimeout: number | undefined = undefined;
private stopTone3000DialogMonitor() {
if (this.tone3000DialogTimeout !== undefined) {
clearTimeout(this.tone3000DialogTimeout);
this.tone3000DialogTimeout = undefined;
if (this.open)
{
this.handleTone3000DownloadComplete();
this.model.showAlert(errorMessage);
}
}
private startTone3000DialogMonitor(delay: number = 2000) {
this.stopTone3000DialogMonitor();
this.tone3000DialogTimeout = window.setTimeout(() => {
if (this.popupWindow == null || this.popupWindow.closed) {
this.stopTone3000DialogMonitor();
this.popupWindow = null;
this.handleTone3000DialogClosed();
this.tone3000DialogTimeout = undefined;
} else {
this.startTone3000DialogMonitor(500);
}
}, delay);
}
private downloadPath: string = "";
private onClosedCallback: (() => void) | undefined = undefined;
private onErrorCallback: ((errorMessage: string) => void) | undefined = undefined;
private onDownloadStartedCallback: (() => void) | undefined = undefined;
private onDownloadCompleteCallback: (() => void) | undefined = undefined;
private open = false;
public launchTone3000Dialog(
private launchInstance = 0;
public async launchTone3000Popup(
downloadType: Tone3000DownloadType,
downloadPath: string,
onClosed: () => void,
onError: (errorMessage: string) => void,
onDownloadStarted: () => void,
onDownloadComplete: () => void
) {
): Promise<void> {
this.closeTone3000Dialog();
// online
this.downloadPath = downloadPath;
this.onClosedCallback = onClosed;
this.onDownloadCompleteCallback = onDownloadComplete;
this.onDownloadStartedCallback = onDownloadStarted;
this.onErrorCallback = onError;
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 {
// online
let popupWidth = Math.floor(window.innerWidth * 0.8);
let popupHeight = Math.floor(window.innerHeight * 0.8);
if (window.innerWidth < 410) {
popupWidth = 400;
}
this.popupWindow =
window.open(
this.tone3000SelectUrl(),
"popup",
`innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes`
);
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.");
this.handleTone3000DownloadError("Cannot open popup window.");
return;
}
this.startTone3000DialogMonitor();
fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" }).then(response => {
if (!response.ok) {
// offline
this.handleTone3000DownloadError("Unable to connect to Tone3000 servers. An internet connection is required.");
}
}).catch(error => {
// offline
this.handleTone3000DownloadError("Not online. Unable to connect to Tone3000 servers.");
});
if (!this.popupWindow) {
console.error("Failed to open TONE3000 dialog popup window.");
throw new Error("Cannot open popup window.");
}
catch (error) {
// offline
this.handleTone3000DownloadError("Not online. Unable to connect to Tone3000 servers.");
return;
// 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..");
};
}
public closeTone3000Dialog(): void {
this.handleTone3000DialogClosed();
this.stopTone3000DialogMonitor();
if (this.popupWindow) {
let pipedalServerCanReachTone3000 = false;
// check to see that the PiPedal server can reach TONE3000.
try {
let t = this.popupWindow;
this.popupWindow = null;
t.close();
} catch (e) {
console.error("Error closing Tone3000 dialog popup:", e);
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 interface Tone3000DownloadDialogProps {
onClose: () => void,
onDownloadComplete: () => void,
downloadPath: string
}
export function Tone3000DownloadDialog(props: Tone3000DownloadDialogProps): JSX.Element {
const model = PiPedalModel.getInstance();
let { onClose, onDownloadComplete, downloadPath } = props;
let [downloading, setDownloading] = useState(false);
useEffect(() => {
model.showTone3000DownloadDialog(
downloadPath,
() => {
onClose();
},
() => {
setDownloading(true);
},
() => {
onDownloadComplete();
}
);
let onStateChanged = (state: State) => {
if (state !== State.Ready) {
onClose();
}
}
model.state.addOnChangedHandler(onStateChanged);
return () => {
model.closeTone3000DownloadDialog();
model.state.removeOnChangedHandler(onStateChanged);
};
});
return (
<DialogEx tag="tone3000-download"
open={true}
onClose={() => {
onClose();
}}
onEnterKey={() => { }}
>
<DialogContent>
<Typography style={{minWidth: 300, maxWidth: 300}} variant="body2">Downloading from Tone3000...</Typography>
{downloading &&
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<LinearProgress style={{ marginTop: 16, flexGrow: 1 }} />
</div>
}
</DialogContent>
<DialogActions >
{!downloading && (
<Button variant="dialogSecondary"
onClick={() => {
onClose();
}}>
Cancel
</Button>
)}
</DialogActions>
</DialogEx>
);
}
export function Tone3000DownloadStaus(
props: {
zindex: number;
}): JSX.Element {
const model = PiPedalModel.getInstance();
const [downloading, setDownloading] = useState<boolean>(false);
const [progress, setProgress] = useState<Tone3000DownloadProgress | null>(null);
const [downloading, setDownloading] = useState<boolean>(model.tone3000Downloading.get());
const [progress, setProgress] = useState<Tone3000DownloadProgress | null>(model.tone3000DownloadProgress.get());
useEffect(() => {
let onDownloadingChanged = (value: boolean) => {
setDownloading(value);
}
model.tone3000Downloading.addOnChangedHandler(onDownloadingChanged);
@@ -302,30 +215,39 @@ export function Tone3000DownloadStaus(
model.tone3000DownloadProgress.removeOnChangedHandler(onProgressChanged);
}
})
let open = downloading && progress !== null && progress.title.length > 0;
let open = downloading;
if (model.state.get() !== State.Ready) {
open = false;
}
let filename = progress?.title ?? "\u00A0";
if (filename === "") {
filename = "\u00A0";
}
return (
<Dialog
open={open}
onClose={() => { /* Do nothing */ }}
>
<DialogTitle>Downloading from Tone3000</DialogTitle>
<DialogContent>
<Typography noWrap variant="body2">{progress?.title ?? "\u00A0"}</Typography>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
<DialogContent style={{ marginBottom: 0, paddingBottom: 0 }}>
<div style={{ display: "flex", flexFlow: "column nowrap", alignItems: "stretch" }}>
<Typography noWrap variant="body2">Downloading from TONE3000...</Typography>
<LinearProgress
style={{ marginTop: 16, flexGrow: 1 }}
variant={(progress?.total ?? 0) === 0 ? "indeterminate" : "determinate"}
style={{ visibility: progress !== null ? "visible" : "hidden", marginTop: 16 }}
variant="determinate"
value={(progress && progress.total !== 0) ? progress.progress / progress.total * 100 : 0}
/>
<Typography noWrap variant="caption"
style={{
width: 300, maxWidth: 300, paddingTop: 8, paddingLeft: 16, paddingRight: 16,
paddingBottom: 0, marginBottom: 0
}}
>{filename}</Typography>
</div>
</DialogContent>
<DialogActions>
<Button variant="dialogSecondary"
onClick={() => {
model.cancelTone3000Download();
model.closeTone3000DownloadPopup();
}}
>Cancel</Button>
</DialogActions>
@@ -0,0 +1,9 @@
// Must agree with Tone3000DownloadType.hpp
enum Tone3000DownloadType {
Nam = "nam",
CabIr = "cabir",
}
export default Tone3000DownloadType;
+77 -29
View File
@@ -6,19 +6,20 @@ import IconButtonEx from "./IconButtonEx";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link";
import Tone3000DownloadType from "./Tone3000DownloadType";
function Tone3000HelpDialog(props: {
open: boolean;
downloadType: Tone3000DownloadType
onClose: () => void;
}) {
const { open, onClose } = props;
const { open, downloadType, onClose } = props;
return (
<DialogEx
tag="tone3000-help"
fullWidth={true}
maxWidth="sm"
fullWidth={true}
maxWidth="sm"
onEnterKey={() => { onClose(); }}
onClose={() => { onClose(); }}
open={open}>
@@ -36,7 +37,7 @@ function Tone3000HelpDialog(props: {
<ArrowBackIcon style={{ width: 24, height: 24 }} />
</IconButtonEx>
<Typography id="tone3000-auth-dialog-title" noWrap component="div" sx={{ flexGrow: 1 }}>
<Typography id="tone3000-help-dialog-title" noWrap component="div" sx={{ flexGrow: 1 }}>
TONE3000 Help
</Typography>
@@ -49,35 +50,82 @@ function Tone3000HelpDialog(props: {
maxWidth: 500, marginLeft: "auto", marginRight: "auto",
display: "flex", flexFlow: "column nowrap", gap: 16, alignItems: "start",
}}>
{downloadType === Tone3000DownloadType.CabIr && (
<>
<Typography variant="body1" component="div" display="block" >
The <Link href="https://www.tone3000.com" target="_blank">TONE3000 website</Link> provides a
massive collection of high-quality Neural Amp Modeler models that can be used by TooB Neural Amp Modeler. It also
provides collections of cabinet Impulse Response (I/R) files that can be used with <i>TooB Cab IR</i>.
</Typography>
<Typography variant="body1" component="div" display="block">
When you click on the button to download I/R files from TONE3000,
selected I/R bundles will be directly downloaded from the TONE3000 website and
uploaded to your PiPedal server. For this to work, the server must have
internet access, and your client (Android app or browser) must have internet
access as well.</Typography>
<Typography variant="body1" component="div" display="block">
If either of your devices does not have internet access, you can still use TONE3000.
Use a browser on a device with internet access to download model files by connecting to
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> and
download model packs directly to your local device. You can then upload the model .zip
files to the PiPedal Server using the <strong><em>Upload</em></strong> button in the
<strong><em>File Properties</em></strong> dialog in the PiPedal app or web interface.
PiPedal will extract I/R files from the .zip file bundles automatically, so there is no
need to extract them first.
</Typography>
</>
)}
{downloadType === Tone3000DownloadType.Nam && (
<>
<Typography variant="body1" component="div" display="block" >
The <Link href="https://www.tone3000.com" target="_blank">TONE3000 website</Link> provides a
massive collection of high-quality Neural Amp Modeler files that can be used by TooB Neural Amp Modeler to
perform high-quality simulations of amps and effect pedals.
</Typography>
<Typography variant="body1" component="div" display="block">
When you click on the button to download Neural Amp Modeler files from TONE3000,
model file bundles will be directly downloaded from the TONE3000 website and
uploaded to your PiPedal server. For this to work, the server must have
internet access, and your client (Android app or browser) must have internet
access as well.</Typography>
<Typography variant="body1" component="div" display="block">
Neural Amp Modeler models that simulate amp heads will typically need a speaker/cabinet simulation to
sound their best, or of course, need to be played through an actual physical amp speaker cabinet. The TONE3000
website provides a broad selection of cabinet I/R files that can be used
with <i>TooB Cab IR</i> to provide high-quality cabinet simulation for your NAM models. <i>TooB Cab IR</i> allows
you to browse and download I/R files from TONE3000 directly within the PiPedal app.
</Typography>
<Typography variant="body1" component="div" display="block">
If either of your devices does not have internet access, you can still use TONE3000.
Use a browser on a device with internet access to download Neural Amp Modeler files by connecting to
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> and
download Neural Amp Modeler file packs directly to your local device as .zip file bundles. You can then
upload the .zip file bundle to the PiPedal Server using the <strong><em>Upload</em></strong> button in the
<strong><em>File Properties</em></strong> dialog. PiPedal will
extract model files from the .zip archives automatically, so there is no need to extract them first.
</Typography>
</>
)}
<Typography variant="body1" component="div" display="block">
TONE3000 is a community-driven platform where community members share Neural Amp Modeler profiles.
The site showcases the collaborative spirit of the guitar modeling community, made
possible by Steven Atkins' <Link href="https://www.neuralampmodeler.com/">Neural Amp Modeler library</Link>, which forms the foundation and
core of TooB Neural Amp Modeler and other NAM-based plugins.
</Typography>
<Typography variant="body1" component="div" display="block">
If you would like to profile your own amplifiers and effects, the TONE3000 website allows you to
generate your own NAM profiles for free. Refer to
the <Link href="https://www.tone3000.com/capture" target="_blank">TONE3000 Capture page</Link> for more details.
</Typography>
<Typography variant="body1" component="div" display="block" >
The <Link href="https://www.tone3000.com" target="_blank">TONE3000 website</Link> provides a
massive collection of neural amp models that can be used by TooB Neural Amp Modeler.
</Typography>
<Typography variant="body1" component="div" display="block">
Using TONE3000 model files in PiPedal is a two step process. First, download model files from
the website using your browser; and then upload
the files in your browser's Downloads directory to the PiPedal server using
the <strong><em>Upload</em></strong> button. PiPedal
automatically extracts bundles of model files from zip archives, so manual extraction is unnecessary.
</Typography>
<Typography variant="body1" component="div" display="block">
TONE3000 is a community-driven platform where community members share Neural Amp Modeler profiles.
The site showcases the collaborative spirit of the guitar modeling community. And all of this is made
possible by Steven Atkins' <Link href="https://www.neuralampmodeler.com/">Neural Amp Modeler library</Link>, which forms the foundation and core of TooB Neural Amp Modeler and other NAM-based plugins.
</Typography>
<Typography variant="body1" component="div" display="block">
If you would like to profile your own amplifiers, and effects, the TONE3000 website allows you to
generate your own NAM profiles for free. Refer to
the <Link href="https://www.tone3000.com/capture">TONE3000 website</Link> for more details.
</Typography>
<Typography variant="body1" component="div" display="block" >
When you click on the TONE3000 link, you will be taken to an external website. The TONE3000
website is not part of PiPedal, and is not affiliated in any way with PiPedal.
When you click on the TONE3000 link, you will be taken to an external website. The TONE3000
website is not part of PiPedal and is not affiliated in any way with PiPedal.
</Typography>
<Typography variant="body2" component="div" display="block" style={{ marginTop: 32 }}>
Privacy statement: PiPedal has no access to personal data used by the TONE3000 website. Please refer to
the <Link href="https://www.tone3000.com/privacy" target="_blank">TONE3000 privacy policy</Link> for
the <Link href="https://www.tone3000.com/privacy" target="_blank">TONE3000 Privacy Policy</Link> for
information on how your data is used by TONE3000.
</Typography>
</div>