splifft 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
splifft/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Lightweight utilities for music source separation."""
2
+
3
+ from pathlib import Path
4
+
5
+ # TODO: this should be in the io module and we should use user cache paths to store models
6
+ PATH_MODULE = Path(__file__).parent.parent
7
+ PATH_BASE = PATH_MODULE.parent
8
+ PATH_DATA = PATH_BASE / "data"
9
+ PATH_CONFIG = PATH_DATA / "config"
10
+ PATH_MODELS = PATH_DATA / "models"
11
+
12
+ # NOTE: not re-exporting because our structure is simple enough.
splifft/__main__.py ADDED
@@ -0,0 +1,197 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Annotated, Optional
4
+
5
+ import typer
6
+ from rich.logging import RichHandler
7
+
8
+ logging.basicConfig(level="INFO", format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
9
+ logger = logging.getLogger(__name__)
10
+
11
+ app = typer.Typer(
12
+ pretty_exceptions_show_locals=False,
13
+ help="A CLI for source separation.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ # TODO: migrate away from hardcoding.
19
+ _DEFAULT_MODULE_NAME = "splifft.models.bs_roformer"
20
+ _DEFAULT_CLASS_NAME = "BSRoformer"
21
+
22
+
23
+ @app.command()
24
+ def separate(
25
+ mixture_path: Annotated[
26
+ Path,
27
+ typer.Argument(
28
+ ...,
29
+ exists=True,
30
+ file_okay=True,
31
+ dir_okay=True,
32
+ readable=True,
33
+ help="Path to the audio file to be separated.",
34
+ ),
35
+ ],
36
+ config_path: Annotated[
37
+ Path,
38
+ typer.Option(
39
+ "--config",
40
+ exists=True,
41
+ file_okay=True,
42
+ dir_okay=False,
43
+ readable=True,
44
+ help="Path to the model's JSON configuration file.",
45
+ ),
46
+ ],
47
+ checkpoint_path: Annotated[
48
+ Path,
49
+ typer.Option(
50
+ "--checkpoint",
51
+ exists=True,
52
+ file_okay=True,
53
+ dir_okay=False,
54
+ readable=True,
55
+ help="Path to the model's `.pt` or `.ckpt` checkpoint file.",
56
+ ),
57
+ ],
58
+ module_name: Annotated[
59
+ str,
60
+ typer.Option(
61
+ "--module",
62
+ help="Python module containing the model and configuration class.",
63
+ ),
64
+ ] = _DEFAULT_MODULE_NAME,
65
+ class_name: Annotated[
66
+ str,
67
+ typer.Option(
68
+ "--class",
69
+ help="Name of the model class to load from the module.",
70
+ ),
71
+ ] = _DEFAULT_CLASS_NAME,
72
+ package_name: Annotated[
73
+ Optional[str],
74
+ typer.Option(
75
+ "--package",
76
+ help=(
77
+ "The package to use as the anchor point from which to resolve the relative import "
78
+ "to an absolute import. This is only required when performing a relative import."
79
+ ),
80
+ ),
81
+ ] = None,
82
+ output_dir: Annotated[
83
+ Optional[Path],
84
+ typer.Option(
85
+ "--output",
86
+ "-o",
87
+ file_okay=False,
88
+ dir_okay=True,
89
+ writable=True,
90
+ help="Directory to save the separated audio stems.",
91
+ ),
92
+ ] = None,
93
+ cpu: Annotated[
94
+ bool, typer.Option("--cpu", help="Force processing on CPU, even if CUDA is available.")
95
+ ] = False,
96
+ ) -> None:
97
+ """Separates an audio file into its constituent stems."""
98
+ import torch
99
+ import torchaudio
100
+
101
+ from .config import Config
102
+ from .core import get_dtype
103
+ from .inference import run_inference_on_file
104
+ from .io import load_weights, read_audio
105
+ from .models import ModelMetadata
106
+
107
+ use_cuda = not cpu and torch.cuda.is_available()
108
+ device = torch.device("cuda" if use_cuda else "cpu")
109
+ logger.info(f"using {device=}")
110
+
111
+ logger.info(f"loading configuration from {config_path=}")
112
+ config = Config.from_file(config_path)
113
+
114
+ logger.info(f"loading model metadata `{class_name}` from module `{module_name}`")
115
+ model_metadata = ModelMetadata.from_module(
116
+ module_name=module_name,
117
+ model_cls_name=class_name,
118
+ model_type=config.model_type,
119
+ package=package_name,
120
+ )
121
+ config_model_concrete = config.model.to_concrete(model_metadata.config)
122
+ model = model_metadata.model(config_model_concrete)
123
+ if config.inference.force_weights_dtype:
124
+ model = model.to(get_dtype(config.inference.force_weights_dtype))
125
+ logger.info(f"loading weights from {checkpoint_path=}")
126
+ model = load_weights(model, checkpoint_path, device).eval()
127
+ if (c := config.inference.compile_model) is not None:
128
+ logger.info("enabled torch compilation")
129
+ model = torch.compile(model, fullgraph=c.fullgraph, dynamic=c.dynamic, mode=c.mode) # type: ignore
130
+
131
+ mixture_paths = mixture_path.glob("*") if mixture_path.is_dir() else [mixture_path]
132
+ for mixture_path in mixture_paths:
133
+ logger.info(f"processing audio file: {mixture_path=}")
134
+ mixture = read_audio(
135
+ mixture_path,
136
+ config.audio_io.target_sample_rate,
137
+ config.audio_io.force_channels,
138
+ device=device,
139
+ )
140
+ output_stems = run_inference_on_file(
141
+ mixture,
142
+ config=config,
143
+ model=model,
144
+ )
145
+ if not config.output:
146
+ return
147
+ curr_output_dir = output_dir or Path("./data/audio/output") / mixture_path.stem
148
+ curr_output_dir.mkdir(parents=True, exist_ok=True)
149
+ for stem_name, stem_data in output_stems.items():
150
+ if config.output.stem_names != "all" and stem_name not in config.output.stem_names:
151
+ continue
152
+
153
+ output_file = (curr_output_dir / stem_name).with_suffix(f".{config.output.file_format}")
154
+ torchaudio.save(
155
+ output_file,
156
+ stem_data.cpu(),
157
+ mixture.sample_rate,
158
+ format=config.output.file_format,
159
+ encoding=config.output.audio_encoding,
160
+ bits_per_sample=config.output.bit_depth,
161
+ # TODO: compression, backend
162
+ )
163
+ logger.info(f"wrote stem `{stem_name}` to {output_file}")
164
+
165
+
166
+ @app.command()
167
+ def debug() -> None:
168
+ """Prints detailed information about the environment, dependencies, and hardware
169
+ for debugging purposes."""
170
+ import sys
171
+
172
+ logger.info(f"{sys.version=}")
173
+ logger.info(f"{sys.executable=}")
174
+ logger.info(f"{sys.platform=}")
175
+ import platform
176
+
177
+ logger.info(f"{platform.system()=} ({platform.release()})")
178
+ logger.info(f"{platform.machine()=}")
179
+ import torch
180
+
181
+ logger.info(f"{torch.__version__=}")
182
+ logger.info(f"{torch.cuda.is_available()=}")
183
+ if torch.cuda.is_available():
184
+ logger.info(f"{torch.cuda.device_count()=}")
185
+ logger.info(f"{torch.cuda.current_device()=}")
186
+ device = torch.cuda.current_device()
187
+ logger.info(f"{torch.cuda.get_device_name(device)=}")
188
+ logger.info(f"{torch.cuda.get_device_capability(device)=}")
189
+ logger.info(f"{torch.cuda.get_device_properties(device)=}")
190
+ import torchaudio
191
+
192
+ logger.info(f"{torchaudio.__version__=}")
193
+ logger.info(f"{torchaudio.list_audio_backends()=}")
194
+
195
+
196
+ if __name__ == "__main__":
197
+ app()
splifft/config.py ADDED
@@ -0,0 +1,265 @@
1
+ """Configuration"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import (
8
+ Annotated,
9
+ Any,
10
+ Hashable,
11
+ Literal,
12
+ Sequence,
13
+ TypeAlias,
14
+ TypeVar,
15
+ Union,
16
+ get_args,
17
+ )
18
+
19
+ from annotated_types import Len
20
+ from pydantic import (
21
+ AfterValidator,
22
+ BaseModel,
23
+ BeforeValidator,
24
+ ConfigDict,
25
+ Discriminator,
26
+ Field,
27
+ StringConstraints,
28
+ TypeAdapter,
29
+ model_validator,
30
+ )
31
+ from pydantic_core import PydanticCustomError
32
+ from typing_extensions import Self
33
+
34
+ from .core import (
35
+ AudioEncoding,
36
+ BatchSize,
37
+ BitDepth,
38
+ Channels,
39
+ ChunkSize,
40
+ Dtype,
41
+ FileFormat,
42
+ OverlapRatio,
43
+ PaddingMode,
44
+ SampleRate,
45
+ WindowShape,
46
+ )
47
+ from .models import ModelConfigLikeT, ModelType
48
+ from .models import ModelOutputStemName as _ModelOutputStemName
49
+
50
+ # NOTE: we are not using typing.TYPE_CHECKING because pydantic relies on that
51
+
52
+ _PYDANTIC_STRICT_CONFIG = ConfigDict(strict=True, extra="forbid")
53
+
54
+ _Item = TypeVar("_Item", bound=Hashable)
55
+
56
+
57
+ def _to_tuple(sequence: Sequence[_Item]) -> tuple[_Item, ...]:
58
+ # this is so json arrays are converted to tuples
59
+ return tuple(sequence)
60
+
61
+
62
+ Tuple = Annotated[tuple[_Item, ...], BeforeValidator(_to_tuple)]
63
+
64
+
65
+ def _validate_unique_sequence(sequence: Sequence[_Item]) -> Sequence[_Item]:
66
+ # e.g. to ensure there are no duplicate stem names
67
+ if len(sequence) != len(set(sequence)):
68
+ raise PydanticCustomError("unique_sequence", "Sequence must contain unique items")
69
+ return sequence
70
+
71
+
72
+ _S = TypeVar("_S")
73
+ NonEmptyUnique = Annotated[
74
+ _S,
75
+ Len(min_length=1),
76
+ AfterValidator(_validate_unique_sequence),
77
+ Field(json_schema_extra={"unique_items": True}),
78
+ ]
79
+
80
+ ModelInputStemName: TypeAlias = Literal["mixture"]
81
+ ModelOutputStemName: TypeAlias = Annotated[_ModelOutputStemName, StringConstraints(min_length=1)]
82
+ _INPUT_STEM_NAMES = get_args(ModelInputStemName)
83
+
84
+
85
+ # NOTE: the ideal case would be to use an ADT whose variants are known at "compile" time, e.g. in Rust:
86
+ # enum ModelConfig {
87
+ # BsRoformer { param_x: ..., param_y: ... },
88
+ # Demucs { param_x: ..., params_z: ... },
89
+ # }
90
+ # but downstream users may want to register their own models with different configurations,
91
+ # so a discriminated enum wouldn't work here.
92
+ # so, we effectively let Config.model_config be dyn ModelConfigLike (i.e. dict[str, Any])
93
+ # and defer the validation of the model configuration until it is actually needed instead of doing it eagerly.
94
+ class LazyModelConfig(BaseModel):
95
+ """A lazily validated model configuration.
96
+
97
+ Note that it is not guaranteed to be fully valid until `to_concrete` is called.
98
+ """
99
+
100
+ chunk_size: ChunkSize
101
+ output_stem_names: NonEmptyUnique[Tuple[ModelOutputStemName]]
102
+
103
+ def to_concrete(
104
+ self,
105
+ model_config: type[ModelConfigLikeT],
106
+ *,
107
+ pydantic_config: ConfigDict = ConfigDict(extra="forbid"),
108
+ ) -> ModelConfigLikeT:
109
+ """Validate against a real model configuration and convert to it.
110
+
111
+ :raises pydantic.ValidationError: if extra fields are present in the model configuration
112
+ that doesn't exist in the concrete model configuration.
113
+ """
114
+ model_config_concrete: ModelConfigLikeT = TypeAdapter(
115
+ type(
116
+ f"{model_config.__name__}Validator",
117
+ (model_config,),
118
+ {"__pydantic_config__": pydantic_config},
119
+ ) # needed for https://docs.pydantic.dev/latest/errors/usage_errors/#type-adapter-config-unused
120
+ ).validate_python(self.model_dump()) # type: ignore
121
+ return model_config_concrete
122
+
123
+ @property
124
+ def stem_names(self) -> tuple[ModelInputStemName | ModelOutputStemName, ...]:
125
+ """Returns the model's input and output stem names."""
126
+ return (*_INPUT_STEM_NAMES, *self.output_stem_names)
127
+
128
+ model_config = ConfigDict(
129
+ strict=True, extra="allow"
130
+ ) # extra fields are not validated until `to_concrete`
131
+
132
+
133
+ class AudioIOConfig(BaseModel):
134
+ target_sample_rate: SampleRate = 44100
135
+ force_channels: Channels | None = 2
136
+ """Whether to force mono or stereo audio input. If None, keep original."""
137
+
138
+ model_config = _PYDANTIC_STRICT_CONFIG
139
+
140
+
141
+ class TorchCompileConfig(BaseModel):
142
+ fullgraph: bool = True
143
+ dynamic: bool = True
144
+ mode: Literal["default", "reduce-overhead", "max-autotune", "max-autotune-no-cudagraphs"] = (
145
+ "reduce-overhead"
146
+ )
147
+
148
+
149
+ class InferenceConfig(BaseModel):
150
+ normalize_input_audio: bool = False
151
+ batch_size: BatchSize = 8
152
+ force_weights_dtype: Dtype | None = None
153
+ use_autocast_dtype: Dtype | None = None
154
+ compile_model: TorchCompileConfig | None = None
155
+ apply_tta: bool = False
156
+
157
+ model_config = _PYDANTIC_STRICT_CONFIG
158
+
159
+
160
+ class ChunkingConfig(BaseModel):
161
+ method: Literal["overlap_add_windowed"] = "overlap_add_windowed"
162
+ overlap_ratio: OverlapRatio = 0.5
163
+ window_shape: WindowShape = "hann"
164
+ padding_mode: PaddingMode = "reflect"
165
+
166
+ model_config = _PYDANTIC_STRICT_CONFIG
167
+
168
+
169
+ DerivedStemName: TypeAlias = Annotated[str, StringConstraints(min_length=1)]
170
+ """The name of a derived stem, e.g. `vocals_minus_drums`."""
171
+ StemName: TypeAlias = Union[ModelOutputStemName, DerivedStemName]
172
+ """A name of a stem, either a model output stem or a derived stem."""
173
+
174
+
175
+ class SubtractConfig(BaseModel):
176
+ operation: Literal["subtract"]
177
+ stem_name: StemName
178
+ by_stem_name: StemName
179
+
180
+ model_config = _PYDANTIC_STRICT_CONFIG
181
+
182
+
183
+ class SumConfig(BaseModel):
184
+ operation: Literal["sum"]
185
+ stem_names: NonEmptyUnique[Tuple[StemName]]
186
+
187
+ model_config = _PYDANTIC_STRICT_CONFIG
188
+
189
+
190
+ DerivedStemRule: TypeAlias = Annotated[Union[SubtractConfig, SumConfig], Discriminator("operation")]
191
+ DerivedStemsConfig: TypeAlias = dict[DerivedStemName, DerivedStemRule]
192
+
193
+
194
+ class OutputConfig(BaseModel):
195
+ stem_names: Literal["all"] | NonEmptyUnique[Tuple[StemName]] = "all"
196
+ file_format: FileFormat = "wav"
197
+ audio_encoding: AudioEncoding = "PCM_F"
198
+ bit_depth: BitDepth = 32
199
+
200
+ model_config = _PYDANTIC_STRICT_CONFIG
201
+
202
+
203
+ # if we were to implement a model registry (which we shouldn't need)
204
+ # heavily consider https://peps.python.org/pep-0487/#subclass-registration
205
+
206
+
207
+ class Config(BaseModel):
208
+ identifier: str
209
+ """Unique identifier for this configuration"""
210
+ model_type: ModelType
211
+ model: LazyModelConfig
212
+ audio_io: AudioIOConfig = Field(default_factory=AudioIOConfig)
213
+ inference: InferenceConfig = Field(default_factory=InferenceConfig)
214
+ chunking: ChunkingConfig = Field(default_factory=ChunkingConfig)
215
+ derived_stems: DerivedStemsConfig | None = None
216
+ output: OutputConfig = Field(default_factory=OutputConfig)
217
+ experimental: dict[str, Any] | None = None
218
+ """Any extra experimental configurations outside of the `splifft` core."""
219
+
220
+ @model_validator(mode="after")
221
+ def check_derived_stems(self) -> Self:
222
+ if self.derived_stems is None:
223
+ return self
224
+ # accumulate valid stem names
225
+ existing_stem_names: list[StemName] = list(self.model.stem_names)
226
+ for derived_stem_name, definition in self.derived_stems.items():
227
+ if derived_stem_name in existing_stem_names:
228
+ raise PydanticCustomError(
229
+ "derived_stem_name_conflict",
230
+ "Derived stem `{derived_stem_name}` must not conflict with existing stem names: `{existing_stem_names}`",
231
+ {
232
+ "derived_stem_name": derived_stem_name,
233
+ "existing_stem_names": existing_stem_names,
234
+ },
235
+ )
236
+ required_stems: tuple[StemName, ...] = tuple()
237
+ if isinstance(definition, SubtractConfig):
238
+ required_stems = (definition.stem_name, definition.by_stem_name)
239
+ elif isinstance(definition, SumConfig):
240
+ required_stems = definition.stem_names
241
+ for stem_name in required_stems:
242
+ if stem_name not in existing_stem_names:
243
+ raise PydanticCustomError(
244
+ "invalid_derived_stem",
245
+ "Derived stem `{derived_stem_name}` requires stem `{stem_name}` but is not found in `{existing_stem_names}`",
246
+ {
247
+ "derived_stem_name": derived_stem_name,
248
+ "stem_name": stem_name,
249
+ "existing_stem_names": existing_stem_names,
250
+ },
251
+ )
252
+ existing_stem_names.append(derived_stem_name)
253
+ return self
254
+
255
+ @classmethod
256
+ def from_file(cls, path: Path) -> Config:
257
+ with open(path, "r") as f:
258
+ config_data = json.load(f)
259
+ return Config.model_validate(config_data)
260
+
261
+ model_config = ConfigDict(
262
+ arbitrary_types_allowed=True, # for .model
263
+ strict=True,
264
+ extra="forbid",
265
+ )