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:
@@ -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... 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}/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user