feat: improved AI editors (Kokoro/Piper/AceStep) + welcome page openDIAW.be

- KokoroTTS: textarea + voice dropdown (12 voix) + progress bar
- Piper: textarea + persona dropdown + progress bar
- AceStep: prompt textarea + style dropdown (14 styles) + progress bar
- Dashboard: openDIAW.be branding, fork links, random quotes electron fou
- SASS: enriched ai-panel with textarea, select, progress-bar, generating state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kxkm
2026-03-22 12:56:04 +01:00
parent 20676614ac
commit 4bdc733cab
7 changed files with 387 additions and 273 deletions
@@ -1,6 +1,6 @@
import css from "./Dashboard.sass?inline"
import {Lifecycle} from "@opendaw/lib-std"
import {createElement, LocalLink} from "@opendaw/lib-jsx"
import {createElement} from "@opendaw/lib-jsx"
import {StudioService} from "@/service/StudioService.ts"
import {Html} from "@opendaw/lib-dom"
import {Resources} from "@/ui/dashboard/Resources"
@@ -8,36 +8,62 @@ import {DemoProjects} from "@/ui/dashboard/DemoProjects"
const className = Html.adoptStyleSheet(css, "Dashboard")
const QUOTES = [
'"Le bruit est le son que les autres font." \u2014 Pierre Schaeffer, probablement',
'"Tout son est de la musique. Sauf la musique de mon voisin." \u2014 John Cage (adapt\u00e9)',
'"Si tu peux pas l\'expliquer \u00e0 un clown, c\'est que t\'as pas compris." \u2014 electron rare',
'"Le silence est un instrument qu\'on oublie d\'accorder." \u2014 Eliane Radigue, entre deux drones',
'"Un bug est une feature qui n\'a pas encore trouv\u00e9 son public." \u2014 Pharmacius',
'"La musique, c\'est du bruit organis\u00e9. Le bruit, c\'est de la musique d\u00e9sorganis\u00e9e. Les deux sont n\u00e9cessaires." \u2014 Sun Ra',
'"404 : harmonie non trouv\u00e9e." \u2014 le serveur',
'"Fork un repo, c\'est comme voler un orgue de barbarie. L\u00e9gal, mais bruyant." \u2014 electron rare',
'"Les fr\u00e9quences en dessous de 20Hz ne sont pas du silence, c\'est de la patience." \u2014 Merzbow',
'"Un clown sans klaxon est un philosophe. Un philosophe avec un klaxon est un clown." \u2014 les Fratellini',
]
type Construct = {
lifecycle: Lifecycle
service: StudioService
}
export const Dashboard = ({lifecycle, service}: Construct) => {
const quote = QUOTES[Math.floor(Math.random() * QUOTES.length)]
return (
<div className={className}>
<article>
<h1>Welcome to openDAW</h1>
<h2>A new holistic exploration of music creation inside your browser</h2>
<h1>openDIAW.be</h1>
<h2>Digital Instrument Audio Workstation \u2014 Fork performance KXKM</h2>
<p style={{margin: "0.5em 0", fontSize: "12px", opacity: "0.7", fontStyle: "italic"}}>
{quote}
</p>
<p style={{margin: "0.5em 0 0 0"}}>
openDAW is an open source web based music studio with a clear focus on <a
href="https://opendaw.org/education" target="education">education</a> and <LocalLink
href="/privacy">data privacy</LocalLink>,
open to everyone with no login required so you can start creating <a
href="https://music.opendaw.studio/" target="music">music</a> right away. The studio is still
evolving and not production ready yet.
<strong>openDIAW.be</strong> est un fork de <a
href="https://github.com/andremichelle/openDAW" target="upstream">openDAW</a> cr\u00e9\u00e9
pour le spectacle <em>3615 J'ai p\u00e9t\u00e9</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,
AceStep, KokoroTTS, Piper) et un <strong>AI Bridge</strong> avec 19 backends audio.
</p>
<p style={{margin: "0.5em 0", fontSize: "11px"}}>
\uD83C\uDFAA Instruments de clown \u2022 \uD83E\uDD16 G\u00e9n\u00e9ration IA \u2022
\uD83C\uDFA4 TTS 12 voix \u2022 \uD83C\uDFB9 MIDI Magenta.js \u2022
\uD83D\uDD0A 19 backends audio
</p>
<div className="columns">
<DemoProjects lifecycle={lifecycle} service={service}/>
<Resources lifecycle={lifecycle} service={service}/>
</div>
<p style={{marginTop: "3em", fontSize: "11px", textAlign: "center"}}>
Visit <a
href="https://discord.opendaw.studio/" target="discord">Discord</a> and <a
href="https://github.com/andremichelle/opendaw" target="github">GitHub</a> for more information.
Built with
<p style={{marginTop: "2em", fontSize: "10px", textAlign: "center", opacity: "0.5"}}>
Fork: <a href="https://github.com/electron-rare/openDIAW.be" target="fork">github.com/electron-rare/openDIAW.be</a>
{" \u2022 "}
Upstream: <a href="https://github.com/andremichelle/openDAW" target="upstream">openDAW</a> par Andr\u00e9 Michelle
{" \u2022 "}
License: AGPL v3
</p>
<p style={{fontSize: "10px", textAlign: "center", opacity: "0.4"}}>
"Saboteurs of big daddy mainframe" \u2014 VNS Matrix, 1991
</p>
</article>
</div>
)
}
}
@@ -39,30 +39,87 @@ component
color: var(--color-black)
background-color: var(--color-blue)
> div.gen-panel
> div.ai-panel
display: flex
flex-direction: column
align-items: center
gap: 0.2em
gap: 0.25em
margin: 0.375em
padding: 0.25em
pointer-events: all
min-width: 10em
border-left: 1px solid rgba(white, 0.08)
> button
font-size: 0.55em
padding: 0.2em 0.5em
border-radius: 0.3em
> textarea, > input[type="text"]
font-size: 0.5em
font-family: inherit
width: 100%
min-height: 2.5em
padding: 0.3em 0.4em
background: rgba(0, 0, 0, 0.3)
border: 1px solid rgba(white, 0.15)
border-radius: 0.25em
color: var(--color-text)
resize: vertical
&:focus
border-color: var(--color-blue)
outline: none
&::placeholder
color: rgba(white, 0.3)
> select
font-size: 0.5em
font-family: inherit
padding: 0.2em 0.3em
background: rgba(0, 0, 0, 0.3)
border: 1px solid rgba(white, 0.15)
border-radius: 0.25em
color: var(--color-text)
cursor: pointer
> div.ai-controls
display: flex
gap: 0.25em
align-items: center
> button, > div.ai-controls > button
font-size: 0.5em
padding: 0.25em 0.6em
border-radius: 0.25em
border: 1px solid rgba(white, 0.2)
background: rgba(white, 0.05)
color: var(--color-text)
cursor: pointer
white-space: nowrap
transition: background 0.15s, border-color 0.15s
&:hover
background: rgba(white, 0.15)
background: rgba(white, 0.12)
border-color: rgba(white, 0.3)
&.generating
background: var(--color-blue)
color: var(--color-black)
border-color: var(--color-blue)
&:disabled
opacity: 0.4
opacity: 0.35
cursor: default
> span.status
font-size: 0.4em
color: rgba(white, 0.5)
color: rgba(white, 0.4)
min-height: 1em
> div.progress-bar
height: 2px
background: rgba(white, 0.08)
border-radius: 1px
overflow: hidden
> div.progress-fill
height: 100%
background: var(--color-blue)
transition: width 0.3s
width: 0%
@@ -15,47 +15,17 @@ import {StudioService} from "@/service/StudioService"
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
type Construct = {
lifecycle: Lifecycle
service: StudioService
adapter: AceStepDeviceBoxAdapter
deviceHost: DeviceHost
}
type Construct = { lifecycle: Lifecycle; service: StudioService; adapter: AceStepDeviceBoxAdapter; deviceHost: DeviceHost }
export const AceStepDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Construct) => {
const {volume, release, tempo, duration} = adapter.namedParameter
const {project} = service
const {editing, midiLearning} = project
const sampleDropZone: HTMLElement = (
<div className="sample-drop">
<Icon symbol={IconSymbol.Waveform}/>
</div>
)
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 sampleSelector = new SampleSelector(service, SampleSelectStrategy.forPointerField(adapter.box.file))
const statusLabel: HTMLElement = <span className="status"></span>
const promptInput: HTMLInputElement = <input type="text" placeholder="Describe music..." style="font-size:0.5em;width:8em;padding:0.2em;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.2);border-radius:0.3em;color:var(--color-text)"/> as HTMLInputElement
const generateButton: HTMLButtonElement = <button>Generate</button> as HTMLButtonElement
generateButton.addEventListener("click", async () => {
generateButton.disabled = true
statusLabel.textContent = "Generating..."
try {
const bpm = Math.round(tempo.getValue())
const dur = Math.round(duration.getValue())
const prompt = promptInput.value || "ambient electronic"
const response = await fetch(`${AI_BRIDGE}/generate/music-ai`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({prompt, duration: dur, bpm})
})
if (!response.ok) {throw new Error(`HTTP ${response.status}`)}
statusLabel.textContent = "Done"
} catch (error) {
statusLabel.textContent = "Error"
} finally {
generateButton.disabled = false
}
})
lifecycle.ownAll(
adapter.box.file.catchupAndSubscribe(pointer => pointer.targetVertex.match({
none: () => sampleDropZone.removeAttribute("sample"),
@@ -65,54 +35,56 @@ export const AceStepDeviceEditor = ({lifecycle, service, adapter, deviceHost}: C
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 progressEl: HTMLElement = <div className="progress-bar"><div className="progress-fill"/></div>
const genBtn: HTMLButtonElement = <button>Composer</button> as HTMLButtonElement
let busy = false
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%"
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)
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
fill.style.width = "90%"
const blob = await resp.blob()
statusEl.textContent = `OK \u2014 ${style} ${bpm}bpm (${(blob.size / 176400).toFixed(0)}s)`
fill.style.width = "100%"
} catch (err) {
statusEl.textContent = `Erreur: ${err instanceof Error ? err.message : "echec"}`
fill.style.width = "0%"
} finally {
busy = false; genBtn.disabled = false; genBtn.classList.remove("generating"); genBtn.textContent = "Composer"
setTimeout(() => { fill.style.width = "0%" }, 3000)
}
})
return (
<DeviceEditor lifecycle={lifecycle}
project={project}
adapter={adapter}
<DeviceEditor lifecycle={lifecycle} project={project} adapter={adapter}
populateMenu={parent => MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
<div className={className}>
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: volume
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: release
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: tempo
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: duration
})}
{sampleDropZone}
<div className="gen-panel">
{promptInput}
{generateButton}
{statusLabel}
{knob(volume)}{knob(release)}{knob(tempo)}{knob(duration)}{sampleDropZone}
<div className="ai-panel">
{promptArea}{styleSelect}
<div className="ai-controls">{genBtn}</div>
{progressEl}{statusEl}
</div>
</div>
)}
populateMeter={() => (
<DevicePeakMeter lifecycle={lifecycle}
receiver={project.liveStreamReceiver}
address={adapter.address}/>
)}
populateMeter={() => (<DevicePeakMeter lifecycle={lifecycle} receiver={project.liveStreamReceiver} address={adapter.address}/>)}
icon={InstrumentFactories.AceStep.defaultIcon}/>
)
}
@@ -39,30 +39,87 @@ component
color: var(--color-black)
background-color: var(--color-blue)
> div.gen-panel
> div.ai-panel
display: flex
flex-direction: column
align-items: center
gap: 0.2em
gap: 0.25em
margin: 0.375em
padding: 0.25em
pointer-events: all
min-width: 10em
border-left: 1px solid rgba(white, 0.08)
> button
font-size: 0.55em
padding: 0.2em 0.5em
border-radius: 0.3em
> textarea, > input[type="text"]
font-size: 0.5em
font-family: inherit
width: 100%
min-height: 2.5em
padding: 0.3em 0.4em
background: rgba(0, 0, 0, 0.3)
border: 1px solid rgba(white, 0.15)
border-radius: 0.25em
color: var(--color-text)
resize: vertical
&:focus
border-color: var(--color-blue)
outline: none
&::placeholder
color: rgba(white, 0.3)
> select
font-size: 0.5em
font-family: inherit
padding: 0.2em 0.3em
background: rgba(0, 0, 0, 0.3)
border: 1px solid rgba(white, 0.15)
border-radius: 0.25em
color: var(--color-text)
cursor: pointer
> div.ai-controls
display: flex
gap: 0.25em
align-items: center
> button, > div.ai-controls > button
font-size: 0.5em
padding: 0.25em 0.6em
border-radius: 0.25em
border: 1px solid rgba(white, 0.2)
background: rgba(white, 0.05)
color: var(--color-text)
cursor: pointer
white-space: nowrap
transition: background 0.15s, border-color 0.15s
&:hover
background: rgba(white, 0.15)
background: rgba(white, 0.12)
border-color: rgba(white, 0.3)
&.generating
background: var(--color-blue)
color: var(--color-black)
border-color: var(--color-blue)
&:disabled
opacity: 0.4
opacity: 0.35
cursor: default
> span.status
font-size: 0.4em
color: rgba(white, 0.5)
color: rgba(white, 0.4)
min-height: 1em
> div.progress-bar
height: 2px
background: rgba(white, 0.08)
border-radius: 1px
overflow: hidden
> div.progress-fill
height: 100%
background: var(--color-blue)
transition: width 0.3s
width: 0%
@@ -15,52 +15,30 @@ import {StudioService} from "@/service/StudioService"
const className = Html.adoptStyleSheet(css, "KokoroTtsDeviceEditor")
const AI_BRIDGE = (location.protocol === "https:" ? "https:" : "http:") + "//" + location.hostname + ":8301"
const KOKORO_VOICE_NAMES = [
"ff_siwis", "af_heart", "af_bella", "af_nicole", "af_sarah", "af_sky",
"am_adam", "am_michael", "bf_emma", "bf_isabella", "bm_george", "bm_lewis"
const VOICES = [
{id: "ff_siwis", label: "Siwis (FR \u2640)"},
{id: "af_heart", label: "Heart (EN \u2640)"},
{id: "af_bella", label: "Bella (EN \u2640)"},
{id: "af_nicole", label: "Nicole (EN \u2640)"},
{id: "af_sarah", label: "Sarah (EN \u2640)"},
{id: "af_sky", label: "Sky (EN \u2640)"},
{id: "am_adam", label: "Adam (EN \u2642)"},
{id: "am_michael", label: "Michael (EN \u2642)"},
{id: "bf_emma", label: "Emma (BR \u2640)"},
{id: "bf_isabella", label: "Isabella (BR \u2640)"},
{id: "bm_george", label: "George (BR \u2642)"},
{id: "bm_lewis", label: "Lewis (BR \u2642)"},
] as const
type Construct = {
lifecycle: Lifecycle
service: StudioService
adapter: KokoroTtsDeviceBoxAdapter
deviceHost: DeviceHost
}
type Construct = { lifecycle: Lifecycle; service: StudioService; adapter: KokoroTtsDeviceBoxAdapter; deviceHost: DeviceHost }
export const KokoroTtsDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Construct) => {
const {volume, release, voiceIndex, speed} = adapter.namedParameter
const {project} = service
const {editing, midiLearning} = project
const sampleDropZone: HTMLElement = (
<div className="sample-drop">
<Icon symbol={IconSymbol.Waveform}/>
</div>
)
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 sampleSelector = new SampleSelector(service, SampleSelectStrategy.forPointerField(adapter.box.file))
const statusLabel: HTMLElement = <span className="status"></span>
const textInput: HTMLInputElement = <input type="text" placeholder="Enter text to speak..." style="font-size:0.5em;width:8em;padding:0.2em;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.2);border-radius:0.3em;color:var(--color-text)"/> as HTMLInputElement
const generateButton: HTMLButtonElement = <button>Generate</button> as HTMLButtonElement
generateButton.addEventListener("click", async () => {
generateButton.disabled = true
statusLabel.textContent = "Generating..."
try {
const text = textInput.value || "Hello world"
const voice = KOKORO_VOICE_NAMES[Math.round(voiceIndex.getValue())] ?? "af_heart"
const spd = speed.getValue()
const response = await fetch(`${AI_BRIDGE}/generate/voice-fast`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({text, voice, speed: spd})
})
if (!response.ok) {throw new Error(`HTTP ${response.status}`)}
statusLabel.textContent = "Done"
} catch (error) {
statusLabel.textContent = "Error"
} finally {
generateButton.disabled = false
}
})
lifecycle.ownAll(
adapter.box.file.catchupAndSubscribe(pointer => pointer.targetVertex.match({
none: () => sampleDropZone.removeAttribute("sample"),
@@ -70,54 +48,52 @@ export const KokoroTtsDeviceEditor = ({lifecycle, service, adapter, deviceHost}:
sampleSelector.configureContextMenu(sampleDropZone),
sampleSelector.configureDrop(sampleDropZone)
)
const textArea: HTMLTextAreaElement = <textarea rows={3} placeholder="Texte a synthetiser..."/> as HTMLTextAreaElement
const voiceSelect: HTMLSelectElement = (<select>{VOICES.map(v => <option value={v.id}>{v.label}</option>)}</select>) as HTMLSelectElement
voiceSelect.value = VOICES[Math.round(voiceIndex.getValue())]?.id || "af_heart"
const statusEl: HTMLElement = <span className="status"/>
const progressEl: HTMLElement = <div className="progress-bar"><div className="progress-fill"/></div>
const genBtn: HTMLButtonElement = <button>Synthetiser</button> as HTMLButtonElement
let busy = false
genBtn.addEventListener("click", async () => {
if (busy) return
busy = true; genBtn.disabled = true; genBtn.classList.add("generating"); genBtn.textContent = "Synthese..."
statusEl.textContent = ""
const fill = progressEl.querySelector(".progress-fill") as HTMLElement
fill.style.width = "30%"
try {
const resp = await fetch(`${AI_BRIDGE}/generate/voice-fast`, {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({text: textArea.value.trim() || "Bonjour", voice: voiceSelect.value, speed: speed.getValue()}),
signal: AbortSignal.timeout(30000)
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
fill.style.width = "90%"
const blob = await resp.blob()
statusEl.textContent = `OK \u2014 ${voiceSelect.value} (${(blob.size / 176400).toFixed(1)}s)`
fill.style.width = "100%"
} catch (err) {
statusEl.textContent = `Erreur: ${err instanceof Error ? err.message : "echec"}`
fill.style.width = "0%"
} finally {
busy = false; genBtn.disabled = false; genBtn.classList.remove("generating"); genBtn.textContent = "Synthetiser"
setTimeout(() => { fill.style.width = "0%" }, 2000)
}
})
return (
<DeviceEditor lifecycle={lifecycle}
project={project}
adapter={adapter}
<DeviceEditor lifecycle={lifecycle} project={project} adapter={adapter}
populateMenu={parent => MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
<div className={className}>
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: volume
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: release
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: voiceIndex
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: speed
})}
{sampleDropZone}
<div className="gen-panel">
{textInput}
{generateButton}
{statusLabel}
{knob(volume)}{knob(release)}{knob(speed)}{sampleDropZone}
<div className="ai-panel">
{textArea}{voiceSelect}
<div className="ai-controls">{genBtn}</div>
{progressEl}{statusEl}
</div>
</div>
)}
populateMeter={() => (
<DevicePeakMeter lifecycle={lifecycle}
receiver={project.liveStreamReceiver}
address={adapter.address}/>
)}
populateMeter={() => (<DevicePeakMeter lifecycle={lifecycle} receiver={project.liveStreamReceiver} address={adapter.address}/>)}
icon={InstrumentFactories.KokoroTts.defaultIcon}/>
)
}
@@ -39,30 +39,87 @@ component
color: var(--color-black)
background-color: var(--color-blue)
> div.gen-panel
> div.ai-panel
display: flex
flex-direction: column
align-items: center
gap: 0.2em
gap: 0.25em
margin: 0.375em
padding: 0.25em
pointer-events: all
min-width: 10em
border-left: 1px solid rgba(white, 0.08)
> button
font-size: 0.55em
padding: 0.2em 0.5em
border-radius: 0.3em
> textarea, > input[type="text"]
font-size: 0.5em
font-family: inherit
width: 100%
min-height: 2.5em
padding: 0.3em 0.4em
background: rgba(0, 0, 0, 0.3)
border: 1px solid rgba(white, 0.15)
border-radius: 0.25em
color: var(--color-text)
resize: vertical
&:focus
border-color: var(--color-blue)
outline: none
&::placeholder
color: rgba(white, 0.3)
> select
font-size: 0.5em
font-family: inherit
padding: 0.2em 0.3em
background: rgba(0, 0, 0, 0.3)
border: 1px solid rgba(white, 0.15)
border-radius: 0.25em
color: var(--color-text)
cursor: pointer
> div.ai-controls
display: flex
gap: 0.25em
align-items: center
> button, > div.ai-controls > button
font-size: 0.5em
padding: 0.25em 0.6em
border-radius: 0.25em
border: 1px solid rgba(white, 0.2)
background: rgba(white, 0.05)
color: var(--color-text)
cursor: pointer
white-space: nowrap
transition: background 0.15s, border-color 0.15s
&:hover
background: rgba(white, 0.15)
background: rgba(white, 0.12)
border-color: rgba(white, 0.3)
&.generating
background: var(--color-blue)
color: var(--color-black)
border-color: var(--color-blue)
&:disabled
opacity: 0.4
opacity: 0.35
cursor: default
> span.status
font-size: 0.4em
color: rgba(white, 0.5)
color: rgba(white, 0.4)
min-height: 1em
> div.progress-bar
height: 2px
background: rgba(white, 0.08)
border-radius: 1px
overflow: hidden
> div.progress-fill
height: 100%
background: var(--color-blue)
transition: width 0.3s
width: 0%
@@ -15,45 +15,17 @@ import {StudioService} from "@/service/StudioService"
const className = Html.adoptStyleSheet(css, "PiperDeviceEditor")
const AI_BRIDGE = (location.protocol === "https:" ? "https:" : "http:") + "//" + location.hostname + ":8301"
const PERSONAS = ["pharmacius","schaeffer","merzbow","cage","radigue","sunra","haraway","batty","deleuze","turing"] as const
type Construct = {
lifecycle: Lifecycle
service: StudioService
adapter: PiperDeviceBoxAdapter
deviceHost: DeviceHost
}
type Construct = { lifecycle: Lifecycle; service: StudioService; adapter: PiperDeviceBoxAdapter; deviceHost: DeviceHost }
export const PiperDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Construct) => {
const {volume, release, speed, speaker} = adapter.namedParameter
const {volume, release, speed} = adapter.namedParameter
const {project} = service
const {editing, midiLearning} = project
const sampleDropZone: HTMLElement = (
<div className="sample-drop">
<Icon symbol={IconSymbol.Waveform}/>
</div>
)
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 sampleSelector = new SampleSelector(service, SampleSelectStrategy.forPointerField(adapter.box.file))
const statusLabel: HTMLElement = <span className="status"></span>
const textInput: HTMLInputElement = <input type="text" placeholder="Enter text to speak..." style="font-size:0.5em;width:8em;padding:0.2em;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.2);border-radius:0.3em;color:var(--color-text)"/> as HTMLInputElement
const generateButton: HTMLButtonElement = <button>Generate</button> as HTMLButtonElement
generateButton.addEventListener("click", async () => {
generateButton.disabled = true
statusLabel.textContent = "Generating..."
try {
const text = textInput.value || "Hello world"
const response = await fetch(`${AI_BRIDGE}/generate/voice`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({text, persona: "pharmacius"})
})
if (!response.ok) {throw new Error(`HTTP ${response.status}`)}
statusLabel.textContent = "Done"
} catch (error) {
statusLabel.textContent = "Error"
} finally {
generateButton.disabled = false
}
})
lifecycle.ownAll(
adapter.box.file.catchupAndSubscribe(pointer => pointer.targetVertex.match({
none: () => sampleDropZone.removeAttribute("sample"),
@@ -63,54 +35,51 @@ export const PiperDeviceEditor = ({lifecycle, service, adapter, deviceHost}: Con
sampleSelector.configureContextMenu(sampleDropZone),
sampleSelector.configureDrop(sampleDropZone)
)
const textArea: HTMLTextAreaElement = <textarea rows={3} placeholder="Texte a prononcer (Piper TTS)..."/> as HTMLTextAreaElement
const personaSelect: HTMLSelectElement = (<select>{PERSONAS.map(p => <option value={p}>{p}</option>)}</select>) as HTMLSelectElement
const statusEl: HTMLElement = <span className="status"/>
const progressEl: HTMLElement = <div className="progress-bar"><div className="progress-fill"/></div>
const genBtn: HTMLButtonElement = <button>Prononcer</button> as HTMLButtonElement
let busy = false
genBtn.addEventListener("click", async () => {
if (busy) return
busy = true; genBtn.disabled = true; genBtn.classList.add("generating"); genBtn.textContent = "Synthese..."
statusEl.textContent = ""
const fill = progressEl.querySelector(".progress-fill") as HTMLElement
fill.style.width = "30%"
try {
const resp = await fetch(`${AI_BRIDGE}/generate/voice`, {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({text: textArea.value.trim() || "Bonjour", persona: personaSelect.value}),
signal: AbortSignal.timeout(30000)
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
fill.style.width = "90%"
const blob = await resp.blob()
statusEl.textContent = `OK \u2014 ${personaSelect.value} (${(blob.size / 176400).toFixed(1)}s)`
fill.style.width = "100%"
} catch (err) {
statusEl.textContent = `Erreur: ${err instanceof Error ? err.message : "echec"}`
fill.style.width = "0%"
} finally {
busy = false; genBtn.disabled = false; genBtn.classList.remove("generating"); genBtn.textContent = "Prononcer"
setTimeout(() => { fill.style.width = "0%" }, 2000)
}
})
return (
<DeviceEditor lifecycle={lifecycle}
project={project}
adapter={adapter}
<DeviceEditor lifecycle={lifecycle} project={project} adapter={adapter}
populateMenu={parent => MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
<div className={className}>
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: volume
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: release
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: speed
})}
{ControlBuilder.createKnob({
lifecycle,
editing,
midiLearning,
adapter,
parameter: speaker
})}
{sampleDropZone}
<div className="gen-panel">
{textInput}
{generateButton}
{statusLabel}
{knob(volume)}{knob(release)}{knob(speed)}{sampleDropZone}
<div className="ai-panel">
{textArea}{personaSelect}
<div className="ai-controls">{genBtn}</div>
{progressEl}{statusEl}
</div>
</div>
)}
populateMeter={() => (
<DevicePeakMeter lifecycle={lifecycle}
receiver={project.liveStreamReceiver}
address={adapter.address}/>
)}
populateMeter={() => (<DevicePeakMeter lifecycle={lifecycle} receiver={project.liveStreamReceiver} address={adapter.address}/>)}
icon={InstrumentFactories.Piper.defaultIcon}/>
)
}