Exercise: Plotting Csound Data in Python

September 21, 2022
Audio waveform snapshot

Recently in my quest to find a good GUI framework for building Csound instruments I've been exploring Python and PyQt5. Before this I was exploring Node.js and Electron, but I ran into frustrating problems displaying Csound data in that framework. So when I switched to PyQt5 the first thing I focused on was displaying data. I'm happy to report that the results thus far have been great. Here's the way I've found to display amplitude and frequency data quickly and smoothly.

Updating a PyQt5 Widget

The easiest way to display Csound amplitude in PyQt5 is to use a ready-made widget. This is just a matter of pulling amplitude data from Csound at regular intervals and updating the widget with the data. In order to get the look of a VU meter I used PyQt5's QProgressBar widget. To get the amplitude data out of Csound I experimented with using channels and Open Sound Control (OSC). I'll only show the channel approach here, but the OSC approach seems better when you need to send lots of data at a fast rate between Csound and the GUI.

Animated image of progress bar updating with amplitude data
Updating a QProgressBar widget with live amplitude data.

For this first example, I'll include the complete Csound code and Python code so you can see how everything works together. For later examples I'll only include the snippets of code relevant to the examples.

Csound Code

The Csound code below reads the maximum amplitude of the audio signal every k-cycle and sends it out through a channel named "meter".

<CsoundSynthesizer>
<CsOptions>
-d -odac -iadc -+rtaudio=CoreAudio
</CsOptions>
<CsInstruments>
sr = 44100
nchnls = 1
ksmps = 32
0dbfs = 1

instr 1 ; Microphone input
    asig inch 1
    out asig
    
    ktrig metro 60
    kmeter max_k asig, ktrig, 1
    chnset kmeter, "meter"
endin

</CsInstruments>
<CsScore>
f 0 z
i 1 0 -1
</CsScore>
</CsoundSynthesizer>

Python Code

In Python I explored two options for receiving the channel data from Csound. The simpler approach is to run a QTimer in the main PyQt5 window that reads the data and updates the QProgressBar widget at regular intervals. The more complicated approach is running a loop in a separate QThread that reads the data and updates the widget using PyQt5's signals and slots to communicate between the threads.

Here's the Python code for the QTimer approach which is run in the QMainWindow.

import sys
import ctcsound
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QPushButton,
    QProgressBar
)

class MainWindow(QMainWindow):
    '''Main window of program.'''
    def __init__(self):
        super().__init__()
        self.setStyleSheet("background-color: black;")
        
        # Initiate Csound
        self.csound = ctcsound.Csound()
        self.csound.compileCsd("example.csd")
        self.csound.start()
        self.pt = ctcsound.CsoundPerformanceThread(self.csound.csound())

        # Create layout and widgets
        mainLayout = QVBoxLayout()

        self.button = QPushButton("START")
        self.button.setCheckable(True)
        self.button.clicked.connect(self.the_button_was_clicked)

        self.meter = QProgressBar()
        self.meter.setMaximum(100)
        self.meter.setStyleSheet("QProgressBar::chunk "
                          "{"
                          "background-color: white;"
                          "width: 1px;"
                          "}")
        self.meter.setTextVisible(False)
        
        mainLayout.addWidget(self.meter)
        mainLayout.addWidget(self.button)
        centralWidget = QWidget()
        centralWidget.setLayout(mainLayout)
        self.setCentralWidget(centralWidget)

    def the_button_was_clicked(self, status):
        if status:
            self.button.setText("STOP")
            self.pt.play()
            self.timer = QTimer()
            self.timer.setInterval(10)
            self.timer.start()
            self.timer.timeout.connect(self.update_data)
        else:
            self.button.setText("START")
            self.pt.pause()
            self.timer.stop()

    def update_data(self):
        volume = self.csound.controlChannel("meter")[0]
        self.meter.setValue(int(volume * 100))

app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())

