-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 826fe4f
Showing
49 changed files
with
5,147 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
include LICENSE | ||
include README.md | ||
recursive-include beatviewer/web * |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from beatviewer import main | ||
|
||
if __name__ == "__main__": | ||
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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!") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from beatviewer import main | ||
|
||
main() |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Oops, something went wrong.