Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ jobs:
with:
python-version: '3.12'
cache: 'poetry'
- name: Install dependencies
run: poetry install
- name: Install dependencies (with [train] extra)
run: poetry install --extras train
- name: Lint with flake8
run: |
poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=examples
Expand Down Expand Up @@ -70,8 +70,8 @@ jobs:
- name: Install ALSA dev headers (Linux only, required to build python-rtmidi from source)
if: runner.os == 'Linux'
run: sudo apt-get install -y libasound2-dev
- name: Install dependencies
run: poetry install
- name: Install dependencies (with [train] extra)
run: poetry install --extras train
- name: Lint with flake8
run: |
poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=examples
Expand All @@ -80,3 +80,33 @@ jobs:
run: poetry run pytest
- name: Verify CLI
run: poetry run python ./start_impsy.py --help

# Inference-only base install: verifies that `pip install impsy` (no extras)
# works without TensorFlow and supports the lightweight Raspberry Pi /
# performance-rig use case. Ubuntu + 3.13 only — the test is for dependency
# surface, not platform compatibility.
base-install-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install ALSA dev headers (required to build python-rtmidi from source)
run: sudo apt-get install -y libasound2-dev
- name: Install base package only (no [train] extra)
run: |
python -m pip install --upgrade pip
pip install . pytest
- name: Confirm TensorFlow is NOT installed
run: |
python - <<'PY'
import importlib.util, sys
if importlib.util.find_spec("tensorflow") is not None:
sys.exit("FAIL: tensorflow should not be present in the base install")
print("OK: tensorflow absent")
PY
- name: Run inference-only smoke tests
run: pytest tests/test_base_install.py -v
- name: Verify CLI loads
run: python ./start_impsy.py --help
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@ The fastest way to try IMPSY on macOS, Linux, or Windows is to install it from P
mkdir my-impsy && cd my-impsy
impsy init

The base install runs `.tflite` models for inference and logs interaction data — no TensorFlow required. To train new models or convert `.keras`/`.h5` files to `.tflite`, install the `train` extra:

pip install "impsy[train]"

The base install is lightweight enough for Raspberry Pi and dedicated performance rigs that only load pre-trained models; the `[train]` extra is for laptops and workstations doing the training.

`impsy init` creates `logs/`, `datasets/`, and `models/` and writes a default `config.toml` in the current directory. Edit `config.toml` to match your I/O setup — MIDI, OSC, WebSocket, or serial. See [`docs/config.md`](docs/config.md) for the full configuration reference.

Then log some interactions, build a dataset, and train a model:

impsy run # log interactions (Ctrl+C to stop)
impsy dataset # collate logs into a .npz dataset
impsy train # train an MDRNN model
impsy train # train an MDRNN model — needs the [train] extra

`impsy --help` lists every available command. To use a saved model for predictions, set `model.file` in `config.toml` and run `impsy run` again.

Expand All @@ -59,7 +65,11 @@ Then you should clone this repository or download it to your computer:
git clone https://github.com/cpmpercussion/impsy.git
cd impsy

Then you can install the dependencies using Poetry:
Then you can install the dependencies using Poetry. Use `--extras train` if you want to train new models from the same checkout (otherwise the base install is inference-only):

poetry install --extras train

Or, for an inference-only setup (e.g., on a Raspberry Pi loading pre-trained `.tflite` models), omit the extra:

poetry install

Expand Down
14 changes: 8 additions & 6 deletions impsy/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@
Abstracts differences between TF 2.16 (tf-keras, tf.lite) and
TF 2.18+ (native Keras 3, tf.lite).

This module is intended to be thin and temporary — remove once older
TF versions are dropped.
TF imports are deferred so that the inference-only install (no `[train]` extra)
can import this module without TensorFlow installed.
"""

import tensorflow as tf


def get_tflite_interpreter(model_path: str):
"""Return a TFLite interpreter for inference."""
Expand All @@ -18,10 +16,14 @@ def get_tflite_interpreter(model_path: str):


def get_tflite_optimize_default():
"""Return the default optimisation flag."""
"""Return the default optimisation flag. Requires TensorFlow."""
import tensorflow as tf

return tf.lite.Optimize.DEFAULT


def analyze_tflite_model(model_content):
"""Run TFLite model analyser if available."""
"""Run TFLite model analyser if available. Requires TensorFlow."""
import tensorflow as tf

tf.lite.experimental.Analyzer.analyze(model_content=model_content)
11 changes: 10 additions & 1 deletion impsy/interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,16 @@ def build_network(config: dict):
click.secho(
f"MDRNN Loading from .keras or .h5 file: {model_file}", fg="green"
)
model = mdrnn.KerasMDRNN(model_file, dimension, units, mixtures, layers)
try:
model = mdrnn.KerasMDRNN(
model_file, dimension, units, mixtures, layers
)
except ImportError as exc:
raise click.ClickException(
f"Cannot load {model_file.suffix} model without the training "
f"extra. Install `pip install impsy[train]`, or convert the "
f"model to .tflite first.\nUnderlying error: {exc}"
)
else:
click.secho(f"MDRNN Loading dummy model: {model_file}", fg="yellow")
model = mdrnn.DummyMDRNN(model_file, dimension, units, mixtures, layers)
Expand Down
126 changes: 100 additions & 26 deletions impsy/mdrnn.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
"""