I was able to get an average of 40 values per second from the Csound channel with this code. To be honest, I'm not sure why I didn't get more values per second. This rate is adequate for animation purposes, but I'd like to have higher resolution. The later examples I'll show also use Csound channels but the data flows faster.

Plotting a Scrolling Waveform

Next, I wanted to display the amplitude as a scrolling waveform rather than a VU meter. I did a quick browse through online tutorials on displaying waveforms in PyQt5, and I saw that the most common way to do this was by plotting the amplitude data on a graph.

Animated image of scrolling waveform
Plotting a scrolling waveform.

Brilliant! I hadn't thought of that before. When I was using p5.js for my Looper GUI I drew the waveform as a canvas element. That was a lot of extra code. I should have been using a plotting library instead.

Matplotlib is a popular way to plot graphs in Python, but pyqtgraph is better optimized for plotting in PyQt5. The rule of thumb I read is that matplotlib is better for creating print-quality graphs while pyqtgraph is better for animating graphs in real time. I used this tutorial from the very helpful Python GUIs website.

Csound Code

For this plot, I'll use the same Csound channel data from the first example above. Here's the new Csound code for sending values through the channel.

ktrig metro 60
kmeter_minimum max_k asig, ktrig, 3
kmeter_maximum max_k asig, ktrig, 2
chnset kmeter_minimum, "meter_minimum"
chnset kmeter_maximum, "meter_maximum"

Python Code

And here's the new Python code snippets for plotting the waveform.

[Inside MainWindow class]
def __init__(self, *args, **kwargs):
    # Create PlotWidget and add it to window
    self.plotWidget = pyqtgraph.PlotWidget()
    mainLayout.addWidget(self.plotWidget)

    # Initialize the values of x-axis and y-axis
    axis_length = 128
    self.x = []
    self.y_minimum = []
    self.y_maximum = []
    for i in range(axis_length):
        self.x.append(i)
        self.y_minimum.append(0)
        self.y_maximum.append(0)

    # Plot widget styling
    self.plotWidget.setBackground("black")
    self.plotWidget.showGrid(x=False, y=True)
    self.plotWidget.setYRange(-1.5, 1.5, padding=0)
    self.pen = pg.mkPen(color="white", width=4, capstyle="flatcap")
    self.output_plot = self.plotWidget.plot(self.x, self.y_minimum, pen=self.pen, fillLevel=0, brush="white")
    self.output_plot2 = self.plotWidget.plot(self.x, self.y_maximum, pen=self.pen, fillLevel=0, brush="white")
    xaxis = self.plotWidget.getAxis('bottom')
    xaxis.setStyle(showValues=False)

def update_data(self):
    self.x = self.x[1:]
    self.x.append(self.x[-1] + 1)

    self.y_minimum = self.y_minimum[1:]
    self.y_maximum = self.y_maximum[1:]
    meter_maximum = self.csound.controlChannel("meter_maximum")[0]
    meter_minimum = self.csound.controlChannel("meter_minimum")[0]
    self.y_minimum.append(meter_minimum)
    self.y_maximum.append(meter_maximum)
    
    self.output_plot.setData(self.x, self.y_minimum)
    self.output_plot2.setData(self.x, self.y_maximum)

How the Code Works

Plotting a Momentary Waveform

Instead of plotting a scrolling waveform that lets you see the waveform over time, what if I wanted to plot a snapshot of the momentary waveform? This would require a list of continuous samples of the signal over a brief duration. Using Csound channels won't work in this scenario.

Animated image of momentary waveform
Plotting a momentary waveform.

First I tried to use the .inputBuffer() method of ctcsound to get this list of continuous samples from Csound, but the input buffer was only 64 samples long. I intentionally set the buffer to 64 samples in my Csound code to reduce latency for live performance. Unfortunately, 64 samples was too short to get a good visualization of the waveform.

I needed to figure out a way to provide a larger buffer of continuous samples from Csound. I decided to maintain my own "virtual buffer" within Csound in the form of a table, and I could then use ctcsound's .table() method to read the table in Python.

