Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions submissions/beatline/audio/engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import sounddevice as sd
import numpy as np

class AudioEngine:
@classmethod
def create(cls, samplerate=44100, chunkSize=1024, use_loopback=False):
self = cls()
self.samplerate = samplerate
self.chunkSize = chunkSize
self.device = self._get_loopback_device() if use_loopback else None
self.stream = sd.InputStream(
channels=2 if use_loopback else 1,
samplerate=self.samplerate,
blocksize=self.chunkSize,
dtype='float32',
device=self.device
)
self.stream.start()
return self

def read_chunk(self):
data, _ = self.stream.read(self.chunkSize)
return np.mean(data, axis=1) if data.ndim > 1 else np.squeeze(data)

def _get_loopback_device(self):
for device in sd.query_devices():
if 'loopback' in device['name'].lower() or 'stereo mix' in device['name'].lower():
return device['name']
raise RuntimeError("Loopback device not found. Make sure your system supports it.")
25 changes: 25 additions & 0 deletions submissions/beatline/audio/fileplayer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import numpy as np
import soundfile as sf
import sounddevice as sd

class FileAudioEngine:
def __init__(self, filepath, chunkSize=1024):
self.filepath = filepath
self.chunkSize = chunkSize
self.data, self.sampleRate = sf.read(filepath, dtype='float32')

if self.data.ndim > 1:
self.data = np.mean(self.data, axis=1)

self.position = 0
self.stream = sd.OutputStream(samplerate=self.sampleRate, channels=1, dtype='float32')
self.stream.start()

def read_chunk(self):
if self.position + self.chunkSize >= len(self.data):
self.position = 0

chunk = self.data[self.position:self.position + self.chunkSize]
self.stream.write(chunk)
self.position += self.chunkSize
return chunk
5 changes: 5 additions & 0 deletions submissions/beatline/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"defaultMode": "waveform",
"colorTheme": "dark",
"beatSensitivity": 10
}
Binary file added submissions/beatline/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions submissions/beatline/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import curses
import time
import json
import argparse
from pathlib import Path
from audio.engine import AudioEngine
from audio.fileplayer import FileAudioEngine
from utils.beat import BeatDetector
from visualizers.waveform import drawWaveform
from visualizers.spectrum import drawSpectrum
from visualizers.pulse import drawPulse
from visualizers.circlebands import drawCircleBands
from visualizers.zones import drawBandZones
from visualizers.squares import drawEnergySquares

configPath = Path("config.json")

modes = ["waveform", "spectrum", "pulse", "circlebands", "zones", "squares"]
colorThemes = {
"neon": ["cyan", "magenta", "blue"],
"matrix": ["green", "black"],
"dark": ["white", "black"]
}

def loadConfig():
if configPath.exists():
with open(configPath) as f:
return json.load(f)
return {
"beatSensitivity": 1.0,
"defaultMode": "waveform",
"colorTheme": "neon"
}

config = loadConfig()

parser = argparse.ArgumentParser(description="Beatline - Terminal Music Visualizer")
parser.add_argument("--audio", type=str, help="Path to audio file to use instead of microphone")
args = parser.parse_args()

def get_color_pair():
return curses.color_pair(1)

def render(stdscr, audio, detector, mode, input_label, fg_color):
samples = audio.read_chunk()
beat = detector.detect(samples)
stdscr.clear()

stdscr.attron(fg_color)
stdscr.addstr(0, 0, f"Input: {input_label}")
stdscr.attroff(fg_color)

if mode == "waveform":
drawWaveform(stdscr, samples, fg_color)
elif mode == "spectrum":
drawSpectrum(stdscr, samples, fg_color)
elif mode == "pulse":
drawPulse(stdscr, samples, fg_color)
elif mode == "circlebands":
drawCircleBands(stdscr, samples, fg_color)
elif mode == "zones":
drawBandZones(stdscr, samples, fg_color)
elif mode == "squares":
drawEnergySquares(stdscr, samples, fg_color)

