feat: improve ai instrument editors

This commit is contained in:
kxkm
2026-04-05 00:03:36 +02:00
parent 301269e11d
commit 8d9eea6a99
12 changed files with 650 additions and 119 deletions
+2 -2
View File
@@ -37,7 +37,7 @@ Ces instruments tournent dans le browser via AudioWorklet — pas de serveur né
| **Glitch** | Générateur de textures glitch | Buffer repeat, stutter, bit crush, downsample, reverse, pitch shift |
| **Circus** | Orgue de barbarie / calliope | 4 registres (Flute 8', Flute 4', Principal, Mixture), tremolo, wobble, air |
| **Honk** | Klaxon / sirène / corne | 3 modes (Klaxon, Siren, Horn), sweep paramétrique, harmoniques |
| **Magenta** | AI MIDI generator | Magenta.js browser-side (MelodyRNN, DrumsRNN, ImprovRNN), temperature, steps |
| **Magenta** | Local algorithmic MIDI generator | Browser-side heuristic generation modes (Melody, Drums, Improv), temperature, steps |
## Instruments AI-backed
@@ -45,7 +45,7 @@ Ces instruments génèrent de l'audio via le serveur AI Bridge (`:8301`).
| Instrument | Backend | Description |
|---|---|---|
| **AceStep** | ACE-Step | Génération musicale IA par prompt (style + bpm + durée) |
| **AceStep** | ACE-Step / AI Bridge | Génération audio par prompt, import automatique en sample, local-first puis fallback bridge |
| **KokoroTTS** | Kokoro | Synthèse vocale rapide — 12 voix (af_heart, am_adam, bf_emma...) |
| **Piper** | Piper/Chatterbox | Synthèse vocale TTS avec personas |
@@ -1,6 +1,6 @@
# IA AceStep
An AI music generator that creates audio from text prompts.
An AI audio generator that turns text prompts into playable samples.
**Track type: Audio** — generated samples appear as regions on the timeline.
@@ -8,7 +8,7 @@ An AI music generator that creates audio from text prompts.
## 0. Overview
_IA AceStep_ generates music from text descriptions using the ACE-Step or MusicGen AI backend. The generated WAV is loaded as a playable sample. Use the AI panel to describe the music you want.
_IA AceStep_ generates music from text descriptions and imports the rendered WAV into the device as a playable sample. The editor prefers a local ACE-Step backend when available, then falls back to the shared AI Bridge workflow.
Example uses:
@@ -54,13 +54,19 @@ Select from 14 presets: ambient, electronic, experimental, classical, jazz, indu
### 2.3 Composer
Click to start generation. A progress bar shows the status. Generation takes 20-60 seconds depending on duration.
Click to start generation. The UI reports provider detection, rendering progress and sample import state.
---
## 3. Backend
Uses ACE-Step (port 9200) when available, falls back to MusicGen (port 9000) via the AI Bridge (port 8301).
Preferred path:
- Local ACE-Step backend (`127.0.0.1:9200`)
Fallback path:
- AI Bridge (`:8301`), which itself can route to ACE-Step or other backends
The resulting audio is imported into the project and attached to the device sample slot automatically.
---
@@ -1,12 +1,12 @@
# IA Magenta
An algorithmic MIDI sequence generator with built-in polyphonic synthesizer.
A local algorithmic MIDI sequence generator with built-in polyphonic synthesizer.
---
## 0. Overview
_IA Magenta_ generates MIDI note sequences using algorithmic composition and plays them through an integrated synthesizer. Three models offer different musical styles. No external connection required — all generation runs locally.
_IA Magenta_ generates MIDI note sequences using local algorithmic composition and plays them through an integrated synthesizer. Three generation modes offer different musical behaviors. No external connection is required.
Example uses:
@@ -35,12 +35,12 @@ Synth amplitude envelope.
Low-pass filter frequency.
### 1.5 Model
### 1.5 Mode
Generation algorithm:
- **Melody** — minor scale patterns
- **Drums** — kick, snare, hihat, ride, crash, tom
- **Improv** — pentatonic with chromatic variations
Generation mode:
- **Melody** — melodic patterns
- **Drums** — simple drum hits and accents
- **Improv** — freer melodic motion with more variation
### 1.6 Temperature
@@ -62,13 +62,17 @@ Stereo panning spread based on note pitch.
## 2. Generation
1. Set Model, Temperature, Steps and Density
1. Set Mode, Temperature, Steps and Density
2. Click **Generate** to create a sequence
3. Click **Play** to listen
4. Click **Stop** to halt playback
MIDI notes also trigger the built-in synth directly.
## 3. Execution Model
Generation is local and heuristic. This device does not depend on an external AI bridge or a remote model backend in its current form.
---
_IA instrument — openDIAW.be / KXKM_
@@ -0,0 +1,145 @@
export type AceStepGenerationState =
| "idle"
| "checking"
| "generating"
| "importing"
| "ready"
| "error"
export type AceStepGenerationRequest = {
prompt: string
style: string
durationSec: number
bpm: number
}
export type AceStepGenerationStatus = {
state: AceStepGenerationState
message: string
detail: string
progress: number
provider?: "ace-step-local" | "ai-bridge"
}
export type AceStepGenerationResult = {
audio: Blob
durationSec: number
provider: "ace-step-local" | "ai-bridge"
}
const DEFAULT_PROMPT = "ambient electronic"
const AI_BRIDGE = `${location.protocol === "https:" ? "https:" : "http:"}//${location.hostname}:8301`
const ACE_STEP_URL = "http://127.0.0.1:9200"
export class AceStepGenerationService {
async generateMusic(
request: AceStepGenerationRequest,
report: (status: AceStepGenerationStatus) => void
): Promise<AceStepGenerationResult> {
const prompt = request.prompt.trim() || DEFAULT_PROMPT
const durationSec = Math.round(request.durationSec)
report({
state: "checking",
message: "Verification du moteur local",
detail: "Recherche d'un backend ACE-Step local...",
progress: 10
})
const local = await this.#generateFromLocal({
prompt,
style: request.style,
durationSec,
bpm: request.bpm
}, report)
if (local !== null) {
return local
}
report({
state: "generating",
message: "Generation via AI Bridge",
detail: "Fallback vers le bridge audio partage.",
progress: 25,
provider: "ai-bridge"
})
const bridgeResponse = await fetch(`${AI_BRIDGE}/generate/music`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
prompt: `${prompt}, ${request.style}`,
duration: durationSec,
style: request.style,
bpm: request.bpm
}),
signal: AbortSignal.timeout(300000)
})
if (!bridgeResponse.ok) {
throw new Error(`AI Bridge HTTP ${bridgeResponse.status}`)
}
report({
state: "generating",
message: "Reception de l'audio",
detail: "Le rendu audio est pret a etre importe.",
progress: 90,
provider: "ai-bridge"
})
return {
audio: await bridgeResponse.blob(),
durationSec,
provider: "ai-bridge"
}
}
async #generateFromLocal(
request: AceStepGenerationRequest,
report: (status: AceStepGenerationStatus) => void
): Promise<AceStepGenerationResult | null> {
const health = await fetch(`${ACE_STEP_URL}/health`, {
signal: AbortSignal.timeout(2000)
}).then(async response => response.ok ? response.json() : null).catch(() => null)
if (health?.ok !== true) {
return null
}
report({
state: "generating",
message: "Generation locale",
detail: `ACE-Step local • ${request.bpm} BPM • ${request.durationSec}s`,
progress: 25,
provider: "ace-step-local"
})
const localResponse = await fetch(`${ACE_STEP_URL}/generate`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
prompt: `${request.prompt}, ${request.style}`,
duration: request.durationSec,
steps: 8,
bpm: request.bpm
}),
signal: AbortSignal.timeout(300000)
})
if (!localResponse.ok) {
throw new Error(`ACE-Step local HTTP ${localResponse.status}`)
}
report({
state: "generating",
message: "Reception de l'audio",
detail: "Le rendu local est pret a etre importe.",
progress: 90,
provider: "ace-step-local"
})
return {
audio: await localResponse.blob(),
durationSec: request.durationSec,
provider: "ace-step-local"
}
}
}
@@ -38,7 +38,7 @@ import {RouteLocation} from "@opendaw/lib-jsx"
import {PPQN} from "@opendaw/lib-dsp"
import {AnimationFrame, Browser, ConsoleCommands, Dragging, Files} from "@opendaw/lib-dom"
import {Promises} from "@opendaw/lib-runtime"
import {ExportStemsConfiguration, InstrumentFactories, PresetDecoder} from "@opendaw/studio-adapters"
import {ExportStemsConfiguration, InstrumentFactories, PresetDecoder, Sample} from "@opendaw/studio-adapters"
import {Address} from "@opendaw/lib-box"
import {
AudioContentFactory,
@@ -71,6 +71,7 @@ import {SoftwareMIDIPanel} from "@/ui/software-midi/SoftwareMIDIPanel"
import {Mixdowns} from "@/service/Mixdowns"
import {ShadertoyState} from "@/ui/shadertoy/ShadertoyState"
import {CodeEditorState} from "@/ui/werkstatt-editor/CodeEditorState"
import {AceStepGenerationService} from "@/service/AceStepGenerationService"
/**
* I am just piling stuff after stuff in here to boot the environment.
@@ -106,6 +107,7 @@ export class StudioService implements ProjectEnv {
readonly panelLayout = new PanelContents(createPanelFactory(this))
readonly spotlightDataSupplier = new SpotlightDataSupplier()
readonly samplePlayback: SamplePlayback
readonly aceStepGeneration = new AceStepGenerationService()
readonly recovery = new Recovery(() => this.#projectProfileService.getValue(), this)
readonly engine = new EngineFacade()
@@ -130,12 +132,12 @@ export class StudioService implements ProjectEnv {
readonly cloudAuthManager: CloudAuthManager,
readonly buildInfo: BuildInfo) {
this.#sampleService = new SampleService(audioContext)
this.#sampleService.subscribe(([sample, _]) => this.#signals.notify({
this.#sampleService.subscribe(([sample]) => this.#signals.notify({
type: "import-sample",
sample
}))
this.#soundfontService = new SoundfontService()
this.#soundfontService.subscribe(([soundfont, _]) => this.#signals.notify({
this.#soundfontService.subscribe(([soundfont]) => this.#signals.notify({
type: "import-soundfont",
soundfont
}))
@@ -222,6 +224,11 @@ export class StudioService implements ProjectEnv {
})
}
async importGeneratedSample(name: string, audio: Blob): Promise<Sample> {
const arrayBuffer = await audio.arrayBuffer()
return this.#sampleService.importFile({name, arrayBuffer})
}
async exportStems() {
return this.#projectProfileService.getValue()
.ifSome(async (profile) => {
@@ -562,4 +569,4 @@ export class StudioService implements ProjectEnv {
StudioPreferences.catchupAndSubscribe(value =>
document.body.classList.toggle("scrollbar-padding", value), "visibility", "scrollbar-padding")
}
}
}
@@ -41,11 +41,11 @@ export const Dashboard = ({lifecycle, service}: Construct) => {
href="https://github.com/andremichelle/openDAW" target="upstream">openDAW</a> cree
pour le spectacle <em>3615 J'ai pete</em> de la compagnie <a
href="https://kxkm.net" target="kxkm">KXKM</a>.
Il ajoute <strong>9 instruments AI</strong> (Drone, Grain, Glitch, Circus, Honk, Magenta,
Il ajoute <strong>9 instruments IA / generatifs</strong> (Drone, Grain, Glitch, Circus, Honk, Magenta,
AceStep, KokoroTTS, Piper) et un <strong>AI Bridge</strong> avec 19 backends audio.
</p>
<p style={{margin: "0.5em 0", fontSize: "11px"}}>
Instruments de clown | Generation IA | TTS 12 voix | MIDI Magenta.js | 19 backends audio
Instruments de clown | Generation locale et IA assistee | TTS 12 voix | MIDI algorithmique | 19 backends audio
</p>
<div className="columns">
<DemoProjects lifecycle={lifecycle} service={service}/>
@@ -1,9 +1,91 @@
@use "@/mixins"
component
@include mixins.ControlLayout(1)
@include mixins.Control
display: grid
grid-template-columns: repeat(2, minmax(0, auto))
gap: 0.5em
align-content: start
> div.sample-drop
> section.hero,
> section.section
background: rgba(white, 0.03)
box-shadow: inset 0 0 0 1px rgba(white, 0.04)
border-radius: 0.35em
padding: 0.45em
display: flex
flex-direction: column
gap: 0.35em
min-width: 0
pointer-events: all
> section.hero
grid-column: 1 / -1
> span.eyebrow
font-size: 0.45em
letter-spacing: 0.12em
text-transform: uppercase
color: rgba(white, 0.45)
> h3
margin: 0
font-size: 0.9em
color: var(--color-text)
> p
margin: 0
font-size: 0.5em
line-height: 1.4
color: rgba(white, 0.55)
> section.compact
grid-column: span 1
> section.sample-section,
> section.ai-panel,
> section.note
grid-column: 1 / -1
> section.note > p
margin: 0
font-size: 0.5em
line-height: 1.4
color: rgba(white, 0.55)
> section.section
> div.section-header
display: flex
flex-direction: column
gap: 0.15em
> h4
margin: 0
font-size: 0.55em
text-transform: uppercase
letter-spacing: 0.08em
color: var(--color-cream)
> span
font-size: 0.45em
color: rgba(white, 0.4)
> div.control-row
display: flex
flex-wrap: wrap
gap: 0.25em
label
display: flex
flex-direction: column
gap: 0.15em
font-size: 0.45em
color: rgba(white, 0.55)
> section.ai-panel
min-width: 14em
> section.sample-section > div.sample-drop
border-radius: 50%
color: var(--color-shadow)
display: flex
@@ -11,10 +93,12 @@ component
align-items: center
justify-content: center
outline: 1px dashed rgba(white, 0.1)
margin: 0.375em
position: relative
cursor: pointer
pointer-events: all
min-width: 3.25em
min-height: 3.25em
align-self: flex-start
> svg
width: 2em
@@ -39,17 +123,14 @@ component
color: var(--color-black)
background-color: var(--color-blue)
> div.ai-panel
display: flex
flex-direction: column
gap: 0.25em
margin: 0.375em
padding: 0.25em
pointer-events: all
min-width: 10em
border-left: 1px solid rgba(white, 0.08)
> section.sample-section > span.detail
font-size: 0.4em
color: rgba(white, 0.45)
min-height: 1em
> textarea, > input[type="text"]
> section.ai-panel
textarea,
input[type="text"]
font-size: 0.5em
font-family: inherit
width: 100%
@@ -68,7 +149,7 @@ component
&::placeholder
color: rgba(white, 0.3)
> select
select
font-size: 0.5em
font-family: inherit
padding: 0.2em 0.3em
@@ -107,10 +188,23 @@ component
opacity: 0.35
cursor: default
> span.status
font-size: 0.4em
color: rgba(white, 0.4)
min-height: 1em
> div.status-block
display: flex
flex-direction: column
gap: 0.1em
padding: 0.2em 0.25em
border-radius: 0.25em
background: rgba(0, 0, 0, 0.18)
> span.status
font-size: 0.45em
color: var(--color-text)
min-height: 1em
> span.detail
font-size: 0.4em
color: rgba(white, 0.45)
min-height: 1em
> div.progress-bar
height: 2px
@@ -1,5 +1,5 @@
import css from "./AceStepDeviceEditor.sass?inline"
import {asInstanceOf, Lifecycle} from "@opendaw/lib-std"
import {asInstanceOf, Lifecycle, UUID} from "@opendaw/lib-std"
import {createElement} from "@opendaw/lib-jsx"
import {DeviceEditor} from "@/ui/devices/DeviceEditor.tsx"
import {MenuItems} from "@/ui/devices/menu-items.ts"
@@ -12,10 +12,11 @@ import {AudioFileBox} from "@opendaw/studio-boxes"
import {Icon} from "@/ui/components/Icon"
import {SampleSelector, SampleSelectStrategy} from "@/ui/devices/SampleSelector"
import {StudioService} from "@/service/StudioService"
import {AceStepGenerationState} from "@/service/AceStepGenerationService"
const className = Html.adoptStyleSheet(css, "AceStepDeviceEditor")
const AI_BRIDGE = (location.protocol === "https:" ? "https:" : "http:") + "//" + location.hostname + ":8301"
const STYLES = ["ambient","electronic","experimental","classical","jazz","industrial","drone","glitch","techno","house","drum-n-bass","lo-fi","cinematic","world"] as const
const STYLES = ["ambient", "electronic", "experimental", "classical", "jazz", "industrial", "drone", "glitch", "techno", "house", "drum-n-bass", "lo-fi", "cinematic", "world"] as const
void createElement
type Construct = { lifecycle: Lifecycle; service: StudioService; adapter: AceStepDeviceBoxAdapter; deviceHost: DeviceHost }
@@ -25,63 +26,146 @@ export const AceStepDeviceEditor = ({lifecycle, service, adapter, deviceHost}: C
const {editing, midiLearning} = project
const knob = (parameter: typeof volume) => ControlBuilder.createKnob({lifecycle, editing, midiLearning, adapter, parameter})
const sampleDropZone: HTMLElement = (<div className="sample-drop"><Icon symbol={IconSymbol.Waveform}/></div>)
const sampleMetaEl: HTMLElement = <span className="detail">Aucun sample genere pour l'instant.</span>
const sampleSelector = new SampleSelector(service, SampleSelectStrategy.forPointerField(adapter.box.file))
lifecycle.ownAll(
adapter.box.file.catchupAndSubscribe(pointer => pointer.targetVertex.match({
none: () => sampleDropZone.removeAttribute("sample"),
some: ({box}) => sampleDropZone.setAttribute("sample", asInstanceOf(box, AudioFileBox).fileName.getValue())
none: () => {
sampleDropZone.removeAttribute("sample")
sampleMetaEl.textContent = "Aucun sample genere pour l'instant."
},
some: ({box}) => {
const fileName = asInstanceOf(box, AudioFileBox).fileName.getValue()
sampleDropZone.setAttribute("sample", fileName)
sampleMetaEl.textContent = `Cible actuelle: ${fileName}`
}
})),
sampleSelector.configureBrowseClick(sampleDropZone),
sampleSelector.configureContextMenu(sampleDropZone),
sampleSelector.configureDrop(sampleDropZone)
)
const promptArea: HTMLTextAreaElement = <textarea rows={3} placeholder="Prompt musical...&#10;Ex: dark ambient with metallic textures and slow drones"/> as HTMLTextAreaElement
const styleSelect: HTMLSelectElement = (<select>{STYLES.map(s => <option value={s}>{s}</option>)}</select>) as HTMLSelectElement
const statusEl: HTMLElement = <span className="status"/>
const styleSelect: HTMLSelectElement = (<select>{STYLES.map(style => <option value={style}>{style}</option>)}</select>) as HTMLSelectElement
styleSelect.value = STYLES[0]
const statusEl: HTMLElement = <span className="status">Pret a composer</span>
const detailEl: HTMLElement = <span className="detail">Generation locale en priorite, puis AI Bridge si necessaire.</span>
const progressEl: HTMLElement = <div className="progress-bar"><div className="progress-fill"/></div>
const genBtn: HTMLButtonElement = <button>Composer</button> as HTMLButtonElement
const providerEl: HTMLElement = <span className="detail">Provider: en attente</span>
const genBtn: HTMLButtonElement = <button>Composer une boucle</button> as HTMLButtonElement
let busy = false
const fill = progressEl.querySelector(".progress-fill") as HTMLElement
const setUiState = (state: AceStepGenerationState, status: string, detail: string, progress: number, provider?: string) => {
statusEl.textContent = status
detailEl.textContent = detail
providerEl.textContent = `Provider: ${provider ?? "en attente"}`
fill.style.width = `${progress}%`
genBtn.classList.toggle("generating", state === "checking" || state === "generating" || state === "importing")
}
genBtn.addEventListener("click", async () => {
if (busy) return
busy = true; genBtn.disabled = true; genBtn.classList.add("generating"); genBtn.textContent = "Composition..."
statusEl.textContent = ""
const fill = progressEl.querySelector(".progress-fill") as HTMLElement
fill.style.width = "10%"
busy = true
genBtn.disabled = true
genBtn.textContent = "Composition..."
setUiState("checking", "Generation en cours", "Verification des providers disponibles...", 10)
try {
const prompt = promptArea.value.trim() || "ambient electronic"
const style = styleSelect.value
const bpm = Math.round(tempo.getValue())
const dur = Math.round(duration.getValue())
fill.style.width = "20%"
const resp = await fetch(`${AI_BRIDGE}/generate/music`, {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({prompt: `${prompt}, ${style}`, duration: dur, style, bpm}),
signal: AbortSignal.timeout(300000)
const generation = await service.aceStepGeneration.generateMusic({
prompt,
style,
bpm,
durationSec: dur
}, update => {
setUiState(update.state, update.message, update.detail, update.progress, update.provider)
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
fill.style.width = "90%"
const blob = await resp.blob()
statusEl.textContent = `OK — ${style} ${bpm}bpm (${(blob.size / 176400).toFixed(0)}s)`
fill.style.width = "100%"
setUiState("importing", "Import du sample", `${style} • ${bpm} BPM • ${generation.durationSec}s`, 95, generation.provider)
const sample = await service.importGeneratedSample(
`AceStep ${style} ${new Date().toISOString().slice(11, 19).replace(/:/g, "-")}`,
generation.audio
)
service.project.trackUserCreatedSample(UUID.parse(sample.uuid))
sampleSelector.newSample(sample)
setUiState("ready", "Composition terminee", `${style} • ${bpm} BPM • ${sample.duration.toFixed(1)}s d'audio`, 100, generation.provider)
} catch (err) {
statusEl.textContent = `Erreur: ${err instanceof Error ? err.message : "echec"}`
fill.style.width = "0%"
setUiState("error", "Echec de la composition", err instanceof Error ? err.message : "Generation interrompue", 0)
} finally {
busy = false; genBtn.disabled = false; genBtn.classList.remove("generating"); genBtn.textContent = "Composer"
busy = false
genBtn.disabled = false
genBtn.textContent = "Composer une boucle"
setTimeout(() => { fill.style.width = "0%" }, 3000)
}
})
return (
<DeviceEditor lifecycle={lifecycle} project={project} adapter={adapter}
populateMenu={parent => MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
<div className={className}>
{knob(volume)}{knob(release)}{knob(tempo)}{knob(duration)}{sampleDropZone}
<div className="ai-panel">
{promptArea}{styleSelect}
<section className="hero">
<span className="eyebrow">Generation audio IA</span>
<h3>AceStep</h3>
<p>Generez un sample musical depuis un prompt, en priorite avec un moteur local puis via AI Bridge si besoin.</p>
</section>
<section className="section compact">
<div className="section-header">
<h4>Sortie</h4>
<span>niveau et relache</span>
</div>
<div className="control-row">
{knob(volume)}
{knob(release)}
</div>
</section>
<section className="section compact">
<div className="section-header">
<h4>Cadence</h4>
<span>tempo et duree</span>
</div>
<div className="control-row">
{knob(tempo)}
{knob(duration)}
</div>
</section>
<section className="section sample-section">
<div className="section-header">
<h4>Destination audio</h4>
<span>sample charge ou depot glisse</span>
</div>
{sampleDropZone}
{sampleMetaEl}
</section>
<section className="section ai-panel">
<div className="section-header">
<h4>Brief musical</h4>
<span>prompt, style, provider et lancement</span>
</div>
<label>
<span>Prompt</span>
{promptArea}
</label>
<label>
<span>Style</span>
{styleSelect}
</label>
<div className="ai-controls">{genBtn}</div>
{progressEl}{statusEl}
</div>
{progressEl}
<div className="status-block">
{statusEl}
{detailEl}
{providerEl}
</div>
</section>
<section className="section note">
<div className="section-header">
<h4>Usage</h4>
</div>
<p>La generation essaye d'abord ACE-Step local, puis bascule vers AI Bridge. Le rendu importe ensuite automatiquement un sample dans le device.</p>
</section>
</div>
)}
populateMeter={() => (<DevicePeakMeter lifecycle={lifecycle} receiver={project.liveStreamReceiver} address={adapter.address}/>)}
@@ -1,18 +1,83 @@
@use "@/mixins"
component
@include mixins.ControlLayout(2)
@include mixins.Control
display: grid
grid-template-columns: repeat(2, minmax(0, auto))
gap: 0.5em
align-content: start
> div.magenta-panel
> section.hero,
> section.section
background: rgba(white, 0.03)
box-shadow: inset 0 0 0 1px rgba(white, 0.04)
border-radius: 0.35em
padding: 0.45em
display: flex
flex-direction: column
align-items: center
gap: 0.25em
padding: 0.25em
margin: 0.375em
gap: 0.35em
min-width: 0
pointer-events: all
> button
> section.hero
grid-column: 1 / -1
> span.eyebrow
font-size: 0.45em
letter-spacing: 0.12em
text-transform: uppercase
color: rgba(white, 0.45)
> h3
margin: 0
font-size: 0.9em
color: var(--color-text)
> p
margin: 0
font-size: 0.5em
line-height: 1.4
color: rgba(white, 0.55)
> section.section
> div.section-header
display: flex
flex-direction: column
gap: 0.15em
> h4
margin: 0
font-size: 0.55em
text-transform: uppercase
letter-spacing: 0.08em
color: var(--color-cream)
> span
font-size: 0.45em
color: rgba(white, 0.4)
> div.control-row
display: flex
flex-wrap: wrap
gap: 0.25em
> section.magenta-panel,
> section.note
grid-column: 1 / -1
> section.note > p
margin: 0
font-size: 0.5em
line-height: 1.4
color: rgba(white, 0.55)
> section.magenta-panel
> div.actions
display: flex
flex-wrap: wrap
gap: 0.25em
> div.actions > button
font-size: 0.6em
padding: 0.2em 0.6em
border-radius: 0.3em
@@ -26,16 +91,24 @@ component
&:hover
background: rgba(white, 0.15)
&.active
background: var(--color-blue)
border-color: var(--color-blue)
color: var(--color-black)
&:disabled
opacity: 0.4
cursor: default
> span.status
font-size: 0.45em
color: rgba(white, 0.5)
white-space: nowrap
> div.status-block
display: flex
flex-direction: column
gap: 0.1em
padding: 0.2em 0.25em
border-radius: 0.25em
background: rgba(0, 0, 0, 0.18)
> span.status
font-size: 0.45em
color: var(--color-text)
min-height: 1em
> span.detail
font-size: 0.4em
color: rgba(white, 0.45)
min-height: 1em
@@ -10,7 +10,7 @@ import {Html} from "@opendaw/lib-dom"
import {StudioService} from "@/service/StudioService"
const className = Html.adoptStyleSheet(css, "MagentaDeviceEditor")
void createElement
type Construct = {
lifecycle: Lifecycle
service: StudioService
@@ -18,7 +18,6 @@ type Construct = {
deviceHost: DeviceHost
}
// Built-in algorithmic MIDI generation (no external CDN needed)
const SCALES: Record<string, number[]> = {
minor: [0, 2, 3, 5, 7, 8, 10],
major: [0, 2, 4, 5, 7, 9, 11],
@@ -29,6 +28,17 @@ const SCALES: Record<string, number[]> = {
const DRUM_PITCHES = [36, 38, 42, 46, 49, 51]
const MODEL_NAMES = ["Melody", "Drums", "Improv"]
const noteToFrequency = (pitch: number): number => 440.0 * Math.pow(2.0, (pitch - 69.0) / 12.0)
const waveformTypeAt = (value: number): OscillatorType => ([
"sine",
"triangle",
"sawtooth",
"square"
] as const)[Math.max(0, Math.min(3, Math.round(value)))]
const dbToGain = (db: number): number => Math.pow(10.0, db / 20.0)
function generateSequence(modelIdx: number, numSteps: number, temp: number, densityVal: number): {notes: Array<{pitch: number, quantizedStartStep: number, quantizedEndStep: number}>, totalQuantizedSteps: number} {
const notes: Array<{pitch: number, quantizedStartStep: number, quantizedEndStep: number}> = []
const isDrums = modelIdx === 1
@@ -47,8 +57,8 @@ function generateSequence(modelIdx: number, numSteps: number, temp: number, dens
pitch = Math.max(36, Math.min(84, octave * 12 + scale[scaleIdx]))
prevPitch = pitch
}
const duration = isDrums ? 1 : Math.max(1, Math.floor(Math.random() * temp * 3) + 1)
notes.push({pitch, quantizedStartStep: step, quantizedEndStep: Math.min(step + duration, numSteps)})
const noteDuration = isDrums ? 1 : Math.max(1, Math.floor(Math.random() * temp * 3) + 1)
notes.push({pitch, quantizedStartStep: step, quantizedEndStep: Math.min(step + noteDuration, numSteps)})
}
return {notes, totalQuantizedSteps: numSteps}
}
@@ -59,71 +69,179 @@ export const MagentaDeviceEditor = ({lifecycle, service, adapter, deviceHost}: C
const {editing, midiLearning} = project
const knob = (parameter: typeof volume) => ControlBuilder.createKnob({lifecycle, editing, midiLearning, adapter, parameter})
const statusEl: HTMLElement = <span className="status">Ready</span>
const generateBtn: HTMLButtonElement = <button>Generate</button> as HTMLButtonElement
const playBtn: HTMLButtonElement = <button disabled>Play</button> as HTMLButtonElement
const statusEl: HTMLElement = <span className="status">Pret a generer</span>
const detailEl: HTMLElement = <span className="detail">Choisissez un mode algorithmique puis lancez une sequence locale.</span>
const generateBtn: HTMLButtonElement = <button>Generer une sequence</button> as HTMLButtonElement
const playBtn: HTMLButtonElement = <button disabled>Ecouter</button> as HTMLButtonElement
const stopBtn: HTMLButtonElement = <button disabled>Stop</button> as HTMLButtonElement
const noteCountEl: HTMLElement = <span className="status"/>
let generatedSequence: ReturnType<typeof generateSequence> | null = null
let playTimeouts: Array<number> = []
let activePreviewStops: Array<() => void> = []
let isPlaying = false
const stopPreview = () => {
playTimeouts.forEach(timeout => clearTimeout(timeout))
playTimeouts = []
activePreviewStops.forEach(stop => stop())
activePreviewStops = []
isPlaying = false
playBtn.textContent = "Ecouter"
stopBtn.disabled = true
}
const previewNote = (pitch: number, startMs: number, durationMs: number) => {
const audioContext = service.audioContext
const now = audioContext.currentTime
const startTime = now + startMs / 1000.0
const durationSec = Math.max(durationMs / 1000.0, 0.04)
const stopTime = startTime + durationSec + Math.max(release.getValue(), 0.02) + 0.05
const attackSec = Math.max(attack.getValue(), 0.003)
const releaseSec = Math.max(release.getValue(), 0.02)
const oscillator = audioContext.createOscillator()
const filterNode = audioContext.createBiquadFilter()
const gainNode = audioContext.createGain()
const panNode = audioContext.createStereoPanner()
const modeIdx = Math.round(model.getValue())
const oscType = modeIdx === 1 ? "square" : waveformTypeAt(waveform.getValue())
const gain = Math.min(0.35, dbToGain(volume.getValue()) * 0.18)
const pan = Math.max(-1.0, Math.min(1.0, ((pitch - 60.0) / 24.0) * stereoWidth.getValue()))
oscillator.type = oscType
oscillator.frequency.setValueAtTime(noteToFrequency(pitch), startTime)
filterNode.type = "lowpass"
filterNode.frequency.setValueAtTime(cutoff.getValue(), startTime)
filterNode.Q.setValueAtTime(modeIdx === 1 ? 0.5 : 1.2, startTime)
panNode.pan.setValueAtTime(pan, startTime)
gainNode.gain.setValueAtTime(0.0001, startTime)
gainNode.gain.linearRampToValueAtTime(gain, startTime + attackSec)
gainNode.gain.setValueAtTime(gain, startTime + durationSec)
gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + durationSec + releaseSec)
oscillator.connect(filterNode)
filterNode.connect(gainNode)
gainNode.connect(panNode)
panNode.connect(audioContext.destination)
oscillator.start(startTime)
oscillator.stop(stopTime)
const teardown = () => {
try { oscillator.stop() } catch (_error) { void _error }
oscillator.disconnect()
filterNode.disconnect()
gainNode.disconnect()
panNode.disconnect()
}
oscillator.onended = () => teardown()
activePreviewStops.push(teardown)
}
generateBtn.addEventListener("click", () => {
const modelIdx = Math.round(model.getValue())
const temp = temperature.getValue()
const numSteps = Math.round(steps.getValue())
const dens = density.getValue()
statusEl.textContent = `Generating ${MODEL_NAMES[modelIdx]}...`
statusEl.textContent = `Generation ${MODEL_NAMES[modelIdx]}`
generatedSequence = generateSequence(modelIdx, numSteps, temp, dens)
statusEl.textContent = `${MODEL_NAMES[modelIdx]}: ${generatedSequence.notes.length} notes`
noteCountEl.textContent = `${numSteps} steps, temp ${temp.toFixed(1)}`
statusEl.textContent = `${generatedSequence.notes.length} notes creees`
detailEl.textContent = `${MODEL_NAMES[modelIdx]}${numSteps} steps • generation locale`
playBtn.disabled = false
})
playBtn.addEventListener("click", () => {
if (!generatedSequence || isPlaying) return
void service.audioContext.resume()
stopPreview()
isPlaying = true
playBtn.textContent = "..."
playBtn.textContent = "Lecture..."
stopBtn.disabled = false
const bpm = 120
statusEl.textContent = "Lecture en cours"
const bpm = project.timelineBox.bpm.getValue()
const noteCount = generatedSequence.notes.length
detailEl.textContent = `${noteCount} evenements • ${Math.round(bpm)} BPM`
const msPerStep = (60000 / bpm) / 4
for (const note of generatedSequence.notes) {
const startMs = note.quantizedStartStep * msPerStep
const durationMs = Math.max((note.quantizedEndStep - note.quantizedStartStep) * msPerStep, msPerStep * 0.8)
previewNote(note.pitch, startMs, durationMs)
playTimeouts.push(window.setTimeout(() => {
statusEl.textContent = `Note ${note.pitch}`
detailEl.textContent = `Note ${note.pitch}`
}, startMs))
}
const totalMs = generatedSequence.totalQuantizedSteps * msPerStep
playTimeouts.push(window.setTimeout(() => {
isPlaying = false
playBtn.textContent = "Play"
stopBtn.disabled = true
statusEl.textContent = "Done"
stopPreview()
statusEl.textContent = "Lecture terminee"
detailEl.textContent = `${noteCount} evenements • ${Math.round(bpm)} BPM`
}, totalMs))
})
stopBtn.addEventListener("click", () => {
playTimeouts.forEach(t => clearTimeout(t))
playTimeouts = []
isPlaying = false
playBtn.textContent = "Play"
stopBtn.disabled = true
statusEl.textContent = "Stopped"
stopPreview()
statusEl.textContent = "Lecture arretee"
detailEl.textContent = "La sequence reste disponible pour une nouvelle ecoute."
})
lifecycle.ownAll({terminate: () => { playTimeouts.forEach(t => clearTimeout(t)) }})
lifecycle.ownAll({terminate: () => stopPreview()})
return (
<DeviceEditor lifecycle={lifecycle} project={project} adapter={adapter}
populateMenu={parent => MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
<div className={className}>
{knob(volume)}{knob(waveform)}{knob(attack)}{knob(release)}{knob(cutoff)}
{knob(model)}{knob(temperature)}{knob(steps)}{knob(density)}{knob(stereoWidth)}
<div className="magenta-panel">
{generateBtn}{playBtn}{stopBtn}
{statusEl}{noteCountEl}
</div>
<section className="hero">
<span className="eyebrow">MIDI algorithmique local</span>
<h3>Magenta</h3>
<p>Generez des sequences MIDI localement, puis jouez-les avec le synth integre. Ici, l'accent est sur l'algorithmique plutot que sur l'inference d'un modele externe.</p>
</section>
<section className="section">
<div className="section-header">
<h4>Son</h4>
<span>timbre et envelope</span>
</div>
<div className="control-row">
{knob(volume)}
{knob(waveform)}
{knob(cutoff)}
{knob(attack)}
{knob(release)}
{knob(stereoWidth)}
</div>
</section>
<section className="section">
<div className="section-header">
<h4>Generation</h4>
<span>mode algorithmique et densite</span>
</div>
<div className="control-row">
{knob(model)}
{knob(temperature)}
{knob(steps)}
{knob(density)}
</div>
</section>
<section className="section magenta-panel">
<div className="section-header">
<h4>Transport</h4>
<span>generation, preview audio locale et retour d'etat</span>
</div>
<div className="actions">
{generateBtn}
{playBtn}
{stopBtn}
</div>
<div className="status-block">
{statusEl}
{detailEl}
</div>
</section>
<section className="section note">
<div className="section-header">
<h4>Usage</h4>
</div>
<p>Les memes parametres restent exposes. Le bouton d'ecoute declenche maintenant une vraie preview audio locale synchronisee sur le tempo du projet.</p>
</section>
</div>
)}
populateMeter={() => (<DevicePeakMeter lifecycle={lifecycle} receiver={project.liveStreamReceiver} address={adapter.address}/>)}
@@ -69,7 +69,7 @@ export class MagentaDeviceBoxAdapter implements InstrumentDeviceBoxAdapter {
StringMapping.numeric({unit: "Hz", fractionDigits: 0, unitPrefix: true}), "Cutoff"),
model: this.#parametric.createParameter(
box.model, ValueMapping.linearInteger(0, 2),
StringMapping.indices("", ["MelodyRNN", "DrumsRNN", "ImprovRNN"]), "Model"),
StringMapping.indices("", ["Melody", "Drums", "Improv"]), "Mode"),
temperature: this.#parametric.createParameter(
box.temperature, ValueMapping.linear(0.1, 2.0),
StringMapping.numeric({unit: "", fractionDigits: 2}), "Temp."),
@@ -323,7 +323,7 @@ export namespace InstrumentFactories {
export const Magenta: InstrumentFactory<void, MagentaDeviceBox> = {
defaultName: "IA Magenta",
defaultIcon: IconSymbol.Robot,
description: "[AI] MIDI generator (algorithmic)",
description: "[Local] Algorithmic MIDI generator",
manualPage: DeviceManualUrls.Magenta,
trackType: TrackType.Notes,
create: (boxGraph: BoxGraph,
@@ -342,7 +342,7 @@ export namespace InstrumentFactories {
export const AceStep: InstrumentFactory<AudioFileBox, AceStepDeviceBox> = {
defaultName: "IA AceStep",
defaultIcon: IconSymbol.Flask,
description: "[AI] Music generator (ACE-Step)",
description: "[AI Audio] Prompt-to-sample generator",
manualPage: DeviceManualUrls.AceStep,
trackType: TrackType.Audio,
create: (boxGraph: BoxGraph,
@@ -450,4 +450,4 @@ export namespace InstrumentFactories {
const useSoundfontFile = (boxGraph: BoxGraph, fileUUID: UUID.Bytes, name: string) =>
boxGraph.findBox<SoundfontFileBox>(fileUUID)
.unwrapOrElse(() => SoundfontFileBox.create(boxGraph, fileUUID, box => box.fileName.setValue(name)))
}
}