Csound Code

Here's the code in Csound for maintaining a buffer table that is 2,048 samples long.

gibuffer_size = 2048
gibuffer_offet = gibuffer_size - ksmps
ginput_buffer ftgen 1, 0, -gibuffer_size, 2, 0

instr 1 ; Microphone input
    asig inch 1
    out asig

    ftslice 1, 1, ksmps
    kindex = 0
    while kindex < ksmps do
        tablew asig[kindex], kindex, 1, 0, gibuffer_offet
        kindex += 1
    od
endin

Python Code

Here's the Python code that reads data from the table.

[Inside MainWindow class]
def __init__(self, *args, **kwargs):
    # Create PlotWidget and add it to window
    self.plotWidget = pyqtgraph.PlotWidget()
    mainLayout.addWidget(self.plotWidget)

    # Initialize the x-axis
    self.momentary_buffer_length = 512
    self.x_momentary_buffer = []
    for i in range(self.momentary_buffer_length):
        self.x_momentary_buffer.append(i)

    # Plot widget styling
    self.plotWidget.setBackground("black")
    self.plotWidget.showGrid(x=False, y=True)
    self.plotWidget.setYRange(-1.5, 1.5, padding=0)
    self.output_plot = self.plotWidget.plot([], [], pen=self.pen, brush="white")
    xaxis = self.plotWidget.getAxis('bottom')
    xaxis.setStyle(showValues=False)

def update_data(self):
    self.output_plot3.setData(self.x_momentary_buffer, self.csound.table(1)[:self.momentary_buffer_length])

How the Code Works

Plotting a Frequency Spectrum

Finally, I wanted to plot the frequency spectrum of the audio signal. To do this I needed to calculate the fast Fourier transform (FFT) on a list of continuous audio samples. SciPy is considered the best Python library for calculating FFTs.

Animated image of frequency spectrum
Plotting a frequency spectrum.

Csound Code

The Csound code is exactly the same as the previous example, so there's no need to repeat it here. The key is that the ginput_buffer table contains the most recent 2,048 samples of audio data which gets updated every k-cycle.

Python Code

import numpy as np
from scipy.fft import rfft, rfftfreq

[Inside MainWindow class]
def __init__(self, *args, **kwargs):
    # Create PlotWidget and add it to window
    self.plotWidget = pyqtgraph.PlotWidget()
    mainLayout.addWidget(self.plotWidget)

    # Plot widget styling
    self.plotWidget.setBackground("black")
    self.plotWidget.showGrid(x=False, y=True)
    self.plotWidget.setYRange(-0.1, 3, padding=0)

    # Set up x-axis with logarithmic units
    self.plotWidget.setLogMode(x=True, y=False)
    xaxis_ticks = [(np.log10(20),"20"),(np.log10(100),"100"),(np.log10(200),"200"),
    (np.log10(400),"400"),(np.log10(600),"600"),(np.log10(1000),"1k"),(np.log10(2000),
    "2k"),(np.log10(3000),"3k"),(np.log10(10000),"10k")]
    xaxis = self.plotWidget.getAxis('bottom')
    xaxis.setTicks([xaxis_ticks])

    self.output_plot = self.plotWidget.plot([], [], pen=self.pen)

def update_data(self):
    fft_spectrum = rfft(self.csound.table(1), norm="ortho")
    freq = rfftfreq(2048, 1./44100)
    self.output_plot.setData(freq, np.abs(fft_spectrum))

How the Code Works

Next Steps

So far my exploration of PyQt5 has been successful. I've been able to create quality data animations that easily render at 60 frames per second, and achieving this was much more straightforward in PyQt5 than in Node.js and Electron.

Here are some areas I'll focus on next.

Now the fun can really begin as I dream up new instruments and generative systems with Python and Csound.

Leave a Comment

Your email address will not be published. Required fields are marked *