diff --git a/packages/app/studio/src/ui/dashboard/Dashboard.tsx b/packages/app/studio/src/ui/dashboard/Dashboard.tsx
index 74f64870..17344235 100644
--- a/packages/app/studio/src/ui/dashboard/Dashboard.tsx
+++ b/packages/app/studio/src/ui/dashboard/Dashboard.tsx
@@ -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 (
- Welcome to openDAW
- A new holistic exploration of music creation inside your browser
+ openDIAW.be
+ Digital Instrument Audio Workstation \u2014 Fork performance KXKM
+
+ {quote}
+
- openDAW is an open source web based music studio with a clear focus on education and data privacy ,
- open to everyone with no login required so you can start creating music right away. The studio is still
- evolving and not production ready yet.
+ openDIAW.be est un fork de openDAW cr\u00e9\u00e9
+ pour le spectacle 3615 J'ai p\u00e9t\u00e9 de la compagnie KXKM .
+ Il ajoute 9 instruments AI (Drone, Grain, Glitch, Circus, Honk, Magenta,
+ AceStep, KokoroTTS, Piper) et un AI Bridge avec 19 backends audio.
+
+
+ \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
-
- Visit Discord and GitHub for more information.
- Built with ❤️
+
+ Fork: github.com/electron-rare/openDIAW.be
+ {" \u2022 "}
+ Upstream: openDAW par Andr\u00e9 Michelle
+ {" \u2022 "}
+ License: AGPL v3
+
+
+ "Saboteurs of big daddy mainframe" \u2014 VNS Matrix, 1991
)
-}
\ No newline at end of file
+}
diff --git a/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.sass b/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.sass
index 275cd1be..83db29a0 100644
--- a/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.sass
+++ b/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.sass
@@ -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%
diff --git a/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.tsx b/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.tsx
index 464bf6bb..45ddeb69 100644
--- a/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.tsx
+++ b/packages/app/studio/src/ui/devices/instruments/AceStepDeviceEditor.tsx
@@ -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 = (
-
-
-
- )
+ const knob = (parameter: typeof volume) => ControlBuilder.createKnob({lifecycle, editing, midiLearning, adapter, parameter})
+ const sampleDropZone: HTMLElement = (
)
const sampleSelector = new SampleSelector(service, SampleSelectStrategy.forPointerField(adapter.box.file))
- const statusLabel: HTMLElement =
- const promptInput: HTMLInputElement = as HTMLInputElement
- const generateButton: HTMLButtonElement = Generate 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 = as HTMLTextAreaElement
+ const styleSelect: HTMLSelectElement = ({STYLES.map(s => {s} )} ) as HTMLSelectElement
+ const statusEl: HTMLElement =
+ const progressEl: HTMLElement =
+ const genBtn: HTMLButtonElement = Composer 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 (
- MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
- {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}
-
- {promptInput}
- {generateButton}
- {statusLabel}
+ {knob(volume)}{knob(release)}{knob(tempo)}{knob(duration)}{sampleDropZone}
+
+ {promptArea}{styleSelect}
+
{genBtn}
+ {progressEl}{statusEl}
)}
- populateMeter={() => (
-
- )}
+ populateMeter={() => ()}
icon={InstrumentFactories.AceStep.defaultIcon}/>
)
}
diff --git a/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.sass b/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.sass
index 275cd1be..83db29a0 100644
--- a/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.sass
+++ b/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.sass
@@ -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%
diff --git a/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.tsx b/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.tsx
index 4c85dc00..4e893483 100644
--- a/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.tsx
+++ b/packages/app/studio/src/ui/devices/instruments/KokoroTtsDeviceEditor.tsx
@@ -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 = (
-
-
-
- )
+ const knob = (parameter: typeof volume) => ControlBuilder.createKnob({lifecycle, editing, midiLearning, adapter, parameter})
+ const sampleDropZone: HTMLElement = (
)
const sampleSelector = new SampleSelector(service, SampleSelectStrategy.forPointerField(adapter.box.file))
- const statusLabel: HTMLElement =
- const textInput: HTMLInputElement = as HTMLInputElement
- const generateButton: HTMLButtonElement = Generate 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 = as HTMLTextAreaElement
+ const voiceSelect: HTMLSelectElement = ({VOICES.map(v => {v.label} )} ) as HTMLSelectElement
+ voiceSelect.value = VOICES[Math.round(voiceIndex.getValue())]?.id || "af_heart"
+ const statusEl: HTMLElement =
+ const progressEl: HTMLElement =
+ const genBtn: HTMLButtonElement = Synthetiser 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 (
- MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
- {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}
-
- {textInput}
- {generateButton}
- {statusLabel}
+ {knob(volume)}{knob(release)}{knob(speed)}{sampleDropZone}
+
+ {textArea}{voiceSelect}
+
{genBtn}
+ {progressEl}{statusEl}
)}
- populateMeter={() => (
-
- )}
+ populateMeter={() => ()}
icon={InstrumentFactories.KokoroTts.defaultIcon}/>
)
}
diff --git a/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.sass b/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.sass
index 275cd1be..83db29a0 100644
--- a/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.sass
+++ b/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.sass
@@ -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%
diff --git a/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.tsx b/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.tsx
index b3334a10..bc4fb8cb 100644
--- a/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.tsx
+++ b/packages/app/studio/src/ui/devices/instruments/PiperDeviceEditor.tsx
@@ -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 = (
-
-
-
- )
+ const knob = (parameter: typeof volume) => ControlBuilder.createKnob({lifecycle, editing, midiLearning, adapter, parameter})
+ const sampleDropZone: HTMLElement = (
)
const sampleSelector = new SampleSelector(service, SampleSelectStrategy.forPointerField(adapter.box.file))
- const statusLabel: HTMLElement =
- const textInput: HTMLInputElement = as HTMLInputElement
- const generateButton: HTMLButtonElement = Generate 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 = as HTMLTextAreaElement
+ const personaSelect: HTMLSelectElement = ({PERSONAS.map(p => {p} )} ) as HTMLSelectElement
+ const statusEl: HTMLElement =
+ const progressEl: HTMLElement =
+ const genBtn: HTMLButtonElement = Prononcer 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 (
- MenuItems.forAudioUnitInput(parent, service, deviceHost)}
populateControls={() => (
- {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}
-
- {textInput}
- {generateButton}
- {statusLabel}
+ {knob(volume)}{knob(release)}{knob(speed)}{sampleDropZone}
+
+ {textArea}{personaSelect}
+
{genBtn}
+ {progressEl}{statusEl}
)}
- populateMeter={() => (
-
- )}
+ populateMeter={() => ()}
icon={InstrumentFactories.Piper.defaultIcon}/>
)
}