import os
import tensorflow as tf
import keras_mdn_layer as mdn

os.environ.pop("TF_USE_LEGACY_KERAS", None)
import numpy as np
Expand All @@ -20,6 +18,71 @@
LOG_PATH = "./logs/"
SCALE_FACTOR = 10 # scales input and output from the model. Should be the same between training and inference.

_TRAIN_EXTRA_HINT = (
"TensorFlow is required for this feature. "
"Install the training extra: `pip install impsy[train]`."
)


def _require_tensorflow():
"""Import tensorflow lazily with a friendly error if the [train] extra is missing."""
try:
import tensorflow as tf # noqa: F401
except ImportError as exc:
raise ImportError(_TRAIN_EXTRA_HINT) from exc
return tf


def _require_keras_mdn():
"""Import keras_mdn_layer lazily with a friendly error if the [train] extra is missing."""
try:
import keras_mdn_layer as mdn # noqa: F401
except ImportError as exc:
raise ImportError(_TRAIN_EXTRA_HINT) from exc
return mdn


def _split_mixture_params(params, output_dim, num_mixes):
"""Split a flat MDN parameter vector into mus, sigmas, and pi-logits."""
assert len(params) == num_mixes + (output_dim * 2 * num_mixes), (
"MDN params length does not match the configured mixture/dimension"
)
mus = params[: num_mixes * output_dim]
sigs = params[num_mixes * output_dim : 2 * num_mixes * output_dim]
pi_logits = params[-num_mixes:]
return mus, sigs, pi_logits


def _softmax_with_temp(w, t=1.0):
"""Temperature-scaled, numerically-stable softmax over a 1D vector."""
e = np.asarray(w, dtype=np.float64) / t
e -= e.max()
e = np.exp(e)
return e / np.sum(e)


def _sample_from_categorical(dist):
"""Sample one index from a categorical distribution given as a probability vector."""
return int(np.random.choice(len(dist), p=dist))


def sample_mdn_output(params, output_dim, num_mixes, temp=1.0, sigma_temp=1.0):
"""Sample one point from an MDN output vector using ancestral sampling.

Functionally identical to ``keras_mdn_layer.sample_from_output`` but inlined
here in pure NumPy so that inference does not depend on Keras / TensorFlow.
"""
mus, sigs, pi_logits = _split_mixture_params(params, output_dim, num_mixes)
pis = _softmax_with_temp(pi_logits, t=temp)
m = _sample_from_categorical(pis)
mus_vector = mus[m * output_dim : (m + 1) * output_dim]
sig_vector = sigs[m * output_dim : (m + 1) * output_dim]
scale_matrix = np.identity(output_dim) * sig_vector
cov_matrix = scale_matrix @ scale_matrix.T
cov_matrix = cov_matrix * sigma_temp
sample = np.random.multivariate_normal(mus_vector, cov_matrix, 1)
return sample[0]


def random_sample(out_dim=2):
"""Generate a random sample in format (dt, x_1, ..., x_n), where dt is positive
Expand All @@ -42,12 +105,16 @@ def proc_generated_touch(x_input, out_dim=2):


def lstm_blank_states(layers: int, units: int):
"""Create blank LSTM states for a networks with a number of layers and the same number of LSTM units in each layer"""
"""Create blank LSTM states for a network with `layers` layers and `units` LSTM units in each.

Returns NumPy arrays — Keras and TFLite both accept these as inputs, and using
NumPy avoids dragging TensorFlow into the inference-only install path.
"""
states = []
for i in range(layers):
for _ in range(layers):
states += [
tf.convert_to_tensor(np.zeros((1, units), dtype=np.float32)),
tf.convert_to_tensor(np.zeros((1, units), dtype=np.float32)),
np.zeros((1, units), dtype=np.float32),
np.zeros((1, units), dtype=np.float32),
]
assert (
len(states) == layers * 2
Expand Down Expand Up @@ -79,7 +146,11 @@ def build_mdrnn_model(
"""Builds a Keras MDRNN model with specified parameters.
Can either be a training model or inference model which affects the configured
sequence length and whether a loss function is added.

