From de6d945fedaa3eabc30f308646c39d6ffab702c0 Mon Sep 17 00:00:00 2001 From: Lassi Niemisto Date: Mon, 28 Jun 2021 16:00:48 +0300 Subject: [PATCH] Audio player mode which self-plays wav files and does not need microphone. Mono only for now --- README.md | 4 +- python/audio_player.py | 85 +++++++++++++++++++++++++++++++++++++++++ python/config.py | 26 +++++++++++-- python/dsp.py | 8 ++-- python/microphone.py | 4 +- python/visualization.py | 25 ++++++------ 6 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 python/audio_player.py diff --git a/README.md b/README.md index 7a0f5a78..6890f518 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ DEVICE = 'pi' USE_GUI = False DISPLAY_FPS = False N_PIXELS = 144 -MIC_RATE = 48000 +AUDIO_RATE = 48000 FPS = 50 ``` @@ -274,7 +274,7 @@ The connections are: 7. In [config.py](python/config.py): - Set `N_PIXELS` to the number of LEDs in your LED strip (must match `NUM_LEDS` in [ws2812_controller.ino](arduino/ws2812_controller/ws2812_controller.ino)) - Set `UDP_IP` to the IP address of your ESP8266 (must match `ip` in [ws2812_controller.ino](arduino/ws2812_controller/ws2812_controller.ino)) - - If needed, set `MIC_RATE` to your microphone sampling rate in Hz. Most of the time you will not need to change this. + - If needed, set `AUDIO_RATE` to your microphone sampling rate in Hz. Most of the time you will not need to change this. # Installation for Raspberry Pi If you encounter any problems running the visualization on a Raspberry Pi, please [open a new issue](https://github.com/scottlawsonbc/audio-reactive-led-strip/issues). Also, please consider opening an issue if you have any questions or suggestions for improving the installation process. diff --git a/python/audio_player.py b/python/audio_player.py new file mode 100644 index 00000000..ad254f68 --- /dev/null +++ b/python/audio_player.py @@ -0,0 +1,85 @@ +import time +import numpy as np +import pyaudio +import config +import wave +import queue +import os + +wave_file = None +loopback_audio_stream = queue.Queue() +audio_file_paths = [] +audio_file_index = 0 + + +def scan_audio_files(): + for entry in os.scandir(config.AUDIO_FILE_SCAN_PATH): + if entry.is_file() and entry.path.endswith('.wav'): + audio_file_paths.append(entry.path) + + # If only one file, add it twice so the list looping will still work + if len(audio_file_paths) == 1: + audio_file_paths.append(audio_file_paths[0]) + + +def need_more_data_callback(in_data, frame_count, time_info, status): + global wave_file + global audio_file_index + ret_status = pyaudio.paComplete + + data = None + if wave_file: + data = wave_file.readframes(frame_count) + if len(data) < frame_count: + wave_file = None + # Loop to next audio file + audio_file_index = (audio_file_index + 1) % len(audio_file_paths) + else: + # Pass the data chunks back to visualization via queue + loopback_audio_stream.put(data) + # More data to come + ret_status = pyaudio.paContinue + return data, ret_status + + +def start_stream(callback): + global wave_file + + scan_audio_files() + p = pyaudio.PyAudio() + + while True: + playing_audio_file_index = audio_file_index + audio_file_path = audio_file_paths[audio_file_index] + wave_file = wave.open(audio_file_path, 'rb') + assert (wave_file.getframerate() == config.AUDIO_RATE) + + # Creates a Stream to which the wav file is written to. + # Setting output to "True" makes the sound be "played" rather than recorded + stream = p.open(format=p.get_format_from_width(wave_file.getsampwidth()), + channels=wave_file.getnchannels(), + rate=wave_file.getframerate(), + output=True, + stream_callback=need_more_data_callback) + + # Visualize the latest played samples until file changes + while playing_audio_file_index == audio_file_index: + # Clear the queue to wait for newest samples + while not loopback_audio_stream.empty(): + loopback_audio_stream.get(block=False) + try: + data = loopback_audio_stream.get(timeout=1) + except queue.Empty: + break + y = np.fromstring(data, dtype=np.int16) + y = y.astype(np.float32) + # Small delay before visualization to sync it better + time.sleep(0.01) + callback(y) + + # Close the stream + stream.stop_stream() + stream.close() + + p.terminate() + diff --git a/python/config.py b/python/config.py index 65e21378..153b9302 100644 --- a/python/config.py +++ b/python/config.py @@ -46,6 +46,9 @@ USE_GUI = False """Whether or not to display a PyQtGraph GUI plot of visualization""" +AUDIO_PLAYER_MODE = False +"""False = Listen to microphone | True = Play music from file(s)""" + DISPLAY_FPS = True """Whether to display the FPS when running (can reduce performance)""" @@ -55,11 +58,26 @@ GAMMA_TABLE_PATH = os.path.join(os.path.dirname(__file__), 'gamma_table.npy') """Location of the gamma correction table""" -MIC_RATE = 48000 -"""Sampling frequency of the microphone in Hz""" +AUDIO_RATE = 48000 +"""Sampling frequency of the microphone or audio file in Hz""" + +AUDIO_FILE_SCAN_PATH = '/home/pi' +"""Path to scan audio files from (for player mode only)""" + +if AUDIO_PLAYER_MODE: + AUDIO_FRAME_SIZE = 1024 + """How many audio samples are processed as a frame, defined by pyaudio/portaudio stream callback""" + + FPS = int(AUDIO_RATE / AUDIO_FRAME_SIZE) + """Target FPS is automatically calculated""" +else: + FPS = 50 + """Target FPS, read more info below""" + + AUDIO_FRAME_SIZE = int(AUDIO_RATE / FPS) + """Audio frame size is automatically calculated""" -FPS = 50 -"""Desired refresh rate of the visualization (frames per second) +"""FPS = Desired refresh rate of the visualization (frames per second) FPS indicates the desired refresh rate, or frames-per-second, of the audio visualization. The actual refresh rate may be lower if the computer cannot keep diff --git a/python/dsp.py b/python/dsp.py index 4dca6174..2eab55ad 100644 --- a/python/dsp.py +++ b/python/dsp.py @@ -28,25 +28,25 @@ def update(self, value): def rfft(data, window=None): window = 1.0 if window is None else window(len(data)) ys = np.abs(np.fft.rfft(data * window)) - xs = np.fft.rfftfreq(len(data), 1.0 / config.MIC_RATE) + xs = np.fft.rfftfreq(len(data), 1.0 / config.AUDIO_RATE) return xs, ys def fft(data, window=None): window = 1.0 if window is None else window(len(data)) ys = np.fft.fft(data * window) - xs = np.fft.fftfreq(len(data), 1.0 / config.MIC_RATE) + xs = np.fft.fftfreq(len(data), 1.0 / config.AUDIO_RATE) return xs, ys def create_mel_bank(): global samples, mel_y, mel_x - samples = int(config.MIC_RATE * config.N_ROLLING_HISTORY / (2.0 * config.FPS)) + samples = int(config.AUDIO_FRAME_SIZE * config.N_ROLLING_HISTORY / 2.0) mel_y, (_, mel_x) = melbank.compute_melmat(num_mel_bands=config.N_FFT_BINS, freq_min=config.MIN_FREQUENCY, freq_max=config.MAX_FREQUENCY, num_fft_bands=samples, - sample_rate=config.MIC_RATE) + sample_rate=config.AUDIO_RATE) samples = None mel_y = None mel_x = None diff --git a/python/microphone.py b/python/microphone.py index 0903f952..add8253b 100644 --- a/python/microphone.py +++ b/python/microphone.py @@ -6,10 +6,10 @@ def start_stream(callback): p = pyaudio.PyAudio() - frames_per_buffer = int(config.MIC_RATE / config.FPS) + frames_per_buffer = config.AUDIO_FRAME_SIZE stream = p.open(format=pyaudio.paInt16, channels=1, - rate=config.MIC_RATE, + rate=config.AUDIO_RATE, input=True, frames_per_buffer=frames_per_buffer) overflows = 0 diff --git a/python/visualization.py b/python/visualization.py index 4802299d..aa3576fc 100644 --- a/python/visualization.py +++ b/python/visualization.py @@ -5,6 +5,7 @@ from scipy.ndimage.filters import gaussian_filter1d import config import microphone +import audio_player import dsp import led import sys @@ -187,11 +188,11 @@ def visualize_spectrum(y): alpha_decay=0.5, alpha_rise=0.99) volume = dsp.ExpFilter(config.MIN_VOLUME_THRESHOLD, alpha_decay=0.02, alpha_rise=0.02) -fft_window = np.hamming(int(config.MIC_RATE / config.FPS) * config.N_ROLLING_HISTORY) +fft_window = np.hamming(config.AUDIO_FRAME_SIZE * config.N_ROLLING_HISTORY) prev_fps_update = time.time() -def microphone_update(audio_samples): +def audio_update(audio_samples): global y_roll, prev_rms, prev_exp, prev_fps_update # Normalize samples between 0 and 1 y = audio_samples / 2.0**15 @@ -245,11 +246,8 @@ def microphone_update(audio_samples): print('FPS {:.0f} / {:.0f}'.format(fps, config.FPS)) -# Number of audio samples to read every time frame -samples_per_frame = int(config.MIC_RATE / config.FPS) - # Array containing the rolling audio sample window -y_roll = np.random.rand(config.N_ROLLING_HISTORY, samples_per_frame) / 1e16 +y_roll = np.random.rand(config.N_ROLLING_HISTORY, config.AUDIO_FRAME_SIZE) / 1e16 if sys.argv[1] == "spectrum": visualization_type = visualize_spectrum @@ -310,16 +308,16 @@ def microphone_update(audio_samples): freq_label = pg.LabelItem('') # Frequency slider def freq_slider_change(tick): - minf = freq_slider.tickValue(0)**2.0 * (config.MIC_RATE / 2.0) - maxf = freq_slider.tickValue(1)**2.0 * (config.MIC_RATE / 2.0) + minf = freq_slider.tickValue(0)**2.0 * (config.AUDIO_RATE / 2.0) + maxf = freq_slider.tickValue(1)**2.0 * (config.AUDIO_RATE / 2.0) t = 'Frequency range: {:.0f} - {:.0f} Hz'.format(minf, maxf) freq_label.setText(t) config.MIN_FREQUENCY = minf config.MAX_FREQUENCY = maxf dsp.create_mel_bank() freq_slider = pg.TickSliderItem(orientation='bottom', allowAdd=False) - freq_slider.addTick((config.MIN_FREQUENCY / (config.MIC_RATE / 2.0))**0.5) - freq_slider.addTick((config.MAX_FREQUENCY / (config.MIC_RATE / 2.0))**0.5) + freq_slider.addTick((config.MIN_FREQUENCY / (config.AUDIO_RATE / 2.0))**0.5) + freq_slider.addTick((config.MAX_FREQUENCY / (config.AUDIO_RATE / 2.0))**0.5) freq_slider.tickMoveFinished = freq_slider_change freq_label.setText('Frequency range: {} - {} Hz'.format( config.MIN_FREQUENCY, @@ -364,5 +362,8 @@ def spectrum_click(x): layout.addItem(spectrum_label) # Initialize LEDs led.update() - # Start listening to live audio stream - microphone.start_stream(microphone_update) + # Start the audio stream + if config.AUDIO_PLAYER_MODE: + audio_player.start_stream(audio_update) + else: + microphone.start_stream(audio_update)