tone3000 design updates
This commit is contained in:
@@ -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}/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -134,6 +134,7 @@ export enum IconSymbol {
|
||||
TapeReel,
|
||||
Time,
|
||||
Timeline,
|
||||
Tone3000,
|
||||
Tool,
|
||||
Triangle,
|
||||
Undo,
|
||||
|
||||
@@ -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 0–127)`
|
||||
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 0–1)`
|
||||
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 0–127, velocity out of 0–1, 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
|
||||
Reference in New Issue
Block a user