Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
ychalier committed Jul 5, 2023
0 parents commit 826fe4f
Show file tree
Hide file tree
Showing 49 changed files with 5,147 additions and 0 deletions.
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
__pycache__
*.pyc
*.log
*.egg-info
dist
*.bin
*.wav
*.tsv
*.webm
.ipynb_checkpoints
*.mp4
*.json
*.gif
*.mp3
build/
assets/
674 changes: 674 additions & 0 deletions LICENSE.txt

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
include LICENSE
include README.md
recursive-include beatviewer/web *
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# BeatViewer

*BeatViewer* is a [Python](https://www.python.org/) program that analyzes an audio stream, extracts [onsets](https://en.wikipedia.org/wiki/Onset_(audio)), [tempo](https://en.wikipedia.org/wiki/Tempo) and [beats](https://en.wikipedia.org/wiki/Beat_(music)), and creates [visuals](https://en.wikipedia.org/wiki/VJing) from them in real time. Feature extraction relies on state-of-the-art [beat tracking algorithms](https://michaelkrzyzaniak.com/Research/Swarms_Preprint.pdf). Visuals are rendered via [Pygame](https://www.pygame.org/news) or external programs such as [OBS Studio](https://obsproject.com/) or any [WebSocket](https://en.wikipedia.org/wiki/WebSocket) client.

For a detailed narration of the birth of the project, watch the [associated video](https://www.youtube.com/watch?v=qv1uJQW-Cpc) (in French, English subtitles are coming).

## Requirements

This program requires a working installation of [Python 3](https://www.python.org/). Tools such as [FFmpeg](https://ffmpeg.org/) and [OBS Studio](https://obsproject.com/) are recommended.

## Installation

1. Download the [latest release](https://github.com/ychalier/beatviewer/releases)
2. Install it with `pip`:
```console
pip install ~/Downloads/beatviewer-1.0.0.tar.gz
```

Some resources (OBS script, tools, JS visuals) are available through direct download and are attached to the [latest release](https://github.com/ychalier/beatviewer/releases).

## Configuration

Here is a summary of what you'll need to get started. For more options (file output, tracking parameters, etc.) please refer to the [wiki](https://github.com/ychalier/beatviewer/wiki/).

### Audio Source Selection

By default, BeatViewer uses the default audio input. You can specify an audio device using the `-a <device-id>` parameter. You can get a list of audio devices by using the `-l` flag. You can also execute the module offline, by passing the path to an audio file with the `-f` argument (for now, only WAVE file are supported).

### Visualizer Selection

By default, no visualizer is attached to the tracker, it simply prints dots to stdout when a beat occurs. You can specify a visualizer by typing its name after the beat tracking arguments:

```console
python -m beatviewer <visualizer-name> <visualizer-arguments+>
```

For a quick test, you can try the `galaxy` visualizer. You'll find a list with more options and instructions on the [wiki](https://github.com/ychalier/beatviewer/wiki/).

## Contributing

Contributions are welcomed. For now, performance enhancements and addition of new visualizers are mostly needed. Do not hesitate to submit a pull request with your changes!

## License

This project is licensed under the GPL-3.0 license.

## Troubleshooting

Submit bug reports and feature suggestions in the [issue tracker](https://github.com/ychalier/beatviewer/issues/new/choose).

4 changes: 4 additions & 0 deletions beatviewer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from beatviewer import main

if __name__ == "__main__":
main()
147 changes: 147 additions & 0 deletions beatviewer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import argparse
import multiprocessing
import logging
import os
import time

import sounddevice

from .handlers import HANDLER_LIST
from .tools import TOOL_LIST
from .beat_tracker import BeatTracker
from .beat_tracker_process import BeatTrackerProcess
from .config import Config
from .audio_source.live_audio_source import LiveAudioSource
from .audio_source.file_audio_source import FileAudioSource


def print_table(table, padx=4):
for i in range(len(table)):
table[i] = list(map(str, table[i]))
widths = [0] * len(table[0])
for row in table:
for j, col in enumerate(row):
widths[j] = max(widths[j], len(col))
for row in table:
for j, col in enumerate(row):
print(col, end=" " * (widths[j] - len(col) + padx))
print("")


def get_action_tool(args):
for cls in TOOL_LIST:
if cls.NAME == args.action:
return cls.from_args(args)
return None


def get_action_handler(args, conn):
for cls in HANDLER_LIST:
if cls.NAME == args.action:
return cls.from_args(conn, args)
return None


def print_audio_device_list():
hostapis_info = sounddevice.query_hostapis()
devices_info = sounddevice.query_devices()
table = []
default_device = hostapis_info[0]["default_input_device"]
for device in devices_info:
if device["max_input_channels"] == 0:
continue
host = hostapis_info[device["hostapi"]]
table.append([
str(device["index"]) + ("<" if device["index"] == default_device else ""),
f"{device['max_input_channels']} in, {device['max_output_channels']} out",
f"{device['default_low_input_latency']:.2f} ms - {device['default_high_input_latency']:.2f} ms",
f"{(device['default_samplerate'] / 1000):.1f} kHz",
host["name"],
device["name"],
])
print_table(table)

LOG_FORMAT = "%(asctime)s\t%(levelname)s\t%(message)s"

def main():
logging.basicConfig(level=logging.INFO, filename="beatviewer.log", format=LOG_FORMAT)
logging.info("Hello, World!")
logging.info("Main process has PID %d", os.getpid())
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-a", "--audio-device", type=int, default=sounddevice.default.device[0], help="Audio device index")
parser.add_argument("-l", "--list-audio-devices", action="store_true", help="Show the list of available audio devices and exit")
parser.add_argument("-f", "--audio-file", type=str, default=None, help="Path to a local audio file for 'offline' beat tracking")
parser.add_argument("-t", "--realtime", action="store_true", help="Make offline beat tracking realtime")
parser.add_argument("-g", "--graph", action="store_true", help="Plot extracted audio features for in-depth analysis")
parser.add_argument("-gf", "--graph-fps", type=float, default=30, help="Refresh rate for the audio features graph")
parser.add_argument("-c", "--config", type=str, default=None, help="Path to a configuration file (see default 'config.txt')")
parser.add_argument("-k", "--keyboard-events", action="store_true", help="Monitor keyboard events")
parser.add_argument("-r", "--record-path", type=str, default=None, help="Record the the audio stream to a local file")
parser.add_argument("-o", "--output-path", type=str, default=None, help="Export the beats, onsets and BPM data to a CSV file")#
action_subparsers = parser.add_subparsers(dest="action")
for cls in HANDLER_LIST + TOOL_LIST:
subparser = action_subparsers.add_parser(cls.NAME, formatter_class=argparse.ArgumentDefaultsHelpFormatter)
cls.add_arguments(subparser)

args = parser.parse_args()
if args.list_audio_devices:
print_audio_device_list()
parser.exit(0)

tool = get_action_tool(args)
if tool is not None:
tool.run()
parser.exit(0)

if args.config is not None:
logging.info("Loading config from %s", args.config)
config = Config.from_file(args.config)
else:
logging.info("Using default config")
config = Config()

if args.audio_file is None:
audio_source = LiveAudioSource(config, args.audio_device, args.record_path)
else:
audio_source = FileAudioSource(config, args.audio_file, realtime=args.realtime, record_path=args.record_path)

tracker_kwargs = {
"show_graph": args.graph,
"graph_fps": args.graph_fps,
"keyboard_events": args.keyboard_events,
"output_path": args.output_path,
}

if args.action is None:
def beat_callback():
print(".", end="", flush=True)
BeatTracker(config, audio_source, beat_callback=beat_callback, **tracker_kwargs).run()
else:
conn1, conn2 = multiprocessing.Pipe()
tracker = BeatTrackerProcess(config, audio_source, conn1, **tracker_kwargs)
handler = get_action_handler(args, conn2)
logging.info("Starting tracker")
tracker.start()
logging.info("Starting handler")
handler.start()
logging.info("Entering main loop")
try:
while True:
time.sleep(1)
if not tracker.is_alive():
logging.info("Tracker process is not alive, breaking main loop")
break
if not handler.is_alive():
logging.info("Handler process is not alive, breaking main loop")
break
except KeyboardInterrupt:
pass
finally:
logging.info("Killing tracker and handler")
tracker.kill()
handler.kill()
logging.info("Joining tracker and handler")
tracker.join()
handler.join()

logging.info("Goodbye!")
3 changes: 3 additions & 0 deletions beatviewer/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from beatviewer import main

main()
Empty file.
42 changes: 42 additions & 0 deletions beatviewer/audio_source/audio_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import logging
import wave


class AudioSource:
"""Interface for audio source signal. It wraps utilities for updating an
array of audio samples and recording them to a local file.
"""

def __init__(self, config, sampling_rate, record_path=None):
logging.info(
"Creating audio source, with sampling rate %d Hz",
sampling_rate
)
self.config = config
self.sampling_rate = sampling_rate
self.record_path = record_path
self.record_file = None
self.active = True

def setup(self):
logging.info("Setting up audio source")
if self.record_path is not None:
self.record_file = wave.open(self.record_path, "wb")
self.record_file.setnchannels(1)
self.record_file.setsampwidth(2)
self.record_file.setframerate(self.sampling_rate)

def _update_window(self, window):
raise NotImplementedError

def update_window(self, window):
logging.debug("Updating audio source window")
self._update_window(window)
if self.record_file is not None:
i = self.config.audio_window_size - self.config.audio_hop_size
self.record_file.writeframes(window[i:].astype("<h").tobytes())

def close(self):
logging.info("Closing audio source")
if self.record_path is not None:
self.record_file.close()
63 changes: 63 additions & 0 deletions beatviewer/audio_source/file_audio_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import logging
import time

import soundfile
import tqdm

from .audio_source import AudioSource


class FileAudioSource(AudioSource):
"""Load audio frames from a local file. A progress bar indicates the
progression within the file. Only support int16 WAVE files. Multichannel
signals are averaged to a mono signal.
"""

def __init__(self, config, path, realtime=False, record_path=None):
logging.info(
"Creating file audio source from '%s', realtime is %s",
path,
realtime
)
self.path = path
self.realtime = realtime
data, sr = soundfile.read(self.path, dtype="int16", start=0)
AudioSource.__init__(self, config, int(sr), record_path=record_path)
self.data = data
self.i = 0
self.last_window_update = 0
self.window_update_period = self.config.audio_hop_size / sr
self.pbar = None

def setup(self):
AudioSource.setup(self)
self.pbar = tqdm.tqdm(
total=len(self.data),
unit="sample",
unit_scale=True
)

def _update_window(self, window):
if self.realtime:
while True:
now = time.time()
if now - self.last_window_update >= self.window_update_period:
self.last_window_update = now
break
pass
if self.i >= self.data.shape[0]:
self.active = False
logging.info("Reached end of audio file source")
return
k = self.config.audio_window_size - self.config.audio_hop_size
window[:k] = window[self.config.audio_hop_size:]
for j in range(self.config.audio_hop_size):
if self.i >= self.data.shape[0]:
window[k + j] = 0
else:
window[k + j] = int(sum(self.data[self.i]) / self.data.shape[1])
self.i += 1
self.pbar.update()

def close(self):
self.pbar.close()
Loading

0 comments on commit 826fe4f

Please sign in to comment.