stdscr.attron(fg_color)
try:
max_y, max_x = stdscr.getmaxyx()
footer = f"[1] Waveform [2] Spectrum [3] Pulse [4] CircleBands [5] Zones [6] Squares | [C] Theme ({config['colorTheme']}) [Q] Quit"
stdscr.addstr(max_y - 1, 0, footer[:max_x - 1])
except curses.error:
pass
stdscr.attroff(fg_color)
stdscr.refresh()

def main(stdscr):
curses.curs_set(0)
stdscr.nodelay(True)
curses.start_color()

if not curses.has_colors():
raise RuntimeError("Terminal does not support colors")

theme = config["colorTheme"]
color = colorThemes.get(theme, ["white"])[0]
color_map = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED,
"green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE
}
fg = color_map.get(color, curses.COLOR_WHITE)
if fg is None:
fg = curses.COLOR_WHITE
curses.init_pair(1, fg, curses.COLOR_BLACK)

input_label = "Microphone"
try:
if args.audio:
audio = FileAudioEngine(args.audio)
input_label = f"File: {args.audio}"
else:
audio = AudioEngine.create(use_loopback=False)
except RuntimeError as e:
stdscr.clear()
stdscr.addstr(0, 0, f"Error: {str(e)}. Using microphone.")
stdscr.refresh()
time.sleep(2)
audio = AudioEngine.create(use_loopback=False)

detector = BeatDetector()
detector.sensitivity = config["beatSensitivity"]
mode = config["defaultMode"]

fg_color = get_color_pair()

while True:
render(stdscr, audio, detector, mode, input_label, fg_color)
ch = stdscr.getch()
if ch == ord('q'):
break
elif ch == ord('1'):
mode = "waveform"
elif ch == ord('2'):
mode = "spectrum"
elif ch == ord('3'):
mode = "pulse"
elif ch == ord('4'):
mode = "circlebands"
elif ch == ord('5'):
mode = "zones"
elif ch == ord('6'):
mode = "squares"
elif ch in (ord('c'), ord('C')):
themes = list(colorThemes.keys())
idx = (themes.index(config["colorTheme"]) + 1) % len(themes)
config["colorTheme"] = themes[idx]
configPath.write_text(json.dumps(config, indent=4))
theme = config["colorTheme"]
color = colorThemes.get(theme, ["white"])[0]
fg = color_map.get(color, curses.COLOR_WHITE)
if fg is None:
fg = curses.COLOR_WHITE
curses.init_pair(1, fg, curses.COLOR_BLACK)
fg_color = get_color_pair()

if __name__ == "__main__":
curses.wrapper(main)
51 changes: 51 additions & 0 deletions submissions/beatline/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Beatline 🎶

Beatline is a terminal-based real-time music visualizer. It captures audio (via microphone or file) and renders various visualizations like waveform, spectrum bars, pulse matrix, radial rings, and frequency zones right inside your terminal.

---

## 🔧 Features
- **Waveform**: See the audio waveform in real time.
- **Spectrum**: Bar graph showing frequency distribution.
- **Pulse**: Radial pulses synced to audio volume.
- **CircleBands**: Circular visual representation of energy bands.
- **Zones**: Frequency-labeled horizontal bands (SUB, BASS, MIDS, TREBLE).
- **Squares**: Intensity-based glowing quadrants.

---

## ▶️ How to Run

```bash
git clone https://github.com/yourname/beatline.git
cd beatline

pip install -r requirements.txt

python3 main.py

python3 main.py --audio path/to/file.mp3
```


## Demo
![Demo](demo.gif)


⚙️ Requirements
• Python 3.8+
• numpy
• sounddevice
• soundfile
• blessed
• curses (builtin on macOS/Linux)

macOS users must grant microphone permissions and optionally install BlackHole to capture system audio.


💡 Why I Created It

