0

I have troubles of unknown kind with the sounddevice module for Python. The code below outputs a GUI where you can output a sine wave of a user-defined frequency. Additionaly, one can modulate its amplitude with a sine wave of a user-defined frequency and amplitude/amount. Upon pressing the "Play"-button, I receive an output overflow and the audio starts lagging immediately. The problem does not occur if I remove the option of amplitude modulation in the code and I hear a smooth sine signal.

I understand that this code might use too much CPU time for the audio signal to be smooth.

Has anybody encountered a similar problem in a similar project? Or is anybody familiar enough with the sounddevice module to help me out?

A good part of the code is copy-paste from this example on Github.

I am by no means an experienced programmer.

Any help is dearly appreciated!

import argparse
import sys

import tkinter as tk
import sounddevice as sd
import numpy as np

# the following function and parsing part is copy-paste from the link above 
# and with a few modifications of the remaining code wouldn't be crucial I guess.
# I left it there because I wanted to spare myself the modifications.

def int_or_str(text):
    """Helper function for argument parsing."""
    try:
        return int(text)
    except ValueError:
        return text

parser = argparse.ArgumentParser(add_help=False)
parser.add_argument(
    '-l', '--list-devices', action='store_true',
    help='show list of audio devices and exit')
args, remaining = parser.parse_known_args()
if args.list_devices:
    print(sd.query_devices())
    parser.exit(0)
parser = argparse.ArgumentParser(
    description=__doc__,
    formatter_class=argparse.RawDescriptionHelpFormatter,
    parents=[parser])
parser.add_argument(
    'frequency', nargs='?', metavar='FREQUENCY', type=float, default=500,
    help='frequency in Hz (default: %(default)s)')
parser.add_argument(
    '-d', '--device', type=int_or_str,
    help='output device (numeric ID or substring)')
parser.add_argument(
    '-a', '--amplitude', type=float, default=0.2,
    help='amplitude (default: %(default)s)')
args = parser.parse_args(remaining)

start_idx = 0

def play():
    samplerate = sd.query_devices(args.device, 'output')['default_samplerate']

    # the callback function is basically copy-paste from the link abouve 
    # except I added the modulating sine wave
    def callback(outdata, frames, time, status): 
        if status:
            print(status, file=sys.stderr)
        global start_idx
        t = (start_idx + np.arange(frames)) / samplerate
        t = t.reshape(-1, 1)
        am_amount = float(am_amt_1.get())
        a_modulator = np.sin(2*np.pi*float(am_freq_1.get())*t)*am_amount+1-am_amount
        carrier = np.sin(2 * np.pi * float(freq_1.get()) * t)
        outdata[:] = carrier*a_modulator
        start_idx += frames

    with sd.OutputStream(device=args.device, channels=1, callback=callback,
                         samplerate=samplerate):
        input() #don't really know what this is doing but there's no audio at all without it

# setting up the GUI
  
main = tk.Tk()
main.title('Test')
main.geometry('10000x10000')

# entries for frequency in hz and am amount

freq_1 = tk.Entry(main)
freq_1.grid(row = 0, column = 1)

am_freq_1 = tk.Entry(main)
am_freq_1.grid(row=1,column=1)

am_amt_1 = tk.Entry(main)
am_amt_1.grid(row=2,column=1)

# labels

f1 = tk.Label(main,text='Frequency')
f1.grid(row=0,column=0)

amf_1 = tk.Label(main,text='AM Frequency')
amf_1.grid(row=1,column=0)

amamt_1 = tk.Label(main,text='AM Amount')
amamt_1.grid(row=2,column=0)

# play button executing the above play function

pl = tk.Button(main, text='Play',command = play)
pl.grid(row=3,column=1)

main.mainloop()

I tried the program with both the built-in output device from my laptop and the M-Audio Fast Track Pro.

2
  • FWIW, it's not working great on my machine even without the modulation... Commented Dec 10, 2023 at 12:56
  • It does on my machine if I remove the modulation part from the callback function. Commented Dec 10, 2023 at 13:15

1 Answer 1

1

As I kind of suspected, the issue is resolved as soon as you don't call tkinter functions within the callback; if you pass the values in to the play function, things seem to work fine.

I also refactored the rest a bit for brevity, too. The reason you need an input() in the with is that the device is otherwise immediately closed; I replaced that with a sleep here, so the sound plays for 2 seconds and then exits.

If your sound device does not contain Speakers in the name, you'll need to change that in the device=... line.

import sys
import time
import tkinter as tk

import numpy as np
import sounddevice as sd