Requires the `[train]` extra (TensorFlow + keras-mdn-layer).
"""
tf = _require_tensorflow()
mdn = _require_keras_mdn()
# Set parameters for inference/training versions.
if inference:
state_input_output = True
Expand Down Expand Up @@ -171,7 +242,11 @@ def __init__(
n_mixtures : number of mixture components (5-10 is good)
layers : number of layers (2 is good)
seq_len : sequence length to unroll

Requires the `[train]` extra (TensorFlow + keras-mdn-layer).
"""
_require_tensorflow()
_require_keras_mdn()
# network parameters
self.dimension = dimension
self.mode = mode
Expand Down Expand Up @@ -229,6 +304,7 @@ def train(
patience=10,
):
"""Train the network for a number of epochs with a specific dataset."""
tf = _require_tensorflow()
save_location = Path(save_location)
checkpoint_path = save_location / f"{self.model_name}-ckpt.keras"
checkpoint_callback = tf.keras.callbacks.ModelCheckpoint(
Expand Down Expand Up @@ -271,24 +347,23 @@ def train(
def generate(self, prev_sample):
"""Generate one forward prediction from a previous sample in format
(dt, x_1,...,x_n). Pi and Sigma temperature are adjustable."""
_require_tensorflow()
assert (
len(prev_sample) == self.dimension
), "Only works with samples of the same dimension as the network"
# print("Input sample", prev_sample)
prev_sample_tensor = tf.convert_to_tensor(
prev_sample_input = (
prev_sample.reshape(1, 1, self.dimension) * SCALE_FACTOR
)
input_list = [prev_sample_tensor] + self.lstm_states
).astype(np.float32, copy=False)
input_list = [prev_sample_input] + [
np.asarray(s, dtype=np.float32) for s in self.lstm_states
]
model_output = self.model(input_list)
# Note that we have confirmed that model.__call__() is way faster than model.predict().
# model_output = self.model.predict(input_list)
mdn_params = model_output[0][0].numpy()
# mdn_params = model_output[0][0]
self.lstm_states = model_output[1:] # update storage of LSTM state

# sample from the MDN:
new_sample = (
mdn.sample_from_output(
sample_mdn_output(
mdn_params,
self.dimension,
self.n_mixtures,
Expand Down Expand Up @@ -576,9 +651,8 @@ def generate(self, prev_value: np.ndarray) -> np.ndarray:
self.lstm_states[2 * i + 1] = self.interpreter.get_tensor(
self._state_output_indices[2 * i + 1]
)
# sample from the MDN:
new_sample = (
mdn.sample_from_output(
sample_mdn_output(
mdn_params,
self.dimension,
self.n_mixtures,
Expand Down Expand Up @@ -610,6 +684,8 @@ def prepare(self) -> None:
assert (
self.model_file.suffix == ".keras" or self.model_file.suffix == ".h5"
), "KerasMDRNN only works on .keras or .h5 files."
tf = _require_tensorflow()
mdn = _require_keras_mdn()
if self.model_file.suffix == ".keras":
# Loading model for .keras files
self.model = tf.keras.models.load_model(
Expand Down Expand Up @@ -639,25 +715,23 @@ def prepare(self) -> None:
def generate(self, prev_value: np.ndarray) -> np.ndarray:
"""Generate one forward prediction from a previous sample in format
(dt, x_1,...,x_n). Pi and Sigma temperature are adjustable."""
_require_tensorflow()
assert (
len(prev_value) == self.dimension
), "Only works with samples of the same dimension as the network"
# print("Input sample", prev_value)
input_list = [
tf.convert_to_tensor(
prev_value.reshape(1, 1, self.dimension) * SCALE_FACTOR
)
] + self.lstm_states
prev_value_input = (
prev_value.reshape(1, 1, self.dimension) * SCALE_FACTOR
).astype(np.float32, copy=False)
input_list = [prev_value_input] + [
np.asarray(s, dtype=np.float32) for s in self.lstm_states
]
model_output = self.model(input_list)
# Note that we have confirmed that model.__call__() is way faster than model.predict().
# model_output = self.model.predict(input_list)
mdn_params = model_output[0][0].numpy()
# mdn_params = model_output[0][0]
self.lstm_states = model_output[1:] # update storage of LSTM state

# sample from the MDN:
new_sample = (
mdn.sample_from_output(
sample_mdn_output(
mdn_params,
self.dimension,
self.n_mixtures,
Expand Down
5 changes: 4 additions & 1 deletion impsy/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ def time_network_build(dimension, size):
@click.command(name="test-mdrnn")
def test_mdrnn():
"""This command simply loads the MDRNN to test that it works and how long it takes."""
time_network_build(4, "s")
try:
time_network_build(4, "s")
except ImportError as exc:
raise click.ClickException(str(exc))
Loading
Loading