tone3000 design updates

This commit is contained in:
André Michelle
2026-03-18 07:08:47 +01:00
parent 9b41a58298
commit cd8db2de3e
10 changed files with 592 additions and 58 deletions
@@ -0,0 +1,20 @@
<svg width="16" height="6" viewBox="0 0 16 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.7204 1.25327C5.72864 1.24904 5.73631 1.24541 5.74398 1.24117C6.17112 1.03724 6.69376 0.933765 7.29817 0.933765C7.93334 0.933765 8.47961 1.04935 8.92097 1.27748C9.24926 1.44692 9.45458 1.64964 9.53528 1.88261V1.01727H11.4172V0.124097H9.53528V0.989437C9.45458 0.75646 9.24926 0.553742 8.92097 0.384304C8.47903 0.156169 7.93281 0.0405884 7.29817 0.0405884C6.66357 0.0405884 6.17112 0.144066 5.74398 0.347996C5.73578 0.351627 5.72807 0.355863 5.7204 0.360099V0.123492H0.0180664V1.01667H5.7204V1.25327Z"
fill="#FF0000"/>
<path d="M13.4688 1.01726H14.4235L15.9771 0.124084H13.4688L11.4172 1.34222V2.23539L13.4688 1.01726Z"
fill="#FF0000"/>
<path d="M9.53519 4.38907C9.42702 4.48952 9.29251 4.58272 9.13111 4.66804C8.63374 4.93004 8.02492 5.06259 7.32273 5.06259C6.66286 5.06259 6.09576 4.944 5.63791 4.7104C5.47762 4.62868 5.34199 4.53672 5.23164 4.43566L4.8457 4.46652L4.88636 4.71645C4.94562 5.08072 5.19873 5.37968 5.63791 5.60359C6.09576 5.83713 6.66286 5.95577 7.32273 5.95577C8.02492 5.95577 8.63374 5.82322 9.13111 5.56122C9.29251 5.4765 9.42702 5.38331 9.53519 5.28222V5.89586H11.4171V5.00268H9.53519V4.38907Z"
fill="#0000FF"/>
<path d="M3.80551 5.00327H1.92358V5.89645H3.80551V5.00327Z" fill="#0000FF"/>
<path d="M1.9236 1.96793H0.0180664V2.8611H1.9236V1.96793Z" fill="#0000FF"/>
<path d="M7.58257 1.89411C7.50133 1.85054 7.42228 1.83299 7.31028 1.83299C7.19061 1.83299 7.10661 1.85236 7.02042 1.89895C6.94249 1.94131 6.83981 2.03208 6.79205 2.22512L6.74756 2.40605L7.08958 2.42057C7.35201 2.41816 7.47717 2.37277 7.54908 2.33828C7.621 2.30378 7.71655 2.24267 7.71655 2.1053C7.71655 1.96794 7.61609 1.91166 7.58257 1.89411Z"
fill="#0000FF"/>
<path d="M9.29045 2.67232C9.24818 2.70923 9.20204 2.74372 9.1521 2.77761C9.16253 2.78245 9.17297 2.78729 9.1834 2.79213C9.31845 2.85688 9.43593 2.93434 9.53528 3.02329V2.33586C9.49082 2.45689 9.40958 2.56944 9.29045 2.67292V2.67232Z"
fill="#0000FF"/>
<path d="M6.71558 3.70103C6.86655 4.16335 7.18882 4.16335 7.31014 4.16335C7.42268 4.16335 7.57422 4.15065 7.74878 4.05262C7.90198 3.96608 7.97662 3.85111 7.97662 3.69983C7.97662 3.56185 7.91237 3.45898 7.78008 3.38455C7.66313 3.31919 7.544 3.29075 7.38152 3.29075C7.33761 3.29075 7.23439 3.29499 7.04222 3.32525L6.22371 3.45414L6.43616 2.49198L4.88746 2.32738L4.96433 2.07504C4.97587 2.03813 4.98903 2.00243 5.0033 1.96672H3.80542V2.8599H4.87538L4.88639 2.92888C4.92424 3.16004 5.03954 3.36518 5.23167 3.54128L6.62775 3.42993L6.71616 3.69983L6.71558 3.70103Z"
fill="#0000FF"/>
<path d="M11.7241 3.46201L11.4172 3.6381V4.53127L11.7241 4.35518L13.5435 5.89646H15.8668L14.76 5.00327H13.5435L11.7241 3.46201Z"
fill="#0000FF"/>
<path d="M14.4235 1.01727H13.4688L11.4172 2.2354V1.01727H9.53528V1.88261C9.45458 1.64963 9.24926 1.44692 8.92097 1.27748C8.47903 1.04934 7.93281 0.933762 7.29817 0.933762C6.66357 0.933762 6.17112 1.03724 5.74398 1.24117C5.73578 1.2448 5.72807 1.24904 5.7204 1.25327V1.01667H0.0180664V1.96733H1.9236V5.00209H3.80553V1.96733H5.00341C4.98914 2.00243 4.97541 2.03873 4.96444 2.07565L4.88757 2.32799L6.43627 2.49258L6.22382 3.45475L7.04233 3.32585C7.2345 3.2956 7.33714 3.29136 7.38163 3.29136C7.54411 3.29136 7.66324 3.3192 7.78019 3.38516C7.91248 3.45959 7.97673 3.56246 7.97673 3.70043C7.97673 3.85172 7.90204 3.96729 7.74889 4.05323C7.57433 4.15186 7.42279 4.16396 7.31025 4.16396C7.18893 4.16396 6.86666 4.16396 6.71569 3.70164L6.62732 3.43175L5.23124 3.5431L4.8453 3.57396L4.88592 3.82388C4.92382 4.05504 5.03912 4.26018 5.23124 4.43627C5.34159 4.53733 5.47718 4.62932 5.63751 4.711C6.09536 4.94459 6.66246 5.06318 7.32233 5.06318C8.02448 5.06318 8.6333 4.93068 9.1307 4.66864C9.29211 4.58332 9.42658 4.49073 9.53475 4.38968V5.00328H11.4167V3.63871L11.723 3.46261L13.5429 5.00387H15.8662L13.0279 2.71285L15.9766 1.01788H14.4229L14.4235 1.01727ZM7.54906 2.33828C7.47714 2.37277 7.35198 2.41815 7.08955 2.42058L6.74753 2.40605L6.79202 2.22512C6.83978 2.03208 6.94242 1.94131 7.0204 1.89895C7.10658 1.85175 7.19058 1.83299 7.31025 1.83299C7.42225 1.83299 7.50077 1.85054 7.58254 1.89411C7.61549 1.91166 7.71652 1.96552 7.71652 2.1053C7.71652 2.24508 7.62097 2.30378 7.54906 2.33828ZM9.53528 3.02329C9.43594 2.93433 9.31846 2.85688 9.1834 2.79213C9.17297 2.78728 9.16254 2.78244 9.15211 2.7776C9.20204 2.74432 9.24819 2.70922 9.29046 2.67231C9.40959 2.56883 9.49136 2.45628 9.53528 2.33525V3.02268V3.02329Z"
fill="#FFFF00"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -797,6 +797,10 @@ export const IconLibrary = () => (
<path
d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"/>
</symbol>
<symbol id={IconSymbol.toName(IconSymbol.Tone3000)} viewBox="0 0 16 6" fill="currentColor">
<path
d="M7.2999 0.188469C7.91249 0.188469 8.43591 0.303574 8.85697 0.529989C9.14482 0.684938 9.3237 0.862654 9.38865 1.05808L9.70788 2.02066V0.275746H11.2644V1.67661L13.5276 0.275746H15.3269L13.9052 1.12765H15.3269L12.7323 2.6822L15.3847 4.91409H14.3945L15.3841 5.74702H13.6162L11.8363 4.17603L11.7526 4.10203L11.659 4.15832L11.2633 4.39549V5.74702H9.70678V4.88753L9.43543 5.15063C9.33691 5.24613 9.21252 5.3353 9.06557 5.41562C8.58892 5.67682 8.00275 5.80963 7.32356 5.80963C6.68565 5.80963 6.14076 5.69137 5.70375 5.45863C5.31517 5.25182 5.09281 4.98619 5.04327 4.66997L5.03502 4.61621L5.15115 4.60673L5.50946 4.577L5.25628 4.28418C5.13849 4.14757 5.06914 4.00148 5.04327 3.83767L5.03502 3.78392L5.53368 3.74281L5.26949 3.44809C5.144 3.30768 5.07024 3.15716 5.04327 2.9864L4.97943 2.57595H3.63206V5.74766H2.07554V2.57595L0.165119 2.57595V0.277011H5.55184V0.627385L5.78466 0.507853L5.79677 0.501529C5.80007 0.499631 5.80337 0.497734 5.80667 0.496469C6.21396 0.293455 6.71593 0.190366 7.2988 0.190366M7.2988 0.00063258C6.69336 0.00063258 6.16883 0.108781 5.74063 0.321914C5.73237 0.325709 5.72466 0.330136 5.71696 0.334563V0.0872776H0V2.76568H1.91042V5.93739H3.79718V2.76568H4.84128L4.88091 3.01992C4.91338 3.22926 5.0053 3.41773 5.15446 3.5847L4.84018 3.61062L4.88091 3.87182C4.91228 4.07421 4.99924 4.25635 5.13904 4.41889L4.84018 4.44356L4.88091 4.70475C4.94035 5.08549 5.19408 5.39791 5.6344 5.63192C6.09343 5.87604 6.66199 6 7.32356 6C8.02752 6 8.63791 5.8615 9.13657 5.58765C9.29838 5.4991 9.43323 5.40171 9.54166 5.29609V5.93739H11.4284V4.51123L11.7355 4.32718L13.5601 5.93802H15.8894L14.8992 5.10509H15.8894L13.0438 2.71066L16 0.93918H14.5783L16 0.0872776H13.4852L11.4284 1.36039V0.0872776H9.54166V0.991673C9.46075 0.748182 9.2549 0.536313 8.92577 0.359228C8.4827 0.120797 7.93505 0 7.2988 0V0.00063258ZM16 0.93918H13.4852L11.4284 2.21229V0.93918H9.54166V1.84358C9.46075 1.60009 9.2549 1.38822 8.92577 1.21113C8.4827 0.9727 7.93505 0.851903 7.2988 0.851903C6.66254 0.851903 6.16883 0.960051 5.74063 1.17318C5.73237 1.17698 5.72466 1.18141 5.71696 1.18583V0.938547H0V1.93212H1.91042V5.10383H3.79718V1.93212H4.99814C4.98383 1.9688 4.97007 2.00675 4.95906 2.04533L4.88201 2.30906L6.43467 2.48108L6.22167 3.48667L7.04231 3.35196C7.23495 3.32033 7.33787 3.31591 7.38245 3.31591C7.54537 3.31591 7.66481 3.345 7.78204 3.41393C7.91469 3.49173 7.97908 3.59924 7.97908 3.74344C7.97908 3.90155 7.90423 4.02235 7.75067 4.11215C7.57564 4.21524 7.42374 4.22789 7.3109 4.22789C7.18927 4.22789 6.86618 4.22789 6.71483 3.7447L6.62621 3.46263L4.83963 3.61126L4.88036 3.87246C4.9398 4.25319 5.19353 4.56562 5.63385 4.79962C6.09288 5.04374 6.66144 5.1677 7.32301 5.1677C8.02697 5.1677 8.63736 5.0292 9.13602 4.75535C9.29783 4.66681 9.43268 4.56941 9.54111 4.46379V5.10509H11.4279V3.67893L11.735 3.49489L13.5595 5.10572H15.8888L13.0433 2.71129L15.9994 0.939813L16 0.93918ZM6.79188 2.20217C6.83977 2.00042 6.94269 1.90556 7.02084 1.86128C7.10726 1.81195 7.19147 1.79235 7.31145 1.79235C7.42373 1.79235 7.50244 1.81069 7.58445 1.85622C7.61747 1.87457 7.71875 1.93085 7.71875 2.07695C7.71875 2.22304 7.62298 2.28439 7.55088 2.32044C7.47877 2.35649 7.35328 2.40392 7.09019 2.40645L6.7473 2.39127L6.79188 2.20217ZM9.1894 2.79477C9.17895 2.78971 9.16849 2.78465 9.15803 2.77959C9.20812 2.74481 9.25435 2.70813 9.29673 2.66955C9.41617 2.5614 9.49818 2.44377 9.54221 2.31728V3.03573C9.44258 2.94276 9.3248 2.86181 9.1894 2.79414V2.79477Z"/>
</symbol>
</defs>
</symbol>
)
@@ -52,6 +52,13 @@ component
flex-direction: column
row-gap: 0.375em
> hr
border: none
background-color: rgba(white, 0.04)
margin: 0.25em 0
height: 1px
width: 100%
> li
display: flex
flex-direction: row
@@ -75,6 +82,13 @@ component
justify-content: center
background-color: var(--color)
&.external
background-color: black
padding: 7px 1px
> img
height: 100%
> div.manual
display: flex
flex-direction: column
@@ -84,60 +84,72 @@ const createInstrumentList = (lifecycle: Lifecycle, project: Project) => (
const createEffectList = <
R extends Record<string, EffectFactory>,
T extends DragDevice["type"]>(lifecycle: Lifecycle, project: Project, records: R, type: T): HTMLUListElement => (
<ul>{
Object.entries(records).map(([key, entry]) => {
const element = (
<li onInit={element => {
lifecycle.own(ContextMenu.subscribe(element, collector => collector.addItems(MenuItem.default({
label: `Visit Manual Page for ${entry.defaultName}`, selectable: isDefined(entry.manualPage)
}).setTriggerProcedure(() => RouteLocation.get().navigateTo(entry.manualPage ?? "/")))))
element.onclick = () => {
const {boxAdapters, editing, userEditingManager} = project
const audioUnitOption = userEditingManager.audioUnit.get()
if (audioUnitOption.isEmpty()) {
T extends DragDevice["type"]>(lifecycle: Lifecycle, project: Project, records: R, type: T): HTMLUListElement => {
const entries = Object.entries(records)
const internal = entries.filter(([_, entry]) => !entry.external)
const external = entries.filter(([_, entry]) => entry.external)
const createItem = ([key, entry]: [string, EffectFactory]) => {
const element = (
<li onInit={element => {
lifecycle.own(ContextMenu.subscribe(element, collector => collector.addItems(MenuItem.default({
label: `Visit Manual Page for ${entry.defaultName}`, selectable: isDefined(entry.manualPage)
}).setTriggerProcedure(() => RouteLocation.get().navigateTo(entry.manualPage ?? "/")))))
element.onclick = () => {
const {boxAdapters, editing, userEditingManager} = project
const audioUnitOption = userEditingManager.audioUnit.get()
if (audioUnitOption.isEmpty()) {
RuntimeNotifier.info({
headline: "No Source Device Yet",
message: "Please create an instrument or select an audio-bus first."
}).finally()
return
}
audioUnitOption.ifSome(vertex => {
const deviceHost: DeviceHost = boxAdapters.adapterFor(vertex.box, Devices.isHost)
if (type === "midi-effect" && deviceHost.inputAdapter.mapOr(input => input.accepts !== "midi", true)) {
RuntimeNotifier.info({
headline: "Add Effect",
message: "Please create an instrument or select an audio-bus first."
headline: "Add Midi Effect",
message: "The selected audio unit does not have a midi input."
}).finally()
return
}
audioUnitOption.ifSome(vertex => {
const deviceHost: DeviceHost = boxAdapters.adapterFor(vertex.box, Devices.isHost)
if (type === "midi-effect" && deviceHost.inputAdapter.mapOr(input => input.accepts !== "midi", true)) {
RuntimeNotifier.info({
headline: "Add Midi Effect",
message: "The selected audio unit does not have a midi input."
}).finally()
return
}
const effectField =
type === "audio-effect" ? deviceHost.audioEffects.field()
: type === "midi-effect" ? deviceHost.midiEffects.field()
: panic(`Unknown ${type}`)
editing.modify(() => entry.create(project, effectField, effectField.pointerHub.incoming().length))
})
}
}}>
<div className="icon">
<Icon symbol={entry.defaultIcon}/>
const effectField =
type === "audio-effect" ? deviceHost.audioEffects.field()
: type === "midi-effect" ? deviceHost.midiEffects.field()
: panic(`Unknown ${type}`)
editing.modify(() => entry.create(project, effectField, effectField.pointerHub.incoming().length))
})
}
}}>
{entry.external
? <div className="icon external">
<img src="/images/tone3000.svg" alt="logo"/>
</div>
{entry.defaultName}
</li>
)
lifecycle.ownAll(
DragAndDrop.installSource(element, () => ({
type: type as any,
start_index: null,
device: key as keyof typeof EffectFactories.MergedNamed
} satisfies DragDevice)),
TextTooltip.simple(element, () => {
const {bottom, left} = element.getBoundingClientRect()
return {clientX: left, clientY: bottom + 12, text: entry.description}
})
)
return element
})
}</ul>
)
: <div className="icon">
<Icon symbol={entry.defaultIcon}/>
</div>}
{entry.defaultName}
</li>
)
lifecycle.ownAll(
DragAndDrop.installSource(element, () => ({
type: type as any,
start_index: null,
device: key as keyof typeof EffectFactories.MergedNamed
} satisfies DragDevice)),
TextTooltip.simple(element, () => {
const {bottom, left} = element.getBoundingClientRect()
return {clientX: left, clientY: bottom + 12, text: entry.description}
})
)
return element
}
return (
<ul>
{internal.map(createItem)}
{external.length > 0 && <hr/>}
{external.map(createItem)}
</ul>
)
}
@@ -25,9 +25,8 @@ component
padding: 1px
> img
height: 100%
width: 100%
display: block
border-radius: 3px
span.name
flex: 1 0 0
@@ -78,7 +78,7 @@ export const NeuralAmpDeviceEditor = ({lifecycle, service, adapter, deviceHost}:
tooltip: "Browse tone3000.com"
}}
className="tone3000-button">
<img src="images/tone3000-logo.webp" alt="tone3000 logo"/>
<img src="images/tone3000.svg" alt="tone3000 logo"/>
</Button>
<Button lifecycle={lifecycle}
onClick={browseLocal}
@@ -151,6 +151,6 @@ export const NeuralAmpDeviceEditor = ({lifecycle, service, adapter, deviceHost}:
receiver={project.liveStreamReceiver}
address={adapter.address}/>
)}
icon={IconSymbol.NeuralAmp}/>
icon={IconSymbol.Tone3000}/>
)
}
+22 -3
View File
@@ -37,6 +37,7 @@ export namespace EffectFactories {
description: "Generates rhythmic note sequences from chords",
manualPage: DeviceManualUrls.Arpeggio,
separatorBefore: false,
external: false,
type: "midi",
create: ({boxGraph}, hostField, index) =>
ArpeggioDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -52,6 +53,7 @@ export namespace EffectFactories {
description: "Shifts the pitch of incoming notes",
manualPage: DeviceManualUrls.Pitch,
separatorBefore: false,
external: false,
type: "midi",
create: ({boxGraph}, hostField, index) =>
PitchDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -67,6 +69,7 @@ export namespace EffectFactories {
description: "Manipulates the velocity of incoming notes",
manualPage: DeviceManualUrls.Velocity,
separatorBefore: false,
external: false,
type: "midi",
create: ({boxGraph}, hostField, index) =>
VelocityDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -82,6 +85,7 @@ export namespace EffectFactories {
description: "Distorts space and time",
manualPage: DeviceManualUrls.Zeitgeist,
separatorBefore: false,
external: false,
type: "midi",
create: (
{boxGraph, rootBoxAdapter},
@@ -115,6 +119,7 @@ export namespace EffectFactories {
"Computes a stereo transformation matrix with volume, panning, phase inversion and stereo width.",
manualPage: DeviceManualUrls.StereoTool,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): StereoToolDeviceBox =>
StereoToolDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -130,6 +135,7 @@ export namespace EffectFactories {
description: "Echoes the input signal with time-based repeats",
manualPage: DeviceManualUrls.Delay,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): DelayDeviceBox =>
DelayDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -147,6 +153,7 @@ export namespace EffectFactories {
"Dense algorithmic reverb based on Dattorro's design, capable of infinite decay",
manualPage: DeviceManualUrls.DattorroReverb,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): DattorroReverbDeviceBox =>
DattorroReverbDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -162,6 +169,7 @@ export namespace EffectFactories {
description: "Brickwall limiter with automatic makeup gain",
manualPage: DeviceManualUrls.Maximizer,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): MaximizerDeviceBox =>
MaximizerDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -178,6 +186,7 @@ export namespace EffectFactories {
"Reduces the dynamic range by attenuating signals above a threshold",
manualPage: DeviceManualUrls.Compressor,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): CompressorDeviceBox =>
CompressorDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -193,6 +202,7 @@ export namespace EffectFactories {
description: "Attenuates signals below a threshold to reduce noise",
manualPage: DeviceManualUrls.Gate,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): GateDeviceBox =>
GateDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -208,6 +218,7 @@ export namespace EffectFactories {
description: "Simulates space and depth with reflections",
manualPage: DeviceManualUrls.Reverb,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): ReverbDeviceBox =>
ReverbDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -224,6 +235,7 @@ export namespace EffectFactories {
description: "Degrates the audio signal",
manualPage: DeviceManualUrls.Crusher,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): CrusherDeviceBox =>
CrusherDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -239,6 +251,7 @@ export namespace EffectFactories {
description: "Folds the signal back into audio-range",
manualPage: DeviceManualUrls.Fold,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): FoldDeviceBox =>
FoldDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -254,6 +267,7 @@ export namespace EffectFactories {
description: "Applies nonlinear waveshaping distortion",
manualPage: DeviceManualUrls.Waveshaper,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): WaveshaperDeviceBox =>
WaveshaperDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -269,6 +283,7 @@ export namespace EffectFactories {
description: "Shape rhythm and space through volume and pan.",
manualPage: DeviceManualUrls.Tidal,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): TidalDeviceBox =>
TidalDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -285,6 +300,7 @@ export namespace EffectFactories {
description: "Shapes the frequency balance of the sound",
manualPage: DeviceManualUrls.Revamp,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): RevampDeviceBox =>
RevampDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -295,15 +311,16 @@ export namespace EffectFactories {
}
export const NeuralAmp: EffectFactory = {
defaultName: "Neural Amp",
defaultIcon: IconSymbol.NeuralAmp,
defaultName: "Tone3000",
defaultIcon: IconSymbol.Tone3000,
description: "Neural network-based amp modeling using NAM models",
manualPage: DeviceManualUrls.NeuralAmp,
separatorBefore: false,
external: true,
type: "audio",
create: ({boxGraph}, hostField, index): NeuralAmpDeviceBox =>
NeuralAmpDeviceBox.create(boxGraph, UUID.generate(), (box) => {
box.label.setValue("Neural Amp")
box.label.setValue("Tone3000")
box.index.setValue(index)
box.host.refer(hostField)
})
@@ -315,6 +332,7 @@ export namespace EffectFactories {
description: "User-scripted DSP processor",
manualPage: DeviceManualUrls.Werkstatt,
separatorBefore: false,
external: false,
type: "audio",
create: ({boxGraph}, hostField, index): WerkstattDeviceBox =>
WerkstattDeviceBox.create(boxGraph, UUID.generate(), (box) => {
@@ -330,6 +348,7 @@ export namespace EffectFactories {
description: "",
manualPage: DeviceManualUrls.Modular,
separatorBefore: true,
external: false,
type: "audio",
create: (
{boxGraph, rootBox, userEditingManager},
@@ -6,6 +6,7 @@ import {EffectBox} from "./EffectBox"
export interface EffectFactory extends DeviceFactory {
readonly separatorBefore: boolean
readonly external: boolean
readonly type: "audio" | "midi"
create(project: Project, host: Field<EffectPointerType>, index: int): EffectBox
+1
View File
@@ -134,6 +134,7 @@ export enum IconSymbol {
TapeReel,
Time,
Timeline,
Tone3000,
Tool,
Triangle,
Undo,
+464
View File
@@ -0,0 +1,464 @@
# Werkstatt MIDI — User-Scripted MIDI Effect Processor
## Concept
A scripted MIDI effect device — the MIDI counterpart to Werkstatt (audio DSP). Users write TypeScript classes that transform or generate note events. Reuses the same infrastructure: code editor, compile-via-`addModule()`, version gating, error recovery.
---
## The `iterateActiveNotesAt` Problem — Solved
Every `MidiEffectProcessor` must implement both `processNotes()` (block-by-block note lifecycle) and `iterateActiveNotesAt()` (point-in-time snapshot). These two methods are tightly coupled — every built-in device mirrors its transformation in both methods. This duplication is error-prone and too complex for users.
### How It Actually Works
`iterateActiveNotesAt` does not look into the past. It returns what is **currently active** — notes that have been started but haven't ended yet. The Arpeggio already proves this pattern: it stores generated notes in an `EventSpanRetainer`, and `iterateActiveNotesAt` simply calls `retainer.overlapping(position)`.
### Solution: The Host Owns the Retainer
The user only writes `processNotes`. The host intercepts every yielded note, stores it in a retainer, and answers `iterateActiveNotesAt` from the retainer. The user never knows this method exists.
This works universally — for transformers (1:1), generators (1:N), and filters (1:0).
---
## Class Contract
```typescript
class Processor {
paramChanged?(name: string, value: number): void
// Called per block. Receives upstream note starts, yields output notes.
// Each yielded note must have: { position, duration, pitch, velocity, cent }
* processNotes(notes, block) {
for (const note of notes) {
yield note
}
}
}
```
- `notes` — an iterator of upstream note starts in `[block.from, block.to)`. Each note is `{ position, duration, pitch, velocity, cent }`. Stop events are hidden from the user.
- `block` — the engine `Block` object passed directly (provides `from`, `to`, `bpm`, `s0`, `s1`, `flags`, etc.). Sample indices are irrelevant for MIDI but passing the Block keeps the API simple.
- The user yields zero or more notes per input note. Each must have `position`, `duration`, `pitch`, `velocity`, `cent`.
- `paramChanged` — optional, same as audio Werkstatt. Receives mapped parameter values from `// @param` declarations.
### Default Code (Passthrough)
```typescript
class Processor {
* processNotes(notes, block) {
for (const note of notes) {
yield note
}
}
}
```
---
## Host Processor — `WerkstattMidiDeviceProcessor`
```typescript
const MAX_NOTES_PER_BLOCK: int = 100
const validateNote = (note: any): Nullable<string> => {
if (!isDefined(note)) return "processNotes yielded undefined"
if (typeof note.pitch !== "number" || note.pitch !== note.pitch) return `Invalid pitch: ${note.pitch}`
if (note.pitch < 0 || note.pitch > 127) return `Pitch out of range: ${note.pitch} (must be 0127)`
if (typeof note.velocity !== "number" || note.velocity !== note.velocity) return `Invalid velocity: ${note.velocity}`
if (note.velocity < 0 || note.velocity > 1) return `Velocity out of range: ${note.velocity} (must be 01)`
if (typeof note.duration !== "number" || note.duration !== note.duration) return `Invalid duration: ${note.duration}`
if (note.duration <= 0) return `Duration must be positive: ${note.duration}`
if (typeof note.position !== "number" || note.position !== note.position) return `Invalid position: ${note.position}`
return null
}
export class WerkstattMidiDeviceProcessor extends EventProcessor implements MidiEffectProcessor {
readonly #adapter: WerkstattMidiDeviceBoxAdapter
readonly #engineToClient: EngineToClient
readonly #retainer: EventSpanRetainer<Id<NoteEvent>>
readonly #sourceToOutput: Map<int, Array<int>> // source note id → output note ids
readonly #uuid: string
#source: Option<NoteEventSource> = Option.None
#userProcessor: Option<any> = Option.None
#currentUpdate: int = -1
#silenced: boolean = false
constructor(context: EngineContext, adapter: WerkstattMidiDeviceBoxAdapter) {
super(context)
this.#adapter = adapter
this.#engineToClient = context.engineToClient
this.#retainer = new EventSpanRetainer<Id<NoteEvent>>()
this.#sourceToOutput = new Map()
this.#uuid = UUID.toString(adapter.uuid)
this.ownAll(
adapter.box.code.catchupAndSubscribe(owner => {
const newUpdate = parseUpdate(owner.getValue())
if (newUpdate > 0 && newUpdate !== this.#currentUpdate) {
this.#silenced = true
this.#userProcessor = Option.None
this.#tryLoad(newUpdate)
}
}),
// ... parameter binding (same pattern as audio Werkstatt)
context.registerProcessor(this)
)
}
#reportError(message: string): void {
this.#engineToClient.deviceMessage(this.#uuid, message)
}
#silence(message: string): void {
this.#silenced = true
this.#retainer.clear()
this.#sourceToOutput.clear()
this.#reportError(message)
}
#tryLoad(update: int): void {
const registry = (globalThis as any).openDAW?.werkstattMidiProcessors?.[this.#uuid]
if (isDefined(registry) && registry.update === update) {
this.#swapProcessor(registry.create, update)
}
}
#swapProcessor(ProcessorClass: any, update: int): void {
try {
this.#userProcessor = Option.wrap(new ProcessorClass())
this.#currentUpdate = update
this.#silenced = false
this.#pushAllParameters()
} catch (error) {
this.#silence(`Failed to instantiate Processor: ${error}`)
}
}
setNoteEventSource(source: NoteEventSource): Terminable {
assert(this.#source.isEmpty(), "NoteEventSource already set")
this.#source = Option.wrap(source)
return Terminable.create(() => this.#source = Option.None)
}
get uuid(): UUID.Bytes {return this.#adapter.uuid}
get incoming(): Processor {return this}
get outgoing(): Processor {return this}
* processNotes(from: ppqn, to: ppqn, flags: int): IterableIterator<NoteLifecycleEvent> {
// Phase 1: Release expired notes from retainer
if (this.#retainer.nonEmpty()) {
if (Bits.every(flags, BlockFlag.discontinuous)) {
for (const event of this.#retainer.releaseAll()) {
yield NoteLifecycleEvent.stop(event, from)
}
this.#sourceToOutput.clear()
} else {
for (const event of this.#retainer.releaseLinearCompleted(to)) {
yield NoteLifecycleEvent.stop(event, event.position + event.duration)
}
}
}
if (this.#source.isEmpty() || this.#userProcessor.isEmpty() || this.#silenced) {return}
const source = this.#source.unwrap()
const proc = this.#userProcessor.unwrap()
// Phase 2: Consume upstream, separate starts from stops
const upstreamStarts: Array<Id<NoteEvent>> = []
const upstreamStops: Array<NoteCompleteEvent> = []
for (const event of source.processNotes(from, to, flags)) {
if (NoteLifecycleEvent.isStart(event)) {
upstreamStarts.push(event)
} else {
upstreamStops.push(event)
}
}
// Phase 3: Handle upstream stops — release associated output notes
for (const stop of upstreamStops) {
const outputIds = this.#sourceToOutput.get(stop.id)
if (isDefined(outputIds)) {
for (const event of this.#retainer.release(note => outputIds.includes(note.id))) {
yield NoteLifecycleEvent.stop(event, stop.position)
}
this.#sourceToOutput.delete(stop.id)
}
}
// Phase 4: Feed starts to user, retain and yield output
const userNotes = upstreamStarts.map(event => ({
position: event.position,
duration: event.duration,
pitch: event.pitch,
velocity: event.velocity,
cent: event.cent
}))
const block: Block = {from, to, /* ... */}
try {
let noteCount: int = 0
for (const yielded of proc.processNotes(userNotes[Symbol.iterator](), block)) {
if (++noteCount > MAX_NOTES_PER_BLOCK) {
this.#silence(`Note flood: exceeded ${MAX_NOTES_PER_BLOCK} notes per block`)
return
}
const error = validateNote(yielded)
if (error !== null) {
this.#silence(error)
return
}
const lifecycle = NoteLifecycleEvent.start(
yielded.position, yielded.duration, yielded.pitch, yielded.velocity, yielded.cent ?? 0)
this.#retainer.addAndRetain({...lifecycle})
yield lifecycle
// Track source→output: find which upstream start produced this note
// For 1:1 transforms (same iteration order), pair by index
// For 1:N generators, associate all yields between two input consumptions
}
} catch (err) {
this.#silence(`Runtime error: ${err}`)
return
}
// Phase 5: Release any output notes that completed within this block
for (const event of this.#retainer.releaseLinearCompleted(to)) {
yield NoteLifecycleEvent.stop(event, event.position + event.duration)
}
}
* iterateActiveNotesAt(position: ppqn, _onlyExternal: boolean): IterableIterator<NoteEvent> {
yield* this.#retainer.overlapping(position, NoteEvent.Comparator)
}
reset(): void {
this.#retainer.clear()
this.#sourceToOutput.clear()
this.eventInput.clear()
}
processEvents(_block: Block, _from: ppqn, _to: ppqn): void {}
parameterChanged(_parameter: AutomatableParameter): void {}
handleEvent(_block: Block, _event: Event): void {}
index(): number {return this.#adapter.indexField.getValue()}
adapter(): WerkstattMidiDeviceBoxAdapter {return this.#adapter}
}
```
### Key Design Decisions
**`iterateActiveNotesAt` is always the retainer.** No user code involved. The retainer holds exactly the notes the user has yielded that haven't expired or been stopped. `overlapping(position)` filters to notes where `note.position <= position < note.position + note.duration`. Always returns from retainer regardless of `onlyExternal` — unlike Arpeggio (which generates time-stepped patterns unrelated to input), Werkstatt MIDI effects produce notes that are derived from or are the input notes, so they should always be visible.
**Stop propagation from upstream.** When an upstream note stops (e.g., key release during live play), the host releases all output notes derived from it via the `sourceToOutput` map. For sequenced content with known durations, notes also expire naturally via `releaseLinearCompleted`. Both paths are needed — duration handles the normal case, stop propagation handles external/audition notes.
**The user never sees stop events.** The `notes` iterator only contains start events. The host handles the entire lifecycle: starts enter the retainer, stops are emitted when duration expires or upstream stops.
**Note flood protection.** `MAX_NOTES_PER_BLOCK = 100`. If the user's generator yields more than 100 notes in a single block, the processor silences with an error message. Prevents runaway loops from freezing the audio thread.
**Error reporting and validation.** Same mechanism as audio Werkstatt: `engineToClient.deviceMessage(uuid, message)` sends errors to the editor, which subscribes via `engine.subscribeDeviceMessage(uuid, observer)` and displays them inline. The host validates every yielded note for: missing fields, NaN, pitch out of 0127, velocity out of 01, non-positive duration, invalid position. On validation failure, runtime exception, or note flood: report the error, silence, wait for recompile.
---
## Examples
### Velocity Curve
```typescript
// @param amount 0 1 0.5 linear
class Processor {
amount = 0.5
paramChanged(name, value) {
if (name === "amount") this.amount = value
}
* processNotes(notes, block) {
for (const note of notes) {
yield { ...note, velocity: Math.pow(note.velocity, 1 + this.amount) }
}
}
}
```
### Note Filter — Pitch Range
```typescript
// @param low 0 127 36 int
// @param high 0 127 84 int
class Processor {
low = 36
high = 84
paramChanged(name, value) {
if (name === "low") this.low = value
if (name === "high") this.high = value
}
* processNotes(notes, block) {
for (const note of notes) {
if (note.pitch >= this.low && note.pitch <= this.high) {
yield note
}
}
}
}
```
### Chord Generator
```typescript
// @param mode 0 3 0 int
class Processor {
intervals = [[0, 4, 7], [0, 3, 7], [0, 4, 7, 11], [0, 3, 7, 10]]
mode = 0
paramChanged(name, value) {
if (name === "mode") this.mode = value
}
* processNotes(notes, block) {
for (const note of notes) {
for (const interval of this.intervals[this.mode]) {
yield { ...note, pitch: note.pitch + interval }
}
}
}
}
```
### Random Humanizer
```typescript
// @param timing 0 50 10 linear
// @param velRange 0 0.3 0.1 linear
class Processor {
timing = 10
velRange = 0.1
paramChanged(name, value) {
if (name === "timing") this.timing = value
if (name === "velRange") this.velRange = value
}
* processNotes(notes, block) {
for (const note of notes) {
yield {
...note,
position: note.position + (Math.random() - 0.5) * this.timing,
velocity: Math.max(0, Math.min(1, note.velocity + (Math.random() - 0.5) * this.velRange))
}
}
}
}
```
### Probability Gate
```typescript
// @param chance 0 1 0.5 linear
class Processor {
chance = 0.5
paramChanged(name, value) {
if (name === "chance") this.chance = value
}
* processNotes(notes, block) {
for (const note of notes) {
if (Math.random() < this.chance) {
yield note
}
}
}
}
```
### Echo / Note Delay
```typescript
// @param repeats 1 8 3 int
// @param delay 24 480 120 int
// @param decay 0.1 1.0 0.7 linear
class Processor {
repeats = 3
delay = 120
decay = 0.7
paramChanged(name, value) {
if (name === "repeats") this.repeats = value
if (name === "delay") this.delay = value
if (name === "decay") this.decay = value
}
* processNotes(notes, block) {
for (const note of notes) {
for (let i = 0; i < this.repeats; i++) {
yield {
...note,
position: note.position + i * this.delay,
velocity: note.velocity * Math.pow(this.decay, i)
}
}
}
}
}
```
---
## Architecture
### Forge Schema
Separate box schema, shared custom fields with audio Werkstatt:
```typescript
const WerkstattFields = {
10: {type: "string", name: "code", value: ""},
11: {type: "field", name: "parameters", pointerRules: {accepts: [Pointers.Parameter], mandatory: false}}
} as const satisfies FieldRecord<Pointers>
// Existing (audio effect)
export const WerkstattDeviceBox = DeviceFactory.createAudioEffect("WerkstattDeviceBox", WerkstattFields)
// New (midi effect)
export const WerkstattMidiDeviceBox = DeviceFactory.createMidiEffect("WerkstattMidiDeviceBox", WerkstattFields)
```
Three separate schemas are necessary because the device type system is deeply structural: different host pointer types, different common fields (effects have `index`, instruments have `icon`), different tags, different adapter interfaces, different processor factories, and different chain wiring. A unified box would require rewriting the entire device dispatch architecture. Both appear as "Werkstatt" in the UI via `box.label.setValue("Werkstatt")`.
Reuses `WerkstattParameterBox` from audio Werkstatt — same `// @param` format, same reconciliation.
### Compiler
Reuses `WerkstattCompiler` infrastructure. Different registry namespace:
```javascript
globalThis.openDAW.werkstattMidiProcessors["<uuid>"] = {
version: 42,
create: (function werkstattMidi() {
class Processor { /* user code */ }
return Processor
})()
}
```
### Editor
Reuses `CodeEditor` component and `DeviceEditor` shell. Error display via `engine.subscribeDeviceMessage(uuid, observer)` — same as audio Werkstatt. No peak meter (MIDI has no audio output). Could show note activity indicator via `NoteBroadcaster`.
---
## Open Questions
### 1. Source-to-Output Tracking for 1:N Generators
For 1:1 transforms, the `sourceToOutput` mapping is straightforward — same iteration order pairs input to output. For generators that yield multiple notes per input (chord generator), we need to track which yields came from which input note.
Approach: wrap the user's input iterator so the host knows when the user consumes each input note. All output notes yielded between consuming input N and input N+1 are associated with input N's id. This lets the host release all chord notes when the original note stops.
### 2. Shared Compiler Infrastructure
Extract `// @param` parsing and box reconciliation from audio Werkstatt into a shared module so both audio and MIDI Werkstatt reuse it.
---
## Implementation Order
1. **Extract shared compiler/param infrastructure** from audio Werkstatt
2. **Extract shared `WerkstattFields`** into a common constant
3. **Forge schema**: `WerkstattMidiDeviceBox` + box visitor + adapter
4. **Host processor**: `WerkstattMidiDeviceProcessor` with retainer + sourceToOutput tracking
5. **Factory registration**: create, adapter, editor
6. **Editor**: reuse CodeEditor, error display, default passthrough code
7. **Test**: verify `iterateActiveNotesAt` correctness with Zeitgeist in chain, test note flood protection, test upstream stop propagation