def play(*, device, freq: float, am_freq: float, am_amount: float):
    samplerate = sd.query_devices(device, "output")["default_samplerate"]
    start_idx = 0

    def callback(outdata, frames, time, status):
        nonlocal start_idx
        if status:
            print(status, file=sys.stderr)
        t = (start_idx + np.arange(frames)) / samplerate
        t = t.reshape(-1, 1)
        a_modulator = (
            np.sin(2 * np.pi * am_freq * t) * am_amount + 1 - am_amount
        )
        carrier = np.sin(2 * np.pi * freq * t)
        outdata[:] = carrier * a_modulator
        start_idx += frames

    with sd.OutputStream(
        device=device,
        channels=1,
        callback=callback,
        samplerate=samplerate,
    ):
        time.sleep(2)  # Play for a while


def make_box(parent, label, value, y):
    box = tk.Entry(parent)
    box.insert(0, value)
    label = tk.Label(parent, text=label)
    label.grid(row=y, column=0)
    box.grid(row=y, column=1)
    return (label, box)


def main():
    root = tk.Tk()
    root.title("Test")
    freq_label, freq_box = make_box(root, "Frequency", "500", 0)
    am_freq_label, am_freq_box = make_box(root, "AM Frequency", "25", 1)
    am_amt_label, am_amt_box = make_box(root, "AM Amount", "0.5", 2)

    pl = tk.Button(
        root,
        text="Play",
        command=(
            lambda: play(
                device="Speakers",
                freq=float(freq_box.get()),
                am_freq=float(am_freq_box.get() or 0),
                am_amount=float(am_amt_box.get() or 0),
            )
        ),
    )
    pl.grid(row=3, column=1)

    root.mainloop()


if __name__ == "__main__":
    main()

EDIT: Here's an additional refactoring that adds event bindings from Tkinter to update the synthesizer state when you hit Return in the entry boxes:

import sys
import tkinter as tk

import numpy as np
import sounddevice as sd


class Synth:
    def __init__(self):
        self.freq = 500
        self.am_freq = 25
        self.am_amount = 0.5
        self.start_idx = 0
        self.stream = None

    def callback(self, outdata, frames, time, status):
        if status:
            print(status, file=sys.stderr)
        t = (self.start_idx + np.arange(frames)) / self.stream.samplerate
        t = t.reshape(-1, 1)
        a_modulator = np.sin(2 * np.pi * self.am_freq * t) * self.am_amount + 1 - self.am_amount
        carrier = np.sin(2 * np.pi * self.freq * t)
        outdata[:] = carrier * a_modulator
        self.start_idx += frames

    def start(self, device):
        if self.stream:
            self.stop()
        samplerate = sd.query_devices(device, "output")["default_samplerate"]
        self.start_idx = 0
        self.stream = sd.OutputStream(
            device=device,
            channels=1,
            callback=self.callback,
            samplerate=samplerate,
        )
        self.stream.start()

    def stop(self):
        if self.stream:
            self.stream.stop()
            self.stream.close()
            self.stream = None


def make_box(parent, label, value, y):
    box = tk.Entry(parent)
    box.insert(0, value)
    label = tk.Label(parent, text=label)
    label.grid(row=y, column=0)
    box.grid(row=y, column=1)
    return (label, box)


def main():
    synth = Synth()

    def update_synth(*_args):
        synth.freq = float(freq_box.get())
        synth.am_freq = float(am_freq_box.get() or 0)
        synth.am_amount = float(am_amt_box.get() or 0)

    root = tk.Tk()
    root.title("Test")
    freq_label, freq_box = make_box(root, "Frequency", "500", 0)
    am_freq_label, am_freq_box = make_box(root, "AM Frequency", "25", 1)
    am_amt_label, am_amt_box = make_box(root, "AM Amount", "0.5", 2)

    freq_box.bind("<Return>", update_synth)
    am_freq_box.bind("<Return>", update_synth)
    am_amt_box.bind("<Return>", update_synth)

    tk.Button(root, text="Start", command=lambda: synth.start("Speakers")).grid(row=3, column=0)
    tk.Button(root, text="Stop", command=synth.stop).grid(row=3, column=2)
    root.mainloop()


if __name__ == "__main__":
    main()
Sign up to request clarification or add additional context in comments.

3 Comments

Thank you so much! That code is working almost fine for me. But as you said, I had to modify the part with the output device, so I added the parsing part from the original code and set device=args.device. But what I was originally intending to do was to modify the values while the audio is played. That's why I had to call tkinter functions in the callback.
Sure – in that case you'd want to instead set up changes in the tkinter widgets to change plain variables in your synthesizer (instead of the synth calling Tkinter).
@martinr Please see my second take on this :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.