diff --git a/docs/img/plugin/generators/fruity-slicer.png b/docs/img/plugin/generators/fruity-slicer.png new file mode 100644 index 0000000..030f78f Binary files /dev/null and b/docs/img/plugin/generators/fruity-slicer.png differ diff --git a/pyflp/channel.py b/pyflp/channel.py index ae0c53f..8e48e1a 100644 --- a/pyflp/channel.py +++ b/pyflp/channel.py @@ -42,7 +42,7 @@ ) from pyflp._models import EventModel, ItemModel, ModelCollection, ModelReprMixin, supports_slice from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet -from pyflp.plugin import BooBass, FruitKick, Plucked, PluginID, PluginProp, VSTPlugin +from pyflp.plugin import BooBass, FruitKick, FruitySlicer, Plucked, PluginID, PluginProp, VSTPlugin from pyflp.types import RGBA, MusicalTime __all__ = [ @@ -1442,7 +1442,7 @@ def tracking(self) -> dict[str, Tracking] | None: class Instrument(_SamplerInstrument): """Represents a native or a 3rd party plugin loaded in a channel.""" - plugin = PluginProp(VSTPlugin, BooBass, FruitKick, Plucked) + plugin = PluginProp(VSTPlugin, BooBass, FruitKick, FruitySlicer, Plucked) """The plugin loaded into the channel.""" diff --git a/pyflp/plugin.py b/pyflp/plugin.py index 6afba1d..2499330 100644 --- a/pyflp/plugin.py +++ b/pyflp/plugin.py @@ -47,6 +47,7 @@ "FruityFastDist", "FruityNotebook2", "FruitySend", + "FruitySlicer", "FruitySoftClipper", "FruityStereoEnhancer", "Plucked", @@ -155,6 +156,49 @@ class FruitySendEvent(StructEventBase): ).compile() +class FruitySlicerEvent(StructEventBase): + STRUCT = c.Struct( + "_u1" / c.Bytes(8), + "bpm" / c.Float32l, + "pitch_shift" / c.Int32sl, + "time_stretch" / c.Int32sl, + "stretching_method" + / c.Enum( + c.Int32ul, + fill_gaps=0, + alt_fill_gaps=1, + pro_default=2, + pro_transient=3, + transient=4, + tonal=5, + monophonic=6, + speech=7, + ), + "fade_in" / c.Int32ul, + "fade_out" / c.Int32ul, + "file_path" / c.PascalString(c.Int8ul, "utf-8"), + "slices" + / c.PrefixedArray( + c.Int32ul, + c.Struct( + "name" / c.PascalString(c.Int8ul, "utf-8"), # TODO: This is a special format + "sample_offset" / c.Int32ul, + "key" / c.Int32sl, + "_u1" / c.Float32l, + "reversed" / c.Flag, + ), + ), + "animate" / c.Flag, + "start_note" / c.Int32ul, + "play_to_end" / c.Flag, + "bitrate" / c.Int32ul, + "auto_dump" / c.Flag, + "declick" / c.Flag, + "auto_fit" / c.Flag, + "view_spectrum" / FourByteBool, + ).compile() + + class FruitySoftClipperEvent(StructEventBase): STRUCT = c.Struct("threshold" / c.Int32ul, "post" / c.Int32ul).compile() @@ -1007,6 +1051,84 @@ class FruitySend(_PluginBase[FruitySendEvent], _IPlugin, ModelReprMixin): """ +class FruitySlicer(_PluginBase[FruitySlicerEvent], _IPlugin, ModelReprMixin): + """![](https://bit.ly/46mngih)""" + + INTERNAL_NAME = "Fruity Slicer" + bpm = _NativePluginProp[float]() + """The BPM (beats per minute) of the sample.""" + + pitch_shift = _NativePluginProp[int]() + """Pitch shift, in cents. Linear. + + | Type | Value | Representation | + |---------|-------|----------------| + | Min | -1200 | -1200 cents | + | Max | 1200 | +1200 cents | + | Default | 0 | +0 cent | + """ + + time_stretch = _NativePluginProp[int]() + """Logarithmic. + + | Type | Value | Representation | + |---------|--------|-------------------| + | Min | -20000 | 25% / 60-240 bpm | + | Max | 20000 | 400% / 60-15 bpm | + | Default | 0 | 100% / 0-0 bpm | + """ + + stretching_method = _NativePluginProp[ + Literal[ + "fill_gaps", + "alt_fill_gaps", + "pro_default", + "pro_transient", + "transient", + "tonal", + "monophonic", + "speech", + ] + ]() + """The stretching method to use on the sample when `time_stretch` is not 0.""" + + fade_in = _NativePluginProp[int]() + """Slice fade in, in milliseconds.""" + + fade_out = _NativePluginProp[int]() + """Slice fade out, in milliseconds.""" + + file_path = _NativePluginProp[str]() + """The file path of the sample.""" + + slices = _NativePluginProp[list[dict]]() + """A list of slices.""" + + animate = _NativePluginProp[bool]() + """Whether to highlight the slices as they are played.""" + + start_note = _NativePluginProp[int]() + """The MIDI note for slicing to start on. Default 60.""" + + play_to_end = _NativePluginProp[bool]() + """Whether to play slices to the end of the sample.""" + + bitrate = _NativePluginProp[int]() + """The bitrate of the sample.""" + + auto_dump = _NativePluginProp[bool]() + """Whether to automatically dump slices to the piano roll.""" + + declick = _NativePluginProp[bool]() + """Whether to prevent clicking on slices.""" + + auto_fit = _NativePluginProp[bool]() + """Whether to automatically fit the beat to the project tempo on load.""" + + view_spectrum = _NativePluginProp[bool]() + """Whether to view the slices as a spectrum instead of a waveform.""" + + class FruitySoftClipper(_PluginBase[FruitySoftClipperEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3BCWfJX)""" diff --git a/tests/assets/plugins/fruity-slicer.fst b/tests/assets/plugins/fruity-slicer.fst new file mode 100644 index 0000000..d424469 Binary files /dev/null and b/tests/assets/plugins/fruity-slicer.fst differ diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 0750646..ee33765 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -11,6 +11,7 @@ FruityCenter, FruityFastDist, FruitySend, + FruitySlicer, FruitySoftClipper, FruityStereoEnhancer, Plucked, @@ -81,6 +82,32 @@ def test_fruity_send(): assert fruity_send.volume == 256 +def test_fruity_slicer(): + fruity_slicer = get_plugin("fruity-slicer.fst", FruitySlicer) + assert fruity_slicer.bpm == 60 + assert fruity_slicer.pitch_shift == 100 + assert fruity_slicer.time_stretch == 4300 + assert fruity_slicer.stretching_method == "transient" + assert fruity_slicer.fade_in == 56 + assert fruity_slicer.fade_out == 5 + assert fruity_slicer.file_path == r"Z:\home\user\Music\audio.wav" + + test_slice = fruity_slicer.slices[0] + assert test_slice.name == "1 - Beat 1" + assert test_slice.sample_offset == 0 + assert test_slice.key == -1 + assert test_slice.reversed is False + + assert fruity_slicer.animate is False + assert fruity_slicer.start_note == 0 + assert fruity_slicer.play_to_end is False + assert fruity_slicer.bitrate == 44100 + assert fruity_slicer.auto_dump is False + assert fruity_slicer.declick is True + assert fruity_slicer.auto_fit is False + assert fruity_slicer.view_spectrum is False + + def test_fruity_soft_clipper(): fruity_soft_clipper = get_plugin("fruity-soft-clipper.fst", FruitySoftClipper) assert fruity_soft_clipper.threshold == 100