diff --git a/submissions/beatline/audio/engine.py b/submissions/beatline/audio/engine.py new file mode 100644 index 00000000..e1b4d797 --- /dev/null +++ b/submissions/beatline/audio/engine.py @@ -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.") \ No newline at end of file diff --git a/submissions/beatline/audio/fileplayer.py b/submissions/beatline/audio/fileplayer.py new file mode 100644 index 00000000..1e950c0f --- /dev/null +++ b/submissions/beatline/audio/fileplayer.py @@ -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 \ No newline at end of file diff --git a/submissions/beatline/config.json b/submissions/beatline/config.json new file mode 100644 index 00000000..aecf51d4 --- /dev/null +++ b/submissions/beatline/config.json @@ -0,0 +1,5 @@ +{ + "defaultMode": "waveform", + "colorTheme": "dark", + "beatSensitivity": 10 +} \ No newline at end of file diff --git a/submissions/beatline/demo.gif b/submissions/beatline/demo.gif new file mode 100644 index 00000000..0fa93420 Binary files /dev/null and b/submissions/beatline/demo.gif differ diff --git a/submissions/beatline/main.py b/submissions/beatline/main.py new file mode 100644 index 00000000..cdae2976 --- /dev/null +++ b/submissions/beatline/main.py @@ -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) \ No newline at end of file diff --git a/submissions/beatline/readme.md b/submissions/beatline/readme.md new file mode 100644 index 00000000..031f1e19 --- /dev/null +++ b/submissions/beatline/readme.md @@ -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. \ No newline at end of file diff --git a/submissions/beatline/requirements.txt b/submissions/beatline/requirements.txt new file mode 100644 index 00000000..d4bf7270 --- /dev/null +++ b/submissions/beatline/requirements.txt @@ -0,0 +1,5 @@ +numpy +sounddevice +soundfile +blessed +curses \ No newline at end of file diff --git a/submissions/beatline/utils/beat.py b/submissions/beatline/utils/beat.py new file mode 100644 index 00000000..eb23d9e0 --- /dev/null +++ b/submissions/beatline/utils/beat.py @@ -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 \ No newline at end of file diff --git a/submissions/beatline/visualizers/circlebands.py b/submissions/beatline/visualizers/circlebands.py new file mode 100644 index 00000000..52653b85 --- /dev/null +++ b/submissions/beatline/visualizers/circlebands.py @@ -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 \ No newline at end of file diff --git a/submissions/beatline/visualizers/pulse.py b/submissions/beatline/visualizers/pulse.py new file mode 100644 index 00000000..35b7103e --- /dev/null +++ b/submissions/beatline/visualizers/pulse.py @@ -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 \ No newline at end of file diff --git a/submissions/beatline/visualizers/spectrum.py b/submissions/beatline/visualizers/spectrum.py new file mode 100644 index 00000000..32c3e646 --- /dev/null +++ b/submissions/beatline/visualizers/spectrum.py @@ -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) \ No newline at end of file diff --git a/submissions/beatline/visualizers/squares.py b/submissions/beatline/visualizers/squares.py new file mode 100644 index 00000000..5d3dd812 --- /dev/null +++ b/submissions/beatline/visualizers/squares.py @@ -0,0 +1,59 @@ +import numpy as np +import curses + +def drawEnergySquares(stdscr, samples, _): + height, width = stdscr.getmaxyx() + h_half = height // 2 + w_half = width // 2 + + fft = np.abs(np.fft.rfft(samples)) + fft = np.log1p(fft) + + sub = np.mean(fft[0:10]) / 2.5 + bass = np.mean(fft[10:40]) / 3.0 + mids = np.mean(fft[40:100]) / 4.0 + treble = np.mean(fft[100:]) / 4.0 + + def clip(x): + return min(max(x, 0.0), 1.0) + + intensities = { + "SUB": clip(sub), + "BASS": clip(bass), + "MIDS": clip(mids), + "TREBLE": clip(treble) + } + + def get_attr(intensity): + if intensity < 0.2: + return curses.color_pair(1) | curses.A_DIM + elif intensity < 0.4: + return curses.color_pair(1) + elif intensity < 0.7: + return curses.color_pair(1) | curses.A_BOLD + else: + return curses.color_pair(1) | curses.A_REVERSE + + def draw_square(y0, x0, y1, x1, intensity): + attr = get_attr(intensity) + for y in range(y0, y1): + for x in range(x0, x1): + try: + stdscr.addch(y, x, ord(' '), attr) + except: + pass + + draw_square(0, 0, h_half, w_half, intensities["SUB"]) + draw_square(0, w_half, h_half, width, intensities["BASS"]) + draw_square(h_half, 0, height, w_half, intensities["MIDS"]) + draw_square(h_half, w_half, height, width, intensities["TREBLE"]) + + try: + stdscr.attron(curses.color_pair(1)) + stdscr.addstr(1, 2, "SUB") + stdscr.addstr(1, w_half + 2, "BASS") + stdscr.addstr(h_half + 1, 2, "MIDS") + stdscr.addstr(h_half + 1, w_half + 2, "TREBLE") + stdscr.attroff(curses.color_pair(1)) + except: + pass \ No newline at end of file diff --git a/submissions/beatline/visualizers/waveform.py b/submissions/beatline/visualizers/waveform.py new file mode 100644 index 00000000..28e668f3 --- /dev/null +++ b/submissions/beatline/visualizers/waveform.py @@ -0,0 +1,20 @@ +import numpy as np + +def drawWaveform(stdscr, samples, color): + height, width = stdscr.getmaxyx() + height -= 2 + + chars = [' ', '.', '˙', ':', '-', '=', '+', '*', '#', '█'] + step = max(1, len(samples) // width) + + scaled = np.interp(samples[::step], [-1, 1], [0, height - 1]) + + for x, val in enumerate(scaled[:width]): + y = int(height - val - 1) + intensity = int((val / (height - 1)) * (len(chars) - 1)) + intensity = max(0, min(intensity, len(chars) - 1)) + + try: + stdscr.addch(y, x, chars[intensity], color) + except: + pass \ No newline at end of file diff --git a/submissions/beatline/visualizers/zones.py b/submissions/beatline/visualizers/zones.py new file mode 100644 index 00000000..6952feb8 --- /dev/null +++ b/submissions/beatline/visualizers/zones.py @@ -0,0 +1,41 @@ +import numpy as np + +def drawBandZones(stdscr, samples, color): + height, width = stdscr.getmaxyx() + height -= 2 + + fft = np.abs(np.fft.rfft(samples)) + fft /= np.max(fft) if np.max(fft) > 0 else 1 + + bands = { + "SUB": fft[0:2], + "BASS": fft[2:7], + "L-MID": fft[7:12], + "MID": fft[12:46], + "H-MID": fft[46:91], + "PRES": fft[91:137], + "BRILL": fft[137:] + } + + band_names = list(bands.keys()) + zone_width = width // len(band_names) + + for i, name in enumerate(band_names): + energy = np.mean(bands[name]) + level = int(energy * height) + start_x = i * zone_width + + for y in range(height - 1, height - 1 - level, -1): + for x in range(start_x, start_x + zone_width): + if 0 <= x < width and 0 <= y < height: + try: + stdscr.addch(y, x, ord('#'), color) + except: + pass + + label_x = start_x + max(0, (zone_width - len(name)) // 2) + if height + 1 < stdscr.getmaxyx()[0]: + try: + stdscr.addstr(height, label_x, name[:zone_width], color) + except: + pass \ No newline at end of file