diff --git a/Makefile b/Makefile index b31b81d..25fb741 100644 --- a/Makefile +++ b/Makefile @@ -3,16 +3,14 @@ PYTHON_VERSION:=python-2.7 CFLAGS += $(shell pkg-config --cflags $(PYTHON_VERSION)) CFLAGS += -Wall -pedantic -std=c99 -g -LFLAGS += $(shell pkg-config --libs $(PYTHON_VERSION)) -LFLAGS += -lSDL -lSDL_mixer - +LFLAGS += $(shell pkg-config --libs $(PYTHON_VERSION) sdl SDL_mixer libmpg123) .PHONY: default clean default: flanger clean: - rm -f flanger *.o + rm -f flanger *.o pymodmodule.so flanger: hoi.c gcc -o flanger hoi.c $(LFLAGS) $(CFLAGS) diff --git a/hoi2.py b/hoi2.py index e45e5d2..1539638 100644 --- a/hoi2.py +++ b/hoi2.py @@ -1,5 +1,6 @@ from ysynth import * +import sys synth = YSynth() #synth.set_graph(Sin(440 + 110 * Sin(1))) @@ -13,8 +14,8 @@ synth = YSynth() #synth.set_graph(WhiteNoise() * 1.0) # Should sound vagely similar to a flanged whitenoise signal -noise = WhiteNoise() -synth.set_graph((noise + noise[50 + 100 * (Sin(0.1) + 1)]) * 0.5 * (RevSaw(2) + 1) * 0.5) +#noise = WhiteNoise() +#synth.set_graph((noise + noise[50 + 100 * (Sin(0.1) + 1)]) * 0.5 * (RevSaw(2) + 1) * 0.5) #synth.set_graph(Sin(440) * RevSaw(5) * 0.2) #synth.set_graph((Sin(440) * 0.5, Sin(220) * 0.5)) #synth.set_graph(Sin((440, 220)) * 0.5) @@ -36,3 +37,17 @@ def harmonics(osc, freq, count): (osc(freq * i) for i in xrange(1, count + 2))) \ / (count + 1) +# MP3 playback +#synth.set_graph(MP3Stream('hoi.mp3')) +# Crossfade between 2 songs +#fade = (Sin(0.5) + 1) * 0.5 +#synth.set_graph(MP3Stream(sys.argv[1]) * fade + MP3Stream(sys.argv[2]) * (1 - fade)) + +# The following code causes more buggy behaviour. +#x = Sin(440) +#for i in xrange(1, 15): +# x += Sin(440 + x) +#synth.set_graph(x / 41 * 0.5) +user = UserSignal(440) +synth.set_graph(Sin(user) * 0.2) + diff --git a/hoi3.py b/hoi3.py new file mode 100644 index 0000000..3fba23e --- /dev/null +++ b/hoi3.py @@ -0,0 +1,34 @@ + +import pymod +import sys +import numpy as np + +mp3 = pymod._ysynth_mp3_open(sys.argv[1]) +samplerate, channels = pymod._ysynth_mp3_get_format(mp3) +print "Samplerate:", samplerate, "Channels:", channels + +x = pymod._ysynth_init(samplerate, channels) +first_time = True + +def play_mp3(channels): + """ + Play contents of MP3 file. + """ + + global first_time + + if first_time: + print "First time callback occurred" + print "Array shape is: %s" % str(channels.shape) + first_time = False + + next_chunk = pymod._ysynth_mp3_read(mp3, channels.shape[0]) + next_chunk = np.minimum(np.maximum(next_chunk, -1.0), 1.0) + # Using 32767.5 as a factor causes overflow, we need to fix this + # in the most uniformly distributed way possible + np.round(next_chunk * 32767, 0, out=channels) + + return + +pymod._ysynth_set_callback(x, play_mp3) + diff --git a/pymod.c b/pymod.c index c5efb1b..d6db428 100644 --- a/pymod.c +++ b/pymod.c @@ -1,10 +1,17 @@ #include #include #include +#include /* SDL error handling */ PyObject *SDLError; +/* MPG123 error handling */ +PyObject *MP3Error; + +/* The synthesis context, used mainly + * for storing audio settings and state. + */ typedef struct { PyObject_HEAD int @@ -234,6 +241,283 @@ static PyObject *_ysynth_get_callback(PyObject *self, PyObject *args) return r; } +/* MP3 Support */ +typedef struct { + PyObject_HEAD + mpg123_handle *mh; + int open, eof; + long rate; + int + channels, + encoding; +} PyYMP3Context; + +void PyYMP3Context_Dealloc(PyYMP3Context *ctx) +{ + puts("_ysynth: Dealloc mp3 context"); + + /* Close if necessary */ + if (ctx->open) + mpg123_close(ctx->mh); + + /* Finally free the MP3 decoder handle */ + if (ctx->mh) { + mpg123_delete(ctx->mh); + ctx->mh = NULL; + } + + return; +} + +PyTypeObject PyYMP3Context_Type; + +PyTypeObject PyYMP3Context_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + "_ysynth.ymp3 context", /*tp_name*/ + sizeof(PyYMP3Context), /*tp_basicsize*/ + 0, /*tp_itemsize*/ + /* methods */ + (destructor)PyYMP3Context_Dealloc, /*tp_dealloc*/ + 0, /*tp_print*/ + 0, /*tp_getattr*/ + 0, /*tp_setattr*/ + 0, /*tp_compare*/ + 0, /*tp_repr*/ + 0, /*tp_as_number*/ + 0, /*tp_as_sequence*/ + 0, /*tp_as_mapping*/ + 0, /*tp_hash*/ +}; + +PyDoc_STRVAR(_ysynth_mp3_open_doc, "Open MP3 file and return context," + " raises exception if file does not exists\n" + "Args:\n" + " filename: Path string to file\n" + "Returns:\n" + " MP3 context"); + +/* Open MP3 file */ +static PyObject *_ysynth_mp3_open(PyObject *self, PyObject *args) +{ + PyObject *r = NULL; + char *filename; + PyYMP3Context *ctx; + mpg123_handle *mh; + int err; + + if (PyArg_ParseTuple(args, "s;filename", &filename)) { + /* Try to open MP3 */ + mh = mpg123_new(NULL, &err); + if (!mh) { + PyErr_SetString(MP3Error, mpg123_plain_strerror(err)); + return NULL; + } + + /* Setup float samples */ + mpg123_param(mh, MPG123_ADD_FLAGS, MPG123_FORCE_FLOAT, 0.); + + /* Open the actual MP3 file */ + if (mpg123_open(mh, filename) != MPG123_OK) { + PyErr_SetString(MP3Error, mpg123_strerror(mh)); + mpg123_delete(mh); + return NULL; + } + + /* Create object */ + ctx = PyObject_NEW(PyYMP3Context, &PyYMP3Context_Type); + ctx->open = 1; + ctx->eof = 0; + ctx->mh = mh; + r = (PyObject*)ctx; + + if (!ctx) { + mpg123_close(mh); + mpg123_delete(mh); + return NULL; + } + + /* Check-out file stream format */ + mpg123_getformat(mh, &ctx->rate, &ctx->channels, &ctx->encoding); + + if (ctx->encoding != MPG123_ENC_FLOAT_32) { + PyErr_SetString(MP3Error, + "Got non-float encoder while forcing float"); + Py_DECREF(ctx); + return NULL; + } + + /* Now don't allow the format to change. + * + * FIXME: Later on we probably do want to change this, as to + * allow YSynth to handle any changes in samplerate instead of + * allowing MPG123's crude resampler to do the job. + */ + mpg123_format_none(mh); + mpg123_format(mh, ctx->rate, ctx->channels, ctx->encoding); + } + + return r; +} + +PyDoc_STRVAR(_ysynth_mp3_close_doc, "Close MP3 file context\n" + "Args:\n" + " context: MP3 context\n" + "Returns:\n" + " None, but raises an MP3Error if the file has already been closed"); + +/* Close MP3 file */ +static PyObject *_ysynth_mp3_close(PyObject *self, PyObject *args) +{ + PyObject *r = NULL; + PyYMP3Context *ctx; + + if (PyArg_ParseTuple(args, "O!", &PyYMP3Context_Type, (PyObject**)&ctx)) { + if (ctx->open) { + ctx->open = 0; + mpg123_close(ctx->mh); + r = Py_None; + Py_INCREF(r); + + } else { + PyErr_SetString(MP3Error, "File already closed"); + r = NULL; + } + } + + return r; +} + +PyDoc_STRVAR(_ysynth_mp3_get_format_doc, "Get MP3 format configuration\n" + "Args: None\n" + "Returns:\n" + " Tuple containing: (samplerate, channels)"); + +/* Read MP3 data */ +static PyObject *_ysynth_mp3_get_format(PyObject *self, PyObject *args) +{ + PyObject *r = NULL; + PyYMP3Context *ctx; + + if (PyArg_ParseTuple(args, "O!", &PyYMP3Context_Type, (PyObject**)&ctx)) { + if (ctx->open) { + r = Py_BuildValue("(ii)", ctx->rate, ctx->channels); + } else { + PyErr_SetString(MP3Error, "File has been closed"); + } + } + + return r; +} + +PyDoc_STRVAR(_ysynth_mp3_read_doc, "Read MP3 data\n" + "Args:\n" + " context: Open MP3 context\n" + " samples: Number of samples to decode\n" + "Returns:\n" + " A NumPy array of shape (samples, channels)\n" + " This function raises an MP3Error on decode failure.\n" + " It shall output silence on EOF"); + +/* Read MP3 data */ +static PyObject *_ysynth_mp3_read(PyObject *self, PyObject *args) +{ + PyObject *r = NULL; + PyYMP3Context *ctx; + int samples, bytes_read; + int err; + npy_int dims[2]; + NPY_AO *stream; + + if (PyArg_ParseTuple(args, "O!i", &PyYMP3Context_Type, (PyObject**)&ctx, &samples)) { + if (ctx->open) { + /* Allocate output array */ + dims[0] = samples; + dims[1] = ctx->channels; + stream = PyArray_New(&PyArray_Type, 2, dims, NPY_FLOAT32, NULL, NULL, 4, 0, NULL); + + if (!stream) + return NULL; + r = (PyObject*)stream; + + /* EOF Generates a silence stream */ + if (ctx->eof) { + + memset(PyArray_DATA(stream), 0x0, PyArray_NBYTES(stream)); + } else { + + /* Read the next set of MP3 samples */ + err = mpg123_read(ctx->mh, PyArray_DATA(stream), + PyArray_NBYTES(stream), &bytes_read); + + /* Handle any weird errors */ + if (err != MPG123_OK) { + if (err == MPG123_DONE) { + ctx->eof = 1; + } else { + Py_DECREF(stream); + PyErr_SetString(MP3Error, mpg123_strerror(ctx->mh)); + return NULL; + } + } + + /* Fill any missing data with silence, and of course complain + * whenever necessary + */ + if (bytes_read != PyArray_NBYTES(stream)) { + if (!ctx->eof) + fprintf(stderr, "warning: MPG123 did not return" + " enough data but did not set MPG123_DONE" + " either..\n"); + memset(PyArray_BYTES(stream) + bytes_read, 0x0, + PyArray_NBYTES(stream) - bytes_read); + } + } + } else { + PyErr_SetString(MP3Error, "File has been closed"); + } + } + + return r; +} + +/* TODO: Let's use the same strategy NumPy uses for its built-ins, provide + * the function signature in the docstrings + */ + +PyDoc_STRVAR(_ysynth_mp3_seek_doc, "Seek to position in MP3\n" + "Args:\n" + " context: Open MP3 context, providing somehting else\n" + " will result in an exception\n" + " position: Integer position in samples to seek to\n" + "Returns:\n" + " None, this functions raises an MP3Error when errors are encountered"); + +/* Seek MP3 data */ +static PyObject *_ysynth_mp3_seek(PyObject *self, PyObject *args) +{ + PyObject *r = NULL; + PyYMP3Context *ctx; + int sample; + int err; + + if (PyArg_ParseTuple(args, "O!i;context, position", &PyYMP3Context_Type, + (PyObject**)&ctx, &sample)) { + if (ctx->open) { + err = mpg123_seek(ctx->mh, sample, SEEK_SET); + if (err < 0) { + PyErr_SetString(MP3Error, mpg123_strerror(ctx->mh)); + } else { + r = Py_None; + Py_INCREF(r); + } + } else { + PyErr_SetString(MP3Error, "File has been closed"); + } + } + + return r; +} + /* Exported methods */ static PyMethodDef pymod_methods[] = { {"_ysynth_init", (PyCFunction)_ysynth_init, @@ -244,17 +528,30 @@ static PyMethodDef pymod_methods[] = { METH_VARARGS, _ysynth_set_callback_doc}, {"_ysynth_get_callback", (PyCFunction)_ysynth_get_callback, METH_VARARGS, _ysynth_get_callback_doc}, + {"_ysynth_mp3_open", (PyCFunction)_ysynth_mp3_open, + METH_VARARGS, _ysynth_mp3_open_doc}, + {"_ysynth_mp3_close", (PyCFunction)_ysynth_mp3_close, + METH_VARARGS, _ysynth_mp3_close_doc}, + {"_ysynth_mp3_get_format", (PyCFunction)_ysynth_mp3_get_format, + METH_VARARGS, _ysynth_mp3_get_format_doc}, + {"_ysynth_mp3_read", (PyCFunction)_ysynth_mp3_read, + METH_VARARGS, _ysynth_mp3_read_doc}, + {"_ysynth_mp3_seek", (PyCFunction)_ysynth_mp3_seek, + METH_VARARGS, _ysynth_mp3_seek_doc}, {"hello", (PyCFunction)pymod_hello, METH_VARARGS, hello_doc}, {NULL, NULL} /* sentinel */ }; -/* Shutdown SDL */ +/* Shutdown SDL/MPG123 */ void exitpymod(void) { + mpg123_exit(); + /* XXX: Should we check for successful initialisation of SDL? */ SDL_Quit(); } + PyDoc_STRVAR(module_doc, "Simple test module"); /* Module initialisation, called by Python on import */ @@ -262,11 +559,21 @@ PyMODINIT_FUNC initpymod(void) { PyObject *m; + int err; /* Setup SDL audio without signal handlers */ if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_NOPARACHUTE) < 0) return; +#if 1 + /* Setup MPG123 */ + if ((err = mpg123_init()) != MPG123_OK) { + fprintf(stderr, "Failure while initialising mgp123: %s\n", + mpg123_plain_strerror(err)); + return; + } +#endif + /* Register exit handler * XXX: Later on try to use Python's atexit module? */ @@ -282,6 +589,17 @@ initpymod(void) Py_INCREF(SDLError); PyModule_AddObject(m, "SDLError", SDLError); + MP3Error = PyErr_NewException("_ysynth.MP3Error", NULL, NULL); + Py_INCREF(MP3Error); + PyModule_AddObject(m, "MP3Error", MP3Error); + + /* Setup types */ + if (PyType_Ready(&PyYSynthContext_Type) < 0) + return; + + if (PyType_Ready(&PyYMP3Context_Type) < 0) + return; + /* Our module requires the GIL to be available */ if (!PyEval_ThreadsInitialized()) { puts("Initialising multithreading for Python"); diff --git a/ysynth.py b/ysynth.py index 34c8184..af4bb35 100644 --- a/ysynth.py +++ b/ysynth.py @@ -2,13 +2,17 @@ Python audio synthesis framework. """ -from pymod import _ysynth_init, _ysynth_init, _ysynth_set_callback +from pymod import _ysynth_init, _ysynth_init, _ysynth_set_callback, \ + _ysynth_mp3_open, _ysynth_mp3_get_format, _ysynth_mp3_read, \ + _ysynth_mp3_close + from math import cos, sin, pi, modf from itertools import izip import numpy as np from scipy.signal import lfilter -__all__ = ['YSynth', 'Sin', 'Cos', 'Saw', 'RevSaw', 'Square', 'Pulse', 'WhiteNoise'] +__all__ = ['YSynth', 'Sin', 'Cos', 'Saw', 'RevSaw', 'Square', 'Pulse', 'WhiteNoise', + 'UserSignal', 'MP3Stream'] class YSynth(object): """ @@ -465,6 +469,64 @@ class ChannelJoiner(YAudioGraphNode): for chunks in l: yield np.hstack(np.reshape(chunk, (-1, 1)) for chunk in chunks) +# User programmable signal +class UserSignal(YAudioGraphNode): + """ + Simple programmable output signal. + """ + + def __init__(self, out=0.0): + YAudioGraphNode.__init__(self) + self.cur_out = out + self.ps_list = [] + + def __call__(self, l): + while True: + x = np.empty((self.synth.chunk_size,)) + if len(self.ps_list): + pos = 0 + cur_out = self.cur_out + + # FIXME: lrn2python queue + while len(self.ps_list): + change, delay = self.ps_list.pop(0) + if delay + pos > self.synth.chunk_size: + x[pos:] = cur_out + delay -= self.synth.chunk_size - pos + self.ps_list.insert(0, (change, delay)) + pos = self.synth.chunk_size + break + else: + x[pos:pos + delay] = cur_out + pos += delay + cur_out = change + + x[pos:] = cur_out + self.cur_out = cur_out + + else: + x.fill(self.cur_out) + + yield x + + def add_signal_change(self, *changes): + """ + Program a sequence of signal changes in the + output signal. + """ + self.ps_list.extend(changes) + + def pulse(self, delay, high=1.0, low=0.0): + """ + Add a pulse change to the output signal. + This is a convenience function that calls + add_signal_change with the appropriate arguments. + """ + self.add_signal_change((high, delay), (low, 1)) + + def set_output(self, out): + self.cur_out = out + # Noise signals # XXX: I am probably not White Noise, fix me # up at a later time. @@ -526,6 +588,59 @@ class Pulse(YOscillator): pos, zi = lfilter([-1, 1], [1], pos, zi=zi) yield np.array(pos > 0, dtype=float) +# MP3 playback +class MP3Stream(YAudioGraphNode): + def __init__(self, filename): + YAudioGraphNode.__init__(self) + + # Open MP3 file + self.filename = filename + self.mp3_handle = _ysynth_mp3_open(filename) + self.samplerate, self.channels = _ysynth_mp3_get_format(self.mp3_handle) + + def __call__(self, l): + """ + Return next chunk of mp3 audio. + """ + + while True: + yield _ysynth_mp3_read(self.mp3_handle, self.synth.chunk_size) + + def __del__(self): + """ + Close MP3 file and 'free' handle + """ + + _ysynth_mp3_close(self.mp3_handle) + self.mp3_handle = None + +# Resamplers +class ResampleNN(YAudioGraphNode): + """ + Resamplers have their own sample clock which + distinguishes them from the usual components, obviously + necessary because they change the samplerate of the input + signal. + """ + + def __init__(self, in_audio, in_rate=None, out_rate=None): + self.synth = self + + if in_rate is None: + in_rate = 44100 + + if out_rate is None: + out_rate = 44100 + + YAudioGraphNode.__init__(self, in_audio, in_rate, out_rate) + + def __call__(self, l): + for in_audio, in_rate, out_rate in l: + pass + + def set_synth(self, synth): + self.real_synth = synth + # Flanger effect # FIXME: Incomplete class class Flanger(YAudioGraphNode):