I wanted a fun, fast, hackable music visualizer that could run directly in a terminal, without GUI frameworks or external displays. This was also a great way to explore audio processing and terminal graphics together.
5 changes: 5 additions & 0 deletions submissions/beatline/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
numpy
sounddevice
soundfile
blessed
curses
11 changes: 11 additions & 0 deletions submissions/beatline/utils/beat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import numpy as np

class BeatDetector:
sensitivity = 0.5
previousEnergy = 0

def detect(self, samples):
energy = np.linalg.norm(samples)
beat = energy > self.previousEnergy * (1 + self.sensitivity)
self.previousEnergy = energy * 0.9 + self.previousEnergy * 0.1
return beat
37 changes: 37 additions & 0 deletions submissions/beatline/visualizers/circlebands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import numpy as np
import math

def drawCircleBands(stdscr, samples, color):
height, width = stdscr.getmaxyx()
center_y, center_x = height // 2, width // 2
radius = min(center_y, center_x) - 2

fft = np.log1p(np.abs(np.fft.rfft(samples)))
fft /= np.max(fft) if np.max(fft) > 0 else 1

sectors = 96
angle_step = 2 * math.pi / sectors
band_step = max(1, len(fft) // sectors)

for i in range(sectors):
angle = i * angle_step
energy = np.mean(fft[i * band_step:(i + 1) * band_step])
length = int(energy * radius)

for r in range(1, length + 1):
y = int(center_y + r * math.sin(angle))
x = int(center_x + r * math.cos(angle))
if 0 <= y < height and 0 <= x < width:
char = '●' if r > radius * 0.7 else '*' if r > radius * 0.4 else '.'
try:
stdscr.addch(y, x, ord(char), color)
except:
pass

try:
stdscr.addstr(center_y - radius - 1, center_x - 3, "TREBLE", color)
stdscr.addstr(center_y + radius + 1, center_x - 2, "BASS", color)
stdscr.addstr(center_y, center_x - radius - 6, "SUB", color)
stdscr.addstr(center_y, center_x + radius + 1, "MID", color)
except:
pass
53 changes: 53 additions & 0 deletions submissions/beatline/visualizers/pulse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import numpy as np
import curses

frame_counter = 0
last_boom_frame = -10

def drawPulse(stdscr, samples, color):
global frame_counter, last_boom_frame
height, width = stdscr.getmaxyx()
peak = np.max(np.abs(samples))
frame_counter += 1

intensity = min(peak / 1.0, 1.0)
attr = curses.color_pair(1)
if intensity < 0.2:
attr |= curses.A_DIM
elif intensity < 0.5:
attr |= 0
elif intensity < 0.8:
attr |= curses.A_BOLD
else:
attr |= curses.A_REVERSE

center_y = height // 2
center_x = width // 2

ring_width = 2

if peak > 0.1:
last_boom_frame = frame_counter
pulse_strength = int(intensity * 4) + 1
else:
pulse_strength = 0

pulse_age = frame_counter - last_boom_frame
radius = pulse_age * (1 + pulse_strength)

for y in range(height):
for x in range(0, width, 2):
dy = y - center_y
dx = (x - center_x) // 2
dist = (dx**2 + dy**2) ** 0.5

if radius - ring_width <= dist <= radius + ring_width:
try:
stdscr.addstr(y, x, "██", attr)
except:
pass
else:
try:
stdscr.addstr(y, x, " ", curses.color_pair(1) | curses.A_DIM)
except:
pass
12 changes: 12 additions & 0 deletions submissions/beatline/visualizers/spectrum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
def drawSpectrum(stdscr, samples, color):
height, width = stdscr.getmaxyx()
step = max(1, len(samples) // width)

for i in range(min(width, len(samples))):
val = max(samples[i * step:i * step + step], default=0)
val = max(0.0, min(val, 1.0)) # clamp to [0, 1]
bar_height = int(val * (height - 2))

for y in range(height - 2, height - 2 - bar_height, -1):
if 0 <= y < height and 0 <= i < width:
stdscr.addch(y, i, ord('*'), color)
Loading