Files
openDIAW.be/packages/app/studio/src/service/StudioService.ts
T
2026-04-05 00:03:36 +02:00

573 lines
25 KiB
TypeScript

import {
asInstanceOf,
DefaultObservableValue,
EmptyExec,
Errors,
Func,
int,
isAbsent,
MutableObservableOption,
Notifier,
Nullable,
Observer,
Option,
Provider,
RuntimeNotifier,
safeRead,
Subscription,
Terminable,
Terminator,
tryCatch,
UUID
} from "@opendaw/lib-std"
import {populateStudioMenu} from "@/service/StudioMenu"
import {Snapping} from "@/ui/timeline/Snapping.ts"
import {PanelContents} from "@/ui/workspace/PanelContents.tsx"
import {createPanelFactory} from "@/ui/workspace/PanelFactory.tsx"
import {SpotlightDataSupplier} from "@/ui/spotlight/SpotlightDataSupplier.ts"
import {Workspace} from "@/ui/workspace/Workspace.ts"
import {PanelType} from "@/ui/workspace/PanelType.ts"
import {Dialogs} from "@/ui/components/dialogs.tsx"
import {BuildInfo} from "@/BuildInfo.ts"
import {SamplePlayback} from "@/service/SamplePlayback"
import {ProjectProfileService} from "./ProjectProfileService"
import {StudioSignal} from "./StudioSignal"
import {AudioOutputDevice} from "@/audio/AudioOutputDevice"
import {FooterLabel} from "@/service/FooterLabel"
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, Sample} from "@opendaw/studio-adapters"
import {Address} from "@opendaw/lib-box"
import {
AudioContentFactory,
AudioWorklets,
CloudAuthManager,
DawProjectService,
EngineFacade,
EngineWorklet,
ExternalLib,
FilePickerAcceptTypes,
GlobalSampleLoaderManager,
GlobalSoundfontLoaderManager,
Project,
ProjectEnv,
ProjectMeta,
ProjectProfile,
ProjectStorage,
Recovery,
RestartWorklet,
SampleService,
SoundfontService,
StudioPreferences,
TimelineRange
} from "@opendaw/studio-core"
import {ProjectDialogs} from "@/project/ProjectDialogs"
import {AudioFileBox, AudioUnitBox} from "@opendaw/studio-boxes"
import {AudioUnitType} from "@opendaw/studio-enums"
import {Surface} from "@/ui/surface/Surface"
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.
* I suppose this gets cleaned up sooner or later.
*/
const range = new TimelineRange({padding: 12})
range.minimum = PPQN.fromSignature(3, 8)
range.maxUnits = PPQN.fromSignature(128, 1)
range.showUnitInterval(0, PPQN.fromSignature(9, 1))
const snapping = new Snapping(range)
export class StudioService implements ProjectEnv {
readonly layout = {
screen: new DefaultObservableValue<Nullable<Workspace.ScreenKeys>>("default")
} as const
readonly timeline = {
range,
snapping,
clips: {
count: new DefaultObservableValue(3),
visible: new DefaultObservableValue(true)
},
followCursor: new DefaultObservableValue(false),
primaryVisibility: {
markers: new DefaultObservableValue(true),
tempo: new DefaultObservableValue(false),
signature: new DefaultObservableValue(false)
}
} as const
readonly menu = populateStudioMenu(this)
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()
readonly #softwareKeyboardLifeCycle = new Terminator()
readonly #signals = new Notifier<StudioSignal>()
readonly #projectProfileService: ProjectProfileService
readonly #sampleService: SampleService
readonly #soundfontService: SoundfontService
#shadertoyState: Option<ShadertoyState> = Option.None
readonly #activeCodeEditor: MutableObservableOption<CodeEditorState> = new MutableObservableOption()
#factoryFooterLabel: Option<Provider<FooterLabel>> = Option.None
regionModifierInProgress: boolean = false
constructor(readonly audioContext: AudioContext,
readonly audioWorklets: AudioWorklets,
readonly audioDevices: AudioOutputDevice,
readonly sampleManager: GlobalSampleLoaderManager,
readonly soundfontManager: GlobalSoundfontLoaderManager,
readonly cloudAuthManager: CloudAuthManager,
readonly buildInfo: BuildInfo) {
this.#sampleService = new SampleService(audioContext)
this.#sampleService.subscribe(([sample]) => this.#signals.notify({
type: "import-sample",
sample
}))
this.#soundfontService = new SoundfontService()
this.#soundfontService.subscribe(([soundfont]) => this.#signals.notify({
type: "import-soundfont",
soundfont
}))
this.samplePlayback = new SamplePlayback()
this.#projectProfileService = new ProjectProfileService({
env: this,
sampleService: this.#sampleService, sampleManager: this.sampleManager,
soundfontService: this.#soundfontService, soundfontManager: this.soundfontManager
})
this.#listenProject()
this.#installConsoleCommands()
this.#populateSpotlightData()
this.#configBeforeUnload()
this.#checkRecovery()
this.#listenPreferences()
}
get sampleRate(): number {return this.audioContext.sampleRate}
get sampleService(): SampleService {return this.#sampleService}
get soundfontService(): SoundfontService {return this.#soundfontService}
get projectProfileService(): ProjectProfileService {return this.#projectProfileService}
panicEngine(): void {this.runIfProject(({engine}) => engine.panic())}
async newProject() {
if (this.hasProfile && !this.project.editing.hasNoChanges()) {
const approved = await RuntimeNotifier.approve({
headline: "Closing Project?",
message: "You will lose all progress!"
})
if (!approved) {return}
}
this.#projectProfileService.setValue(Option.wrap(
new ProjectProfile(UUID.generate(), Project.new(this), ProjectMeta.init("Untitled"), Option.None)))
}
async closeProject() {
RouteLocation.get().navigateTo("/")
if (!this.hasProfile) {
this.switchScreen("dashboard")
return
}
if (this.project.editing.hasNoChanges()) {
this.#projectProfileService.setValue(Option.None)
} else {
const approved = await RuntimeNotifier.approve({
headline: "Closing Project?",
message: "You will lose all progress!"
})
if (approved) {this.#projectProfileService.setValue(Option.None)}
}
}
async browseLocalProjects(): Promise<void> {
const {status, value} = await Promises.tryCatch(ProjectDialogs.showBrowseDialog(this))
if (status === "resolved") {
const [uuid, meta] = value
await this.#projectProfileService.load(uuid, meta)
}
}
async exportBundle() {return this.#projectProfileService.exportBundle()}
async importBundle() {return this.#projectProfileService.importBundle()}
async deleteProject(uuid: UUID.Bytes, meta: ProjectMeta): Promise<void> {
if (this.#projectProfileService.getValue().ifSome(profile => UUID.equals(profile.uuid, uuid)) === true) {
await this.closeProject()
}
const {status} = await Promises.tryCatch(ProjectStorage.deleteProject(uuid))
if (status === "resolved") {
this.#signals.notify({type: "delete-project", meta})
}
}
async exportMixdown() {
return this.#projectProfileService.getValue()
.ifSome(async (profile) => {
await this.audioContext.suspend()
const {status, error} = await Promises.tryCatch(Mixdowns.exportMixdown(profile))
if (status === "rejected" && !Errors.isAbort(error)) {
await RuntimeNotifier.info({headline: "Export Failed", message: String(error)})
}
this.audioContext.resume().then()
})
}
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) => {
const {project} = profile
if (project.rootBox.audioUnits.pointerHub.incoming()
.every(({box}) => asInstanceOf(box, AudioUnitBox).type.getValue() === AudioUnitType.Output)) {
return RuntimeNotifier.info({
headline: "Export Error",
message: "No stems to export"
})
}
const {status: dialogStatus, error: dialogError, value: config} =
await Promises.tryCatch(ProjectDialogs.showExportStemsDialog(project))
if (dialogStatus === "rejected") {
if (Errors.isAbort(dialogError)) {return}
await RuntimeNotifier.info({headline: "Export Failed", message: String(dialogError)})
return
}
ExportStemsConfiguration.sanitizeExportNamesInPlace(config)
await this.audioContext.suspend()
const {status, error} = await Promises.tryCatch(Mixdowns.exportStems(profile, config))
if (status === "rejected" && !Errors.isAbort(error)) {
await RuntimeNotifier.info({headline: "Export Failed", message: String(error)})
}
this.audioContext.resume().then(EmptyExec, EmptyExec)
})
}
async importDawproject() {
(await DawProjectService.importDawproject(this.sampleService))
.ifSome(skeleton => this.#projectProfileService
.setProject(Project.fromSkeleton(this, skeleton), "Dawproject"))
}
async exportDawproject() {
return this.#projectProfileService.getValue().ifSome(profile => DawProjectService.exportDawproject(profile))
}
async importPreset() {
const {
status,
value: files
} = await Promises.tryCatch(Files.open({types: [FilePickerAcceptTypes.PresetFileType]}))
if (status === "rejected") {return}
if (files.length === 0) {return}
const bytes = await files[0].arrayBuffer()
console.debug("importing preset", bytes.byteLength)
if (this.hasProfile) {
const {editing, skeleton} = this.project
editing.modify(() => PresetDecoder.decode(bytes, skeleton))
} else {
const project = Project.new(this)
const {editing, skeleton} = project
editing.modify(() => PresetDecoder.decode(bytes, skeleton))
this.#projectProfileService.setValue(Option.wrap(
new ProjectProfile(UUID.generate(), project, ProjectMeta.init("Untitled"), Option.None)))
}
}
async importStems(): Promise<void> {
const fileResult = await Promises.tryCatch(Files.open({types: [FilePickerAcceptTypes.ZipFileType]}))
if (fileResult.status === "rejected") {return}
const firstFile = fileResult.value.at(0)
if (isAbsent(firstFile)) {return}
const {status, value: JSZip} = await ExternalLib.JSZip()
if (status === "rejected") {return}
const zipResult = await Promises.tryCatch(JSZip.loadAsync(await firstFile.arrayBuffer()))
if (zipResult.status === "rejected") {
await RuntimeNotifier.info({headline: "Import Failed", message: String(zipResult.error)})
return
}
const audioEntries = Object.entries(zipResult.value.files)
.filter(([path, file]) => {
if (file.dir) {return false}
const lower = path.toLowerCase()
if (!lower.endsWith(".wav")) {return false}
if (lower.startsWith("__macosx/")) {return false}
const name = path.substring(path.lastIndexOf("/") + 1)
return !name.startsWith("._")
})
if (audioEntries.length === 0) {return}
if (!this.hasProfile) {
this.#projectProfileService.setValue(Option.wrap(
new ProjectProfile(UUID.generate(), Project.new(this), ProjectMeta.init("Untitled"), Option.None)))
}
const {editing, boxGraph} = this.project
let dialog = RuntimeNotifier.progress({headline: "Importing Stems..."})
for (let index = 0; index < audioEntries.length; index++) {
const [path, file] = audioEntries[index]
const name = path.substring(path.lastIndexOf("/") + 1).replace(/\.wav$/i, "")
dialog.message = `Importing ${name} (${index + 1}/${audioEntries.length})`
const arrayBuffer = await file.async("arraybuffer").then(buffer => buffer.slice(0))
const {status, value: sample, error} = await Promises.tryCatch(this.#sampleService.importFile({
name,
arrayBuffer
}))
if (status === "rejected") {
console.warn(`Failed to import '${name}'`, error)
dialog.terminate()
const skip = await RuntimeNotifier.approve({
headline: `Failed to import '${name}'`,
message: String(error),
approveText: "Skip",
cancelText: "Cancel Import"
})
if (!skip) {break}
dialog = RuntimeNotifier.progress({headline: "Importing Stems..."})
continue
}
const uuid = UUID.parse(sample.uuid)
await Promises.tryCatch(this.sampleManager.getAudioData(uuid))
editing.modify(() => {
const {trackBox, instrumentBox} = this.project.api.createInstrument(InstrumentFactories.Tape)
instrumentBox.label.setValue(name)
const audioFileBox = boxGraph.findBox<AudioFileBox>(uuid)
.unwrapOrElse(() => AudioFileBox.create(boxGraph, uuid, box => {
box.fileName.setValue(name)
box.startInSeconds.setValue(0)
box.endInSeconds.setValue(sample.duration)
}))
AudioContentFactory.createNotStretchedRegion({
boxGraph, sample, audioFileBox, position: 0, targetTrack: trackBox
})
})
}
dialog.terminate()
}
runIfProject<R>(procedure: Func<Project, R>): Option<R> {
return this.#projectProfileService.getValue().map(({project}) => procedure(project))
}
get project(): Project {return this.profile.project}
get optProject(): Option<Project> {return this.projectProfileService.getValue().map(({project}) => project)}
get profile(): ProjectProfile {return this.#projectProfileService.getValue().unwrap("No profile available")}
get hasProfile(): boolean {return this.#projectProfileService.getValue().nonEmpty()}
subscribeSignal<T extends StudioSignal["type"]>(
observer: Observer<Extract<StudioSignal, { type: T }>>, type: T): Subscription {
return this.#signals.subscribe(signal => {
if (signal.type === type) {
observer(signal as Extract<StudioSignal, { type: T }>)
}
})
}
switchScreen(key: Nullable<Workspace.ScreenKeys>): void {
this.layout.screen.setValue(key)
RouteLocation.get().navigateTo("/")
}
registerFooter(factory: Provider<FooterLabel>): void {
this.#factoryFooterLabel = Option.wrap(factory)
}
factoryFooterLabel(): Option<Provider<FooterLabel>> {return this.#factoryFooterLabel}
get optShadertoyState(): Option<ShadertoyState> {return this.#shadertoyState}
get activeCodeEditor(): MutableObservableOption<CodeEditorState> {return this.#activeCodeEditor}
openCodeEditor(state: CodeEditorState): void {
const previousScreen = this.layout.screen.getValue() === "code"
? this.#activeCodeEditor.map(existing => existing.previousScreen).unwrapOrNull() ?? state.previousScreen
: state.previousScreen
this.layout.screen.setValue(null)
this.#activeCodeEditor.wrap({...state, previousScreen})
this.layout.screen.setValue("code")
RouteLocation.get().navigateTo("/")
}
closeCodeEditor(): void {
const previousScreen = this.#activeCodeEditor.map(state => state.previousScreen).unwrapOrNull()
this.#activeCodeEditor.clear()
if (this.layout.screen.getValue() === "code") {
// Defer the screen switch to avoid cascading UI updates during synchronous
// box deletion. Switching the screen triggers DevicePanel re-evaluation which
// clears mounts before remaining pointerHub onRemoved events have finished.
queueMicrotask(() => this.layout.screen.setValue(previousScreen ?? "default"))
}
}
resetPeaks(): void {this.#signals.notify({type: "reset-peaks"})}
async verifyProject() {
if (!this.hasProfile) {return}
const {boxGraph} = this.project
const result = boxGraph.verifyPointers()
await RuntimeNotifier.info({message: `Project is okay. All ${result.count} pointers are fine.`})
}
toggleSoftwareKeyboard(): void {
if (this.isSoftwareKeyboardVisible()) {
this.#softwareKeyboardLifeCycle.terminate()
} else {
const element = SoftwareMIDIPanel({
lifecycle: this.#softwareKeyboardLifeCycle,
service: this
})
Surface.get(window).floating.appendChild(element)
this.#softwareKeyboardLifeCycle.own(Terminable.create(() => element.remove()))
}
}
isSoftwareKeyboardVisible(): boolean {return this.#softwareKeyboardLifeCycle.nonEmpty()}
#listenProject(): void {
const lifeTime = new Terminator()
const observer = (optProfile: Option<ProjectProfile>) => {
const path = RouteLocation.get().path
const isRoot = path === "/"
if (isRoot) {this.layout.screen.setValue(null)}
lifeTime.terminate()
document.body.classList.toggle("no-project", optProfile.isEmpty())
if (optProfile.nonEmpty()) {
const profile = optProfile.unwrap()
const {project, meta} = profile
console.debug(`switch to %c${meta.name}%c`, "color: hsl(25, 69%, 63%)", "color: inherit")
const {timelineBox, timelineBoxAdapter, userEditingManager} = project
range.showUnitInterval(0, PPQN.fromSignature(9, 1))
this.#shadertoyState = Option.wrap(lifeTime.own(new ShadertoyState(project)))
//
// -------------------------------
// Show views if content available
// -------------------------------
//
// Markers
this.timeline.primaryVisibility.markers.setValue(true)
// Tempo
this.timeline.primaryVisibility.tempo.setValue(timelineBoxAdapter
.tempoTrackEvents.mapOr(collection => !collection.events.isEmpty(), false))
// Signature
this.timeline.primaryVisibility.signature.setValue(timelineBoxAdapter.signatureTrack.size > 0)
// Clips
const maxClipIndex: int = project.rootBoxAdapter.audioUnits.adapters()
.reduce((max, unit) => Math.max(max, unit.tracks.values()
.reduce((max, track) => Math.max(max, track.clips.collection
.getMinFreeIndex()), 0)), 0)
if (maxClipIndex > 0 || StudioPreferences.settings.visibility["auto-open-clips"]) {
this.timeline.clips.count.setValue(Math.max(maxClipIndex + 1, 3))
this.timeline.clips.visible.setValue(true)
} else {
this.timeline.clips.count.setValue(3)
this.timeline.clips.visible.setValue(false)
}
let screen: Nullable<Workspace.ScreenKeys> = null
const restart: RestartWorklet = {
unload: async (event: unknown) => {
screen = this.layout.screen.getValue()
// we need to restart the screen to subscribe to new broadcaster instances
this.switchScreen(null)
this.engine.releaseWorklet()
return Dialogs.info({
headline: "Audio-Engine Error",
message: String(safeRead(event, "error", "message") ?? "Unknown error"),
okText: "Restart Engine",
cancelable: false
})
},
load: (engine: EngineWorklet) => {
this.engine.setWorklet(engine)
this.switchScreen(screen)
}
}
this.engine.releaseWorklet()
const {status, value: worklet, error} = tryCatch(() => project.startAudioWorklet(restart, {}))
if (status === "failure") {
Dialogs.info({
headline: "Audio-Engine Error",
message: `Could not start the audio engine. Your browser may not support all required features. (${Errors.toString(error)})`,
okText: "OK",
cancelable: false
}).finally()
return
}
this.engine.setWorklet(worklet)
lifeTime.ownAll(
project,
snapping.registerSignatureTrackAdapter(project.timelineBoxAdapter.signatureTrack),
userEditingManager.timeline.catchupAndSubscribe(option => option
.ifSome(() => AnimationFrame.once(() => this.panelLayout.showIfAvailable(PanelType.ContentEditor)))),
timelineBox.durationInPulses.catchupAndSubscribe(owner => range.maxUnits = owner.getValue() + PPQN.Bar)
)
if (isRoot) {this.switchScreen("default")}
} else {
this.engine.releaseWorklet()
range.maxUnits = PPQN.fromSignature(128, 1)
range.showUnitInterval(0, PPQN.fromSignature(9, 1))
this.layout.screen.setValue("dashboard")
}
}
this.#projectProfileService.catchupAndSubscribe(observer)
}
#installConsoleCommands(): void {
ConsoleCommands.exportAccessor("box.graph.boxes",
() => this.runIfProject(({boxGraph}) => boxGraph.debugBoxes()))
ConsoleCommands.exportMethod("box.graph.lookup",
(address: string) => this.runIfProject(({boxGraph}) => boxGraph.findVertex(Address.decode(address)).match({
none: () => "not found",
some: vertex => vertex.toString()
})).match({none: () => "no project", some: value => value}))
ConsoleCommands.exportAccessor("box.graph.dependencies",
() => this.runIfProject(project => project.boxGraph.debugDependencies()))
}
#populateSpotlightData(): void {
this.spotlightDataSupplier.registerAction("Create Synth", EmptyExec)
this.spotlightDataSupplier.registerAction("Create Drumcomputer", EmptyExec)
this.spotlightDataSupplier.registerAction("Create ModularSystem", EmptyExec)
}
#configBeforeUnload(): void {
if (!Browser.isLocalHost()) {
window.addEventListener("beforeunload", (event: Event) => {
if (!navigator.onLine) {event.preventDefault()}
if (this.hasProfile && this.profile.hasUnsavedChanges()) {
event.preventDefault()
}
})
}
}
#checkRecovery(): void {
this.recovery.restoreProfile().then(optProfile => {
if (optProfile.nonEmpty()) {
this.#projectProfileService.setValue(optProfile)
}
}, EmptyExec)
}
#listenPreferences(): void {
StudioPreferences.catchupAndSubscribe(value =>
Dragging.usePointerLock = value && Browser.isChrome(), "pointer", "dragging-use-pointer-lock")
StudioPreferences.catchupAndSubscribe(value =>
document.body.classList.toggle("experimental-visible", value), "debug", "enable-beta-features")
StudioPreferences.catchupAndSubscribe(value =>
document.body.classList.toggle("help-hidden", !value), "visibility", "visible-help-hints")
StudioPreferences.catchupAndSubscribe(value =>
document.body.classList.toggle("scrollbar-padding", value), "visibility", "scrollbar-padding")
}
}