de.sciss.mellite.ActionBounce.scala Maven / Gradle / Ivy
/*
* ActionBounce.scala
* (Mellite)
*
* Copyright (c) 2012-2023 Hanns Holger Rutz. All rights reserved.
*
* This software is published under the GNU Affero General Public License v3+
*
*
* For further information, please contact Hanns Holger Rutz at
* [email protected]
*/
package de.sciss.mellite
import de.sciss.asyncfile.AsyncFile
import de.sciss.asyncfile.Ops._
import de.sciss.audiofile.{AudioFile, AudioFileType, SampleFormat}
import de.sciss.audiowidgets.TimeField
import de.sciss.desktop.{Desktop, DialogSource, FileDialog, OptionPane, PathField, Window}
import de.sciss.lucre.edit.EditFolder
import de.sciss.lucre.swing.LucreSwing.defer
import de.sciss.lucre.swing.View
import de.sciss.lucre.synth.{Buffer, Server, Synth, Txn}
import de.sciss.lucre.{Artifact, ArtifactLocation, BooleanObj, DoubleObj, IntObj, LongObj, Obj, Source, SpanLikeObj, StringObj, Txn => LTxn}
import de.sciss.mellite.Mellite.executionContext
import de.sciss.mellite.impl.component.BaselineFlowPanel
import de.sciss.mellite.util.Gain
import de.sciss.numbers.Implicits._
import de.sciss.proc.Implicits._
import de.sciss.proc.{AudioCue, Bounce, Tag, TimeRef, Timeline, Universe}
import de.sciss.processor.impl.ProcessorImpl
import de.sciss.processor.{Processor, ProcessorLike}
import de.sciss.span.Span.SpanOrVoid
import de.sciss.span.{Span, SpanLike}
import de.sciss.swingplus.{ComboBox, GroupPanel, Spinner, SpinnerComboBox}
import de.sciss.synth.{Client, SynthGraph, addToTail}
import de.sciss.{desktop, equal, numbers, swingplus, synth}
import java.awt
import java.awt.event.{ComponentAdapter, ComponentEvent, WindowAdapter, WindowEvent}
import java.io.{EOFException, File, IOException}
import java.net.URI
import java.text.ParseException
import javax.swing.{JFormattedTextField, SpinnerNumberModel, SwingUtilities}
import scala.collection.immutable.{IndexedSeq => Vec, Iterable => IIterable, Seq => ISeq}
import scala.concurrent.blocking
import scala.reflect.ClassTag
import scala.swing.Swing._
import scala.swing.event.{ButtonClicked, SelectionChanged, ValueChanged}
import scala.swing.{Button, ButtonGroup, CheckBox, Component, Dialog, Label, ProgressBar, TextField, ToggleButton}
import scala.util.control.NonFatal
import scala.util.{Failure, Try}
object ActionBounce {
private[this] val DEBUG = false
final val title = "Export as Audio File"
def presetAllTimeline[T <: Txn[T]](tl: Timeline[T])(implicit tx: T): List[SpanPreset] = {
val opt = for {
start <- tl.firstEvent
stop <- tl.lastEvent
} yield {
SpanPreset(" All ", Span(start, stop))
}
opt.toList
}
object FileFormat {
final case class PCM(tpe: AudioFileType = AudioFileType.AIFF, sampleFormat: SampleFormat = SampleFormat.Int24)
extends FileFormat {
def isPCM : Boolean = true
def id : String = tpe.id
def extension : String = tpe.extension
}
final val mp3id = "mp3"
final case class MP3(kbps: Int = 256, vbr: Boolean = false, title: String = "", artist: String = "",
comment: String = "")
extends FileFormat {
def isPCM : Boolean = false
def id : String = mp3id
def extension : String = mp3id
}
}
sealed trait FileFormat {
def isPCM: Boolean
def isCompressed: Boolean = !isPCM
def id: String
def extension: String
}
private def mkNumFileChannels (channels: Vec[Range.Inclusive]): Int = channels.map(_.size).sum
private def mkNumPlayChannels (channels: Vec[Range.Inclusive]): Int = {
val fl = channels.flatten
val m = if (fl.isEmpty) 0 else fl.max // Scala 2.12: `maxOption` not defined
m + 1
}
private[this] val attrTag = "tag-bounce" // Tag
private[this] val attrFile = "file" // StringObj
private[this] val attrFileType = "file-type" // StringObj
private[this] val attrSampleFormat = "sample-format" // StringObj
private[this] val attrBitRate = "bit-rate" // IntObj
private[this] val attrMP3VBR = "mp3-vbr" // BooleanObj
private[this] val attrMP3Title = "mp3-title" // StringObj
private[this] val attrMP3Artist = "mp3-artist" // StringObj
private[this] val attrMP3Comment = "mp3-comment" // StringObj
private[this] val attrSampleRate = "sample-rate" // IntObj
private[this] val attrGain = "gain" // DoubleObj
private[this] val attrSpan = "span" // SpanLikeObj
private[this] val attrNormalize = "normalize" // BooleanObj
private[this] val attrChannels = "channels" // IntVector
private[this] val attrRealtime = "realtime" // BooleanObj
private[this] val attrFineControl = "fine-control" // BooleanObj
private[this] val attrImport = "import" // BooleanObj
private[this] val attrLocation = "location" // ArtifactLocation
// cf. stackoverflow #4310439 - with added spaces after comma
// val regRanges = """^(\d+(-\d+)?)(,\s*(\d+(-\d+)?))*$""".r
// cf. stackoverflow #16532768
private val regRanges = """(\d+(-\d+)?)""".r
private def stringToChannels(text: String): Vec[Range.Inclusive] =
regRanges.findAllIn(text).toIndexedSeq match {
case list if list.nonEmpty => list.map { s =>
val i = s.indexOf('-')
if (i < 0) {
val a = s.toInt - 1
a to a
} else {
val a = s.substring(0, i).toInt - 1
val b = s.substring(i +1).toInt - 1
a to b
}
}
case _ => throw new IllegalArgumentException
}
private def channelToString(r: Range): String =
if (r.start < r.end) s"${r.start + 1}-${r.end + 1}"
else s"${r.start + 1}"
private def channelsToString(r: Vec[Range]): String =
r.map(channelToString).mkString(", ")
/** Saves the query settings in conventional keys of an object's attribute map.
* This way, they can be preserved in the workspace.
*
* @param q the settings to store
* @param obj the object into whose attribute map the settings are stored
*/
def storeSettings[T <: Txn[T]](q: QuerySettings[T], obj: Obj[T], hasRealtimeOption: Boolean)
(implicit tx: T): Unit = {
val attr = {
val tag = obj.attr.$[Tag](attrTag).getOrElse {
val _tag = Tag[T]()
obj.attr.put(attrTag, _tag)
_tag
}
tag.attr
}
import equal.Implicits._
def storeString(key: String, value: String, default: String = ""): Unit =
attr.$[StringObj](key) match {
case Some(a) if a.value === value =>
case Some(StringObj.Var(vr)) => vr() = value
case Some(_) if value == default => attr.remove(key)
case None if value != default => attr.put(key, StringObj.newVar[T](value))
case _ =>
}
def storeInt(key: String, value: Int, default: Int = 0): Unit =
attr.$[IntObj](key) match {
case Some(a) if a.value === value =>
case Some(IntObj.Var(vr)) => vr() = value
case Some(_) if value == default => attr.remove(key)
case None if value != default => attr.put(key, IntObj.newVar[T](value))
case _ =>
}
def storeBoolean(key: String, value: Boolean, default: Boolean = false): Unit =
attr.$[BooleanObj](key) match {
case Some(a) if a.value === value =>
case Some(BooleanObj.Var(vr)) => vr() = value
case Some(_) if value == default => attr.remove(key)
case None if value != default => attr.put(key, BooleanObj.newVar[T](value))
case _ =>
}
def storeDouble(key: String, value: Double, default: Double /* = 0.0 */): Unit =
attr.$[DoubleObj](key) match {
case Some(a) if a.value === value =>
case Some(DoubleObj.Var(vr)) => vr() = value
case Some(_) if value == default => attr.remove(key)
case None if value != default => attr.put(key, DoubleObj.newVar[T](value))
case _ =>
}
def storeSpanLike(key: String, value: SpanLike, default: SpanLike = Span.Void): Unit =
attr.$[SpanLikeObj](key) match {
case Some(a) if a.value === value =>
case Some(SpanLikeObj.Var(vr)) => vr() = value
case Some(_) if value == default => attr.remove(key)
case None if value != default => attr.put(key, SpanLikeObj.newVar[T](value))
case _ =>
}
q.uriOption match {
case Some(f) => storeString(attrFile, f.path)
case None => attr.remove(attrFile)
}
storeString(attrFileType, q.fileFormat.id)
q.fileFormat match {
case pcm: FileFormat.PCM =>
storeString (attrFileType , pcm.tpe.id)
storeString (attrSampleFormat , pcm.sampleFormat.id)
case mp3: FileFormat.MP3 =>
storeInt (attrBitRate , mp3.kbps )
storeBoolean(attrMP3VBR , mp3.vbr )
storeString (attrMP3Title , mp3.title )
storeString (attrMP3Artist , mp3.artist )
storeString (attrMP3Comment , mp3.comment)
}
storeInt (attrSampleRate , q.sampleRate)
storeDouble (attrGain , q.gain.decibels, default = Double.NaN)
storeBoolean (attrNormalize , q.gain.normalized, default = true)
storeSpanLike (attrSpan , q.span)
storeString (attrChannels , channelsToString(q.channels))
if (hasRealtimeOption) {
storeBoolean (attrRealtime , q.realtime)
storeBoolean (attrFineControl, q.fineControl)
}
storeBoolean (attrImport , q.importFile)
q.location match {
case Some(locH) => attr.put (attrLocation, locH())
case None => attr.remove(attrLocation)
}
}
/** Retrieves settings from a tag map found at `attrTag`. If settings are found,
* the returned `Boolean` is `true` ("marked"), if no attribute was recalled,
* the returned `Boolean` is `false`.
*/
def recallSettings[T <: Txn[T]](obj : Obj[T],
defaultRealtime : Boolean = false,
defaultFile : URI = Artifact.Value.empty,
defaultChannels : Vec[Range.Inclusive] = Vector(0 to 1))
(implicit tx: T): (QuerySettings[T], Boolean) = {
val attrOpt = {
tx.attrMapOption(obj).flatMap { a0 =>
a0.$[Tag](attrTag).flatMap(tag => tx.attrMapOption(tag))
}
}
import equal.Implicits._
var mark = false
def attr[R[~ <: LTxn[~]] <: Obj[~]](key: String)(implicit tx: T, ct: ClassTag[R[T]]): Option[R[T]] = {
val res = attrOpt.flatMap(_.$[R](key))
if (res.isDefined) mark = true
res
}
def recallString(key: String, default: String = ""): String =
attr[StringObj](key).fold(default)(_.value)
def recallInt(key: String, default: Int /* = 0 */): Int =
attr[IntObj](key).fold(default)(_.value)
def recallBoolean(key: String, default: Boolean = false): Boolean =
attr[BooleanObj](key).fold(default)(_.value)
def recallDouble(key: String, default: Double /* = 0.0 */): Double =
attr[DoubleObj](key).fold(default)(_.value)
def recallSpanLike(key: String, default: SpanLike = Span.Void): SpanLike =
attr[SpanLikeObj](key).fold(default)(_.value)
val tpeId = recallString(attrFileType, AudioFileType.AIFF.id)
val fileFormat = if (tpeId === FileFormat.mp3id) {
val kbps = recallInt(attrBitRate, 256)
val vbr = recallBoolean(attrMP3VBR)
val title = recallString(attrMP3Title)
val artist = recallString(attrMP3Artist)
val comment = recallString(attrMP3Comment)
FileFormat.MP3(kbps = kbps, vbr = vbr, title = title, artist = artist, comment = comment)
} else {
val tpe = AudioFileType.writable.find(_.id === tpeId).getOrElse(AudioFileType.AIFF)
val smpId = recallString(attrSampleFormat, SampleFormat.Int24.id)
val smp = SampleFormat.fromInt16.find(_.id === smpId).getOrElse(SampleFormat.Int24)
FileFormat.PCM(tpe, smp)
}
val path = recallString(attrFile)
val fileOpt: Option[URI] = if (path.isEmpty) {
if (defaultFile.path.isEmpty) None else {
val f = defaultFile.replaceExt(fileFormat.extension)
Some(f)
}
} else {
Some(new File(path).toURI)
}
val sampleRate = recallInt (attrSampleRate , 44100)
val decibels = recallDouble (attrGain , -0.2)
val normalized = recallBoolean (attrNormalize , default = true)
val gain = Gain(decibels.toFloat, normalized)
val spanLike = recallSpanLike(attrSpan)
val span: SpanOrVoid = spanLike match {
case sp: Span => sp
case _ => Span.Void
}
val chansS = recallString (attrChannels)
val channels = Try(stringToChannels(chansS)).toOption.getOrElse(defaultChannels)
val realtime = recallBoolean (attrRealtime, default = defaultRealtime)
val fineControl = recallBoolean (attrFineControl)
val importFile = recallBoolean (attrImport)
val location = attr[ArtifactLocation](attrLocation).map(tx.newHandle(_))
val qs = QuerySettings(uriOption = fileOpt, fileFormat = fileFormat, sampleRate = sampleRate, gain = gain, span = span,
channels = channels, realtime = realtime, fineControl = fineControl, importFile = importFile,
location = location)
(qs, mark)
}
/** @param uriOption the output file
* @param fileFormat the output file format
* @param sampleRate the output sample rate
* @param gain the gain settings (normalized / immediate)
* @param span the optional span within the object to bounce
* @param channels the sequence of channel ranges. Unlike the UI presentation, this is _zero based_!
* @param realtime whether to use real-time processing
* @param fineControl whether to use fine-grained DSP blocks
* @param importFile whether to import the resulting file back into the workspace
* @param location the location to use for re-imported artifact
*/
final case class QuerySettings[T <: Txn[T]](
uriOption : Option[URI] = None,
fileFormat : FileFormat = FileFormat.PCM(),
sampleRate : Int = 44100,
gain : Gain = Gain.normalized(-0.2f),
span : SpanOrVoid = Span.Void,
channels : Vec[Range.Inclusive] = Vector(0 to 1),
realtime : Boolean = false,
fineControl : Boolean = false,
importFile : Boolean = false,
location : Option[Source[T, ArtifactLocation[T]]] = None
) {
def prepare(group: IIterable[Source[T, Obj[T]]], uri: URI)(mkSpan: => Span): PerformSettings[T] = {
val sConfig = Server.Config()
val cConfig = Client.Config()
Mellite.applyAudioPreferences(sConfig, cConfig, useDevice = realtime, pickPort = realtime)
if (fineControl) sConfig.blockSize = 1
val numFileChannels = mkNumFileChannels(channels)
val numPlayChannels = mkNumPlayChannels(channels)
val numServerChannels = math.max(numFileChannels, numPlayChannels)
specToServerConfig(uri, fileFormat, numChannels = numServerChannels, sampleRate = sampleRate, config = sConfig)
val span1: Span = span match {
case s: Span => s
case _ => mkSpan
}
PerformSettings(
realtime = realtime, fileFormat = fileFormat,
group = group, server = sConfig, client = cConfig, gain = gain, span = span1, channels = channels
)
}
}
/**
* @param channels the sequence of channel ranges. Unlike the UI presentation, this is _zero based_!
*/
final case class PerformSettings[T <: Txn[T]](
realtime : Boolean,
fileFormat : FileFormat,
group : IIterable[Source[T, Obj[T]]],
server : Server.Config,
client : Client.Config,
gain : Gain = Gain.normalized(-0.2f),
span : Span,
channels : Vec[Range.Inclusive]
)
/** Note: header format and sample format are left unspecified if file format is not PCM.
*
* @param numChannels the number of server output bus channels
*/
def specToServerConfig(uri: URI, fileFormat: FileFormat, numChannels: Int, sampleRate: Int,
config: Server.ConfigBuilder): Unit = {
config.nrtOutputPath = uri.path
fileFormat match {
case pcm: FileFormat.PCM =>
config.nrtHeaderFormat = pcm.tpe
config.nrtSampleFormat = pcm.sampleFormat
case _ =>
}
config.sampleRate = sampleRate
config.outputBusChannels = numChannels
val numPrivate = Prefs.audioNumPrivate.getOrElse(Prefs.defaultAudioNumPrivate)
config.audioBusChannels = (config.outputBusChannels + numPrivate).nextPowerOfTwo
}
sealed trait Selection
case object SpanSelection extends Selection
case object DurationSelection extends Selection
case object NoSelection extends Selection
private object FileType {
final case class PCM(peer: AudioFileType) extends FileType {
override def toString: String = peer.toString
def extension: String = peer.extension
def isPCM = true
}
final case object MP3 extends FileType {
override def toString: String = extension
def extension: String = "mp3"
def isPCM = false
}
}
private sealed trait FileType {
def extension: String
def isPCM: Boolean
}
private final case class KBPS(value: Int) {
override def toString = s"$value kbps"
}
private def mp3BitRates: Vec[KBPS] = Vector(
KBPS( 64), KBPS( 80), KBPS( 96), KBPS(112), KBPS(128),
KBPS(144), KBPS(160), KBPS(192), KBPS(224), KBPS(256), KBPS(320))
final case class SpanPreset(name: String, value: Span) {
override def toString: String = name
}
/////////////////////////////////////////////////////////////////////////////////////////////
def query[T <: Txn[T]](view: UniverseView[T] with View.Editable[T],
init: QuerySettings[T], selectionType: Selection,
spanPresets: ISeq[SpanPreset], hasRealtimeOption: Boolean)
(callback: (QuerySettings[T], Boolean) => Unit): Unit = {
val viewU: UniverseView[T] = view
import view.undoManager
import viewU.{cursor, universe}
val window = Window.find(view.component)
import equal.Implicits._
val ggOk = new Button(" Ok ")
val ggCancel = new Button("Cancel")
val sqFileType = AudioFileType.writable.map(FileType.PCM) :+ FileType.MP3
val ggFileType = new ComboBox[FileType](sqFileType)
ggFileType.selection.item = init.fileFormat match {
case f: FileFormat.PCM => FileType.PCM(f.tpe)
case _: FileFormat.MP3 => FileType.MP3
}
val ggPCMSampleFormat = new ComboBox[SampleFormat](SampleFormat.fromInt16)
val ggMP3BitRate = new ComboBox[KBPS](mp3BitRates)
val ggMP3VBR = new CheckBox("VBR")
val ggMP3Title = new TextField(10)
val ggMP3Artist = new TextField(10)
val ggMP3Comment = new TextField(10)
val lbMP3Title = new Label("Title:")
val lbMP3Artist = new Label("Author:")
val lbMP3Comment = new Label("Comment:")
val ggImport = new CheckBox()
def fileFormatVisibility(pack: Boolean): Unit = {
val isMP3 = ggFileType.selection.item === FileType.MP3
val isPCM = !isMP3
if (!pack || ggPCMSampleFormat.visible != isPCM) {
ggPCMSampleFormat .visible = isPCM
ggMP3BitRate .visible = isMP3
ggMP3VBR .visible = isMP3
lbMP3Title .visible = isMP3
ggMP3Title .visible = isMP3
lbMP3Artist .visible = isMP3
ggMP3Artist .visible = isMP3
lbMP3Comment .visible = isMP3
ggMP3Comment .visible = isMP3
ggImport .enabled = isPCM
if (!isPCM) ggImport.selected = false
if (pack) {
val w = SwingUtilities.getWindowAncestor(ggFileType.peer)
if (w != null) w.pack()
}
}
}
ggFileType.listenTo(ggFileType.selection)
ggFileType.reactions += {
case SelectionChanged(_) => fileFormatVisibility(pack = true)
}
fileFormatVisibility(pack = false)
init.fileFormat match {
case f: FileFormat.PCM =>
ggPCMSampleFormat .selection.item = f.sampleFormat
ggMP3BitRate .selection.item = KBPS(256)
case f: FileFormat.MP3 =>
ggPCMSampleFormat .selection.item = SampleFormat.Int24
ggMP3BitRate .selection.index = mp3BitRates.indexWhere(_.value >= f.kbps)
ggMP3Title .text = f.title
ggMP3Artist .text = f.artist
ggMP3Comment .text = f.comment
ggMP3VBR .selected = f.vbr
}
val ggSampleRate = new SpinnerComboBox[Int](value0 = init.sampleRate,
minimum = 1, maximum = TimeRef.SampleRate.toInt,
step = 100, items = Seq(44100, 48000, 88200, 96000))
val ggPath = new PathField
ggPath.mode = FileDialog.Save
ggPath.title = "Audio Output File"
val ggPathT = ggPath.textField
ggPathT.columns = 0 // otherwise doesn't play nicely with group-panel
ggPath.reactions += {
case ValueChanged(_) => ggOk.enabled = ggPath.valueOption.isDefined
}
def setPath(uri: File): Unit = {
import de.sciss.file._
ggPath.value = uri.replaceExt(ggFileType.selection.item.extension)
}
ggFileType.listenTo(ggFileType.selection)
ggFileType.reactions += {
case SelectionChanged(_) =>
ggPath.valueOption.foreach { p =>
setPath(p)
}
}
init.uriOption.foreach { uri =>
val fOpt = Try(new File(uri)).toOption
fOpt.foreach(ggPath.value_=)
}
val gainModel = new SpinnerNumberModel(init.gain.decibels, -160.0, 160.0, 0.1)
val ggGainAmt = new Spinner(gainModel)
val ggGainType = new ComboBox(Seq("Normalized", "Immediate"))
ggGainType.selection.index = if (init.gain.normalized) 0 else 1
ggGainType.listenTo(ggGainType.selection)
ggGainType.reactions += {
case SelectionChanged(_) =>
ggGainType.selection.index match {
case 0 => gainModel.setValue(-0.2)
case 1 => gainModel.setValue( 0.0)
}
ggGainAmt.requestFocus()
}
val ggRealtime = new CheckBox()
ggRealtime.selected = init.realtime
val ggFineControl = new CheckBox()
ggFineControl.selected = init.fineControl
object fmtRanges extends JFormattedTextField.AbstractFormatter {
def stringToValue(text: String): Vec[Range.Inclusive] = try {
stringToChannels(text)
} catch {
case e @ NonFatal(_) =>
e.printStackTrace()
throw new ParseException(text, 0)
}
def valueToString(value: Any): String = try {
value match {
case sq: Vec[_] =>
sq.map {
case r: Range => channelToString(r)
case _ => throw new IllegalArgumentException
} .mkString(", ")
case _ => throw new IllegalArgumentException
}
} catch {
case NonFatal(_) => throw new ParseException(Option(value).fold("null")(_.toString), 0)
}
}
val ggChannelsJ = new JFormattedTextField(fmtRanges)
ggChannelsJ.setColumns(12)
ggChannelsJ.setValue(init.channels)
ggChannelsJ.setFocusLostBehavior(JFormattedTextField.COMMIT_OR_REVERT)
val ggChannels = Component.wrap(ggChannelsJ)
ggChannels.tooltip = "Ranges of channels to bounce, such as 1-4 or 1,3,5"
val lbPath = new Label("Output File:")
val lbFormat = new Label("Format:")
val lbSampleRate = new Label("Sample Rate [Hz]:")
val lbGain = new Label("Gain [dB]:")
val lbChannels = new Label("Channels:")
val span0F = init.span.nonEmptyOption.getOrElse {
spanPresets.headOption.fold(Span(0L, (10 * TimeRef.SampleRate).toLong))(_.value)
}
val ggSpanStart = new TimeField(span0F.start, Span.Void, sampleRate = TimeRef.SampleRate, viewSampleRate0 = init.sampleRate)
val ggSpanStopOrDur = new TimeField(span0F.stop , Span.Void, sampleRate = TimeRef.SampleRate, viewSampleRate0 = init.sampleRate)
val ggSpanPresets = spanPresets.map { pst =>
new ToggleButton(pst.name) {
listenTo(this)
reactions += {
case ButtonClicked(_) if selected =>
if (selectionType === SpanSelection) {
ggSpanStart .value = pst.value.start
ggSpanStopOrDur .value = pst.value.stop
} else {
ggSpanStopOrDur .value = pst.value.length
}
ggSpanStopOrDur.requestFocus()
}
}
}
val bgSpanPresets = new ButtonGroup(ggSpanPresets: _*)
val lbSpanPresets = new Label("Span:")
val pSpanPresets = new BaselineFlowPanel(ggSpanPresets: _*)
if (ggSpanPresets.isEmpty) {
pSpanPresets .visible = false
lbSpanPresets .visible = false
}
def mkSpan(): SpanOrVoid = selectionType match {
case SpanSelection =>
val a = ggSpanStart .value
val b = ggSpanStopOrDur .value
Span(math.min(a, b), math.max(a, b))
case DurationSelection =>
val a = 0L
val b = ggSpanStopOrDur.value
Span(math.min(a, b), math.max(a, b))
case NoSelection =>
init.span
}
ggSampleRate.listenTo(ggSampleRate.spinner)
ggSampleRate.reactions += {
case ValueChanged(_) =>
val sr = ggSampleRate.value
ggSpanStart .viewSampleRate = sr
ggSpanStopOrDur .viewSampleRate = sr
}
def updatePresetSelection(): Unit = {
val sp = mkSpan()
val idx = if (selectionType === SpanSelection) {
spanPresets.indexWhere(_.value === sp)
} else if (selectionType === DurationSelection) {
val len = sp.length
spanPresets.indexWhere(_.value.length === len)
} else -1
import swingplus.Implicits._
if (idx < 0) bgSpanPresets.clearSelection() else bgSpanPresets.select(ggSpanPresets(idx))
}
val lbSpanStart = new Label
val lbSpanStopOrDur = new Label
val ggSpanStartOpt: Component =
if (selectionType === SpanSelection) ggSpanStart else HStrut(4)
if (selectionType === SpanSelection) {
GUI.linkFormats(ggSpanStart, ggSpanStopOrDur)
lbSpanStart .text = "Start:"
lbSpanStopOrDur .text = "Stop:"
ggSpanStart.listenTo(ggSpanStart)
ggSpanStart.reactions += {
case ValueChanged(_) => updatePresetSelection()
}
ggSpanStopOrDur.listenTo(ggSpanStopOrDur)
ggSpanStopOrDur.reactions += {
case ValueChanged(_) => updatePresetSelection()
}
} else if (selectionType === DurationSelection) {
lbSpanStopOrDur.text = "Duration:"
ggSpanStopOrDur.listenTo(ggSpanStopOrDur)
ggSpanStopOrDur.reactions += {
case ValueChanged(_) => updatePresetSelection()
}
}
updatePresetSelection()
val lbRealtime = new Label("Run in Real-Time:" /* , EmptyIcon, Trailing */)
val lbFineControl = new Label("Fine Control Rate:" /* , EmptyIcon, Trailing */)
val lbImport = new Label("Import into Workspace:" /* , EmptyIcon, Trailing */)
if (!hasRealtimeOption) {
ggRealtime .visible = false
lbRealtime .visible = false
ggFineControl .visible = false
lbFineControl .visible = false
}
ggImport.selected = init.importFile
// do this _here_ because the path has already
// been set at this point, so we can have
// a larger pref-size if the path is long
ggPathT.minimumSize = {
val d = ggPathT.minimumSize
d.width = math.max(320, d.width)
d
}
ggPathT.preferredSize = {
val d = ggPathT.preferredSize
d.width = math.max(320, d.width)
d
}
val P = GroupPanel // make IntelliJ happy
val box = new GroupPanel { panel =>
horizontal = Par(
Seq(lbPath, ggPath), // Seq(Size.fixed(lbPath, Size.Preferred), Size.fill(ggPath, pref = Size.Infinite)),
Seq(
Par(Trailing)(
lbFormat , lbSampleRate, lbGain, lbChannels, lbSpanPresets, lbSpanStart, lbSpanStopOrDur,
lbRealtime, lbFineControl, lbImport, lbMP3Title, lbMP3Artist, lbMP3Comment),
Par(
Seq(ggFileType, ggPCMSampleFormat, ggMP3BitRate, ggMP3VBR), ggSampleRate,
Seq(ggGainAmt, ggGainType), ggChannels, pSpanPresets, ggSpanStartOpt, ggSpanStopOrDur,
ggRealtime, ggFineControl, ggImport,
ggMP3Title, ggMP3Artist, ggMP3Comment
),
P.Gap.Spring()
)
)
vertical = Seq(
Par(Baseline)(lbPath, ggPath),
Par(Baseline)(lbFormat, ggFileType, ggPCMSampleFormat, ggMP3BitRate, ggMP3VBR),
Par(Baseline)(lbSampleRate, ggSampleRate),
Par(Baseline)(lbGain, ggGainAmt, ggGainType),
Par(Baseline)(lbChannels, ggChannels),
Par(Baseline)(lbSpanPresets, pSpanPresets),
Par(Baseline)(lbSpanStart, ggSpanStartOpt),
Par(Baseline)(lbSpanStopOrDur, ggSpanStopOrDur),
P.Gap.Preferred(Unrelated),
Par(Baseline)(lbRealtime, ggRealtime),
Par(Baseline)(lbFineControl, ggFineControl),
Par(Baseline)(lbImport, ggImport),
P.Gap.Preferred(Unrelated),
Par(Baseline)(lbMP3Title , ggMP3Title ),
Par(Baseline)(lbMP3Artist , ggMP3Artist ),
Par(Baseline)(lbMP3Comment, ggMP3Comment)
)
}
// val opt = OptionPane.confirmation(message = box, optionType = OptionPane.Options.OkCancel,
// messageType = OptionPane.Message.Plain)
def findWindow(): Option[awt.Window] = Option(SwingUtilities.getWindowAncestor(box.peer))
// --------------------------
// val ok = optRes === OptionPane.Result.Ok
var wasCompleted = false
def dialogComplete(ok: Boolean): Unit = if (!wasCompleted) {
wasCompleted = true
findWindow().foreach(_.dispose())
val fileOpt = ggPath.valueOption
val channels: Vec[Range.Inclusive] = try {
fmtRanges.stringToValue(ggChannelsJ.getText)
} catch {
case _: ParseException => init.channels
}
val importFile = ggImport.selected
val spanOut = mkSpan()
val sampleRate = ggSampleRate.value
val fileFormat: FileFormat = ggFileType.selection.item match {
case FileType.PCM(tpe) =>
FileFormat.PCM(tpe, ggPCMSampleFormat.selection.item)
case FileType.MP3 =>
FileFormat.MP3(kbps = ggMP3BitRate.selection.item.value,
vbr = ggMP3VBR.selected, title = ggMP3Title.text, artist = ggMP3Artist.text, comment = ggMP3Comment.text)
}
var settings = QuerySettings(
realtime = ggRealtime .selected,
fineControl = ggFineControl.selected,
uriOption = fileOpt.map(_.toURI),
fileFormat = fileFormat,
sampleRate = sampleRate,
gain = Gain(gainModel.getNumber.floatValue(), if (ggGainType.selection.index == 0) true else false),
span = spanOut,
channels = channels,
importFile = importFile,
location = init.location
)
val ok2: Boolean = fileOpt match {
case Some(f) if importFile =>
val uri = f.toURI
init.location match {
case Some(source) if cursor.step { implicit tx =>
val parent = source().directory
Try(Artifact.Value.relativize(parent, uri)).isSuccess
} => // ok, keep previous location
ok
case _ => // either no location was set, or it's not parent of the file
ActionArtifactLocation.query[T](uri)(implicit tx => universe.workspace.root) match {
case Some((either, directory)) =>
either match {
case Left(source) =>
settings = settings.copy(location = Some(source))
ok
case Right(name) =>
val source = cursor.step { implicit tx =>
val locObj = ActionArtifactLocation.create[T](name = name, directory = directory)
val folder = universe.workspace.root
EditFolder.appendUndo[T](folder, child = locObj)
tx.newHandle(locObj)
}
settings = settings.copy(location = Some(source))
ok
}
case _ => false // return (settings, false)
}
}
case _ => ok
}
fileOpt match {
case Some(f) if ok2 && f.exists() =>
val ok3 = Dialog.showConfirmation(
message = s"File
${f.getPath}
already exists.Are you sure you want to overwrite it?",
title = title,
optionType = Dialog.Options.OkCancel,
messageType = Dialog.Message.Warning
) === Dialog.Result.Ok
callback(settings, ok3)
case None if ok2 =>
query(view, settings, selectionType, spanPresets, hasRealtimeOption = hasRealtimeOption)(callback)
case _ =>
callback(settings, ok2)
}
}
ggOk.listenTo(ggOk)
ggOk.reactions += {
case ButtonClicked(_) => dialogComplete(ok = true)
}
ggOk.enabled = ggPath.valueOption.isDefined
ggCancel.listenTo(ggCancel)
ggCancel.reactions += {
case ButtonClicked(_) => dialogComplete(ok = false)
}
val optEntries = Seq(ggOk, ggCancel)
val opt = OptionPane(message = box, optionType = OptionPane.Options.OkCancel,
messageType = OptionPane.Message.Plain, entries = optEntries)
opt.title = title
opt.showNonModal(window)
// XXX TODO --- the price for non-modality
findWindow().foreach { w =>
w.addWindowListener(new WindowAdapter {
// this happens when window bar's close button is pressed
override def windowClosed (e: WindowEvent): Unit = dialogComplete(ok = false)
override def windowClosing(e: WindowEvent): Unit = dialogComplete(ok = false)
})
w.addComponentListener(new ComponentAdapter {
// this happens when 'escape' is pressed
override def componentHidden(e: ComponentEvent): Unit = dialogComplete(ok = false)
})
}
}
/////////////////////////////////////////////////////////////////////////////////////////////
/** Monitors the bounce process, possibly opening a status window, displaying errors,
* and eventually importing the target file into the workspace if the import option was set.
*/
def monitorProcess[T <: Txn[T]](p: ProcessorLike[Any, Any], settings: QuerySettings[T], uri: URI,
view: UniverseView[T]): Unit = {
import view.{cursor, universe}
import universe.workspace
val window = Window.find(view.component)
var processCompleted = false
val ggProgress = new ProgressBar()
val ggCancel = new Button("Abort")
ggCancel.focusable = false
lazy val op = OptionPane(message = ggProgress, messageType = OptionPane.Message.Plain,
entries = Seq(ggCancel))
val title = s"Exporting to ${uri.name} ..."
op.title = title
ggCancel.listenTo(ggCancel)
ggCancel.reactions += {
case ButtonClicked(_) =>
p.abort()
// currently process doesn't seem to abort under certain errors
// (e.g. buffer allocator exhausted). XXX TODO
val run = new Runnable { def run(): Unit = { Thread.sleep(1000); defer(fDispose()) }}
new Thread(run).start()
}
ggCancel.listenTo(ggCancel)
ggCancel.reactions += {
case ButtonClicked(_) =>
p.abort()
// currently process doesn't seem to abort under certain errors
// (e.g. buffer allocator exhausted). XXX TODO
val run = new Runnable { def run(): Unit = { Thread.sleep(1000); defer(fDispose()) }}
new Thread(run).start()
}
def fDispose(): Unit = {
val w = SwingUtilities.getWindowAncestor(op.peer); if (w != null) w.dispose()
processCompleted = true
}
p.addListener {
case pr @ Processor.Progress(_, _) => defer(ggProgress.value = pr.toInt)
}
def bounceDone(): Unit = {
if (DEBUG) println("allDone")
defer(fDispose())
(settings.importFile, settings.location) match {
case (true, Some(locSource)) =>
AudioFile.readSpecAsync(uri).foreach { spec =>
cursor.step { implicit tx =>
val loc = locSource()
val depArt = Artifact(loc, uri)
val depOffset = LongObj .newVar[T](0L)
val depGain = DoubleObj.newVar[T](1.0)
val deployed = AudioCue.Obj[T](depArt, spec, depOffset, depGain)
deployed.name = uri.base
workspace.root.addLast(deployed)
}
}
case _ =>
val fileOpt = Try(new File(uri)).toOption
fileOpt.foreach { f =>
Desktop.revealFile(f)
}
}
}
val onFailure: PartialFunction[Throwable, Unit] = {
case Processor.Aborted() =>
defer(fDispose())
case ex =>
defer {
fDispose()
DialogSource.Exception(ex -> title).show(window)
}
}
p.onComplete {
case Failure(ex) => onFailure(ex)
case _ => bounceDone()
}
desktop.Util.delay(500) {
if (!processCompleted) op.show(window)
}
}
def performGUI[T <: Txn[T]](view: UniverseView[T],
settings: QuerySettings[T],
group: IIterable[Source[T, Obj[T]]], uri: URI, span: Span): Unit = {
import view.universe
val pSet = settings.prepare(group, uri)(span)
val process: ProcessorLike[Any, Any] = perform(pSet)
monitorProcess(process, settings, uri, view)
}
/////////////////////////////////////////////////////////////////////////////////////////////
def perform[T <: Txn[T]](settings: PerformSettings[T])
(implicit universe: Universe[T]): Processor[File] = {
// for real-time, we generally have to overshoot because in SC 3.6, DiskOut's
// buffer is not flushed after synth is stopped.
val realtime = settings.realtime
val normalized = settings.gain.normalized
val compressed = !settings.fileFormat.isPCM
val numServerChannels = settings.server.outputBusChannels
val numFileChannels = mkNumFileChannels(settings.channels)
assert (numFileChannels <= numServerChannels, s"numFileChannels $numFileChannels, numServerChannels $numServerChannels")
val lessChannels = numFileChannels < numServerChannels
val needsTemp = realtime || normalized || compressed || lessChannels
val span = settings.span
val fileOut = new File(settings.server.nrtOutputPath)
val sampleRate = settings.server.sampleRate
val fileFrames0 = (span.length * sampleRate / TimeRef.SampleRate + 0.5).toLong
val fileFrames = fileFrames0 // - (fileFrames0 % settings.server.blockSize)
val settings1: PerformSettings[T] = if (!needsTemp) settings else {
val fTmp = File.createTempFile("bounce", ".w64")
fTmp.deleteOnExit()
val sConfig = Server.ConfigBuilder(settings.server)
sConfig.nrtOutputPath = fTmp.getPath
sConfig.nrtHeaderFormat = AudioFileType.Wave64
sConfig.nrtSampleFormat = SampleFormat.Float
// if (realtime) sConfig.outputBusChannels = 0
settings.copy(server = sConfig)
}
val bounce = Bounce[T]() // workspace.I
val bnc = Bounce.Config[T]()
bnc.group = settings1.group
bnc.realtime = realtime
bnc.server.read(settings1.server)
bnc.client.read(settings1.client)
val bncGainFactor = if (settings1.gain.normalized) 1f else settings1.gain.linear
val selectedChannels = settings1.channels.flatten
val numPlayChannels = mkNumPlayChannels(settings1.channels)
val span1 = if (!realtime) span else {
val bufDur = Buffer.defaultRecBufferSize.toDouble / bnc.server.sampleRate
// apart from DiskOut buffer, add a bit of head-room (100ms) to account for jitter
val bufFrames = ((bufDur + 0.1) * TimeRef.SampleRate + 0.5).toLong
val numFrames = span.length + bufFrames // (span.length + bufFrames - 1) / bufFrames * bufFrames
Span(span.start, span.start + numFrames)
}
bnc.span = span1
bnc.beforePrepare = { (_tx, s) =>
implicit val tx: T = _tx
// // make sure no private bus overlaps with the virtual output
// if (numInChans > numChannels) {
// s.allocAudioBus(numInChans - numChannels)
// }
val graph = SynthGraph {
import synth._
import Import._
import ugen._
val sigIn = In.ar(0, numPlayChannels)
val sigOut: GE = selectedChannels.map { in =>
val chIn = sigIn.out(in)
chIn * bncGainFactor
}
ReplaceOut.ar(0, sigOut)
}
// on Linux, scsynth in real-time connects to jack,
// but the -H switch doesn't seem to work. So we end
// up with a sample-rate determined by Jack and not us
// (see https://github.com/Sciss/Mellite/issues/30).
// As a clumsy work-around, we abort the bounce if we
// see that the rate is incorrect.
if (s.sampleRate != sampleRate) {
throw new IOException(
s"Real-time bounce - SuperCollider failed to set requested sample-rate of $sampleRate Hz. " +
"Use a matching sample-rate, or try disabling real-time.")
}
Synth.play(graph)(s.defaultGroup, addAction = addToTail)
}
// XXX TODO --- could use filtered console output via Poll to
// measure max gain already during bounce
val bProcess = bounce.apply(bnc)
// bProcess.addListener {
// case u => println(s"UPDATE: $u")
// }
bProcess.start()
val process = if (!needsTemp) bProcess else {
postProcess(
bounce = bProcess,
fileOut = fileOut,
fileFormat = settings.fileFormat,
gain = if (normalized) settings1.gain else Gain.immediate(0f),
numFrames = fileFrames,
numChannels = numFileChannels
)
}
process
}
/** Converts a bounced file to another file format, for example using compression,
* optionally normalizing the output.
*
* @param bounce the ongoing or completed bounce process
* @param fileOut the file created by the post processing
* @param fileFormat the target file format
* @param gain relative gain or normalized headroom for the output
* @param numFrames the nominal number of frames to process. Real-time bounce may be
* slightly off, thus this allowed to truncate the length of the post-processing output.
* Use -1 to use the length reported by the bounced file.
* @param numChannels the number of output file channels, which may be smaller than the number
* of channels the bounce output has
*/
def postProcess(bounce: ProcessorLike[File, Any],
fileOut: File, fileFormat: FileFormat,
gain: Gain, numFrames: Long, numChannels: Int): Processor[File] = {
val nProcess = new PostProcessor(bounce = bounce, fileOut = fileOut, fileFormat = fileFormat, gain = gain,
numFrames = numFrames, numChannels = numChannels)
nProcess.start()
nProcess
}
private final class PostProcessor[T <: Txn[T]](bounce: ProcessorLike[File, Any],
fileOut: File, fileFormat: FileFormat,
gain: Gain, numFrames: Long, numChannels: Int)
extends ProcessorImpl[File, Processor[File]] with Processor[File] {
override def toString = s"Normalize bounce $fileOut"
def body(): File = blocking {
// XXX TODO use async API
val fileIn = await(bounce, target = if (gain.normalized) 0.8 else 0.9) // arbitrary weight
// tricky --- scsynth flush might not yet be seen
// thus wait a few seconds until header becomes available
val t0 = System.currentTimeMillis()
while (AudioFile.readSpec(fileIn).numFrames == 0L && System.currentTimeMillis() - t0 < 4000) {
blocking(Thread.sleep(500))
}
val afIn = AudioFile.openRead(fileIn)
import numbers.Implicits._
try {
val bufSz = 8192
val buf = afIn.buffer(bufSz)
val numFrames0 = if (numFrames < 0L) afIn.numFrames else math.min(afIn.numFrames, numFrames)
var rem = numFrames0
// if (rem >= afIn.numFrames)
// throw new EOFException(s"Bounced file is too short (${afIn.numFrames} -- expected at least $rem)")
if (afIn.numFrames == 0)
throw new EOFException("Exported file is empty")
val mul = if (!gain.normalized) gain.linear else {
var max = 0.0
while (rem > 0) {
val chunk = math.min(bufSz, rem).toInt
afIn.read(buf, 0, chunk)
var ch = 0; while (ch < numChannels /*buf.length*/) {
val cBuf = buf(ch)
var i = 0; while (i < chunk) {
val f = math.abs(cBuf(i))
if (f > max) max = f
i += 1
}
ch += 1
}
rem -= chunk
progress = (rem.toDouble / numFrames0).linLin(0, 1, 0.9, 0.8)
checkAborted()
}
afIn.seek(0L)
if (max == 0) 1.0 else gain.linear / max
}
def writePCM(f: File, tpe: AudioFileType, sampleFormat: SampleFormat, maxChannels: Int,
progStop: Double): Unit = {
val numCh = if (maxChannels <= 0) /*afIn.*/numChannels else math.min(/*afIn.*/numChannels, maxChannels)
val afOut = AudioFile.openWrite(f,
afIn.spec.copy(fileType = tpe, numChannels = numCh, sampleFormat = sampleFormat, byteOrder = None))
try {
rem = numFrames0
while (rem > 0) {
val chunk = math.min(bufSz, rem).toInt
afIn.read(buf, 0, chunk)
if (mul != 1) {
var ch = 0; while (ch < numCh) {
val cBuf = buf(ch)
var i = 0; while (i < chunk) {
cBuf(i) *= mul
i += 1
}
ch += 1
}
}
afOut.write(buf, 0, chunk)
rem -= chunk
progress = (rem.toDouble / numFrames0).linLin(0, 1, progStop, 0.9)
checkAborted()
}
afOut.close()
afIn .close()
} finally {
if (afOut.isOpen) afOut.cleanUp()
}
}
fileFormat match {
case FileFormat.PCM(fileType, sampleFormat) =>
writePCM(fileOut, fileType, sampleFormat, maxChannels = 0, progStop = 1.0)
case mp3: FileFormat.MP3 =>
val fTmp = File.createTempFile("bounce", ".aif")
fTmp.deleteOnExit()
try {
writePCM(fTmp, AudioFileType.AIFF, SampleFormat.Int16, maxChannels = 2, progStop = 0.95)
var lameArgs = List[String](fTmp.getPath, fileOut.getPath)
if (mp3.comment .nonEmpty) lameArgs :::= List("--tc", mp3.comment)
if (mp3.artist .nonEmpty) lameArgs :::= List("--ta", mp3.artist )
if (mp3.title .nonEmpty) lameArgs :::= List("--tt", mp3.title )
lameArgs :::= List[String](
"-h", "-S", "--noreplaygain", // high quality, silent, no AGC
if (mp3.vbr) "--abr" else "-b", mp3.kbps.toString // bit rate
)
blocking {
val lame = new de.sciss.jump3r.Main
val res = lame.run(lameArgs.toArray)
if (res != 0) throw new Exception(s"LAME mp3 encoder failed with code $res")
}
progress = 1.0
checkAborted()
} finally {
fTmp.delete()
}
}
} finally {
if (afIn.isOpen) afIn.cleanUp()
afIn.uri.foreach { uri =>
AsyncFile.getFileSystemProvider(uri).foreach { fsp =>
fsp.obtain().foreach { af =>
af.delete(uri)
}
}
}
}
fileOut
}
}
}
class ActionBounce[T <: Txn[T]](protected val view: UniverseView[T] with View.Editable[T],
protected val objH: Source[T, Obj[T]],
storeSettings : Boolean = true,
hasRealtimeOption : Boolean = false,
)
extends scala.swing.Action(ActionBounce.title) {
import ActionBounce.{storeSettings => _, _}
private[this] var settings = QuerySettings[T]()
private[this] var hadSettings = false
protected def prepare(settings: QuerySettings[T], recalled: Boolean): QuerySettings[T] = settings
protected type SpanPresets = ISeq[SpanPreset]
protected def spanPresets(): SpanPresets = Nil
protected def selectionType: Selection = SpanSelection
protected def defaultRealtime(implicit tx: T): Boolean = false
protected def defaultFile (implicit tx: T): URI = Artifact.Value.empty
protected def defaultChannels(implicit tx: T): Vec[Range.Inclusive] = Vector(0 to 1)
import view.cursor
/** subclasses may override this if they wish to perform a different kind
* of bounce (e.g. not SuperCollider-based)
*
* @param uri definitely specifies `settings.uriOption`, i.e. the target file
* @param span definitely specifies `settings.span`, i.e. the object's span to be bounced
*/
protected def performGUI(settings: QuerySettings[T], uri: URI, span: Span): Unit =
ActionBounce.performGUI(view, settings, objH :: Nil, uri, span)
def apply(): Unit = {
if (storeSettings) {
val tup = cursor.step { implicit tx =>
ActionBounce.recallSettings(objH(),
defaultRealtime = defaultRealtime, defaultFile = defaultFile, defaultChannels = defaultChannels)
}
settings = tup._1
hadSettings = tup._2
}
val setUpd = prepare(settings, recalled = hadSettings)
val presets = spanPresets()
hadSettings = true
query(view, setUpd, selectionType, presets, hasRealtimeOption = hasRealtimeOption) { (_settings, ok) =>
settings = _settings
if (ok) {
if (storeSettings) {
cursor.step { implicit tx =>
ActionBounce.storeSettings(_settings, objH(), hasRealtimeOption = hasRealtimeOption)
}
}
for {
f <- _settings.uriOption
span <- _settings.span.nonEmptyOption
} {
performGUI(_settings, f, span)
}
}
}
}
}