Skip to content

Commit 2e300dd

Browse files
Merge pull request #1 from NotoriousArnav/feat/sequence-looper
Add step sequencer with audio engine fixes
2 parents 390e7bc + 29efcea commit 2e300dd

9 files changed

Lines changed: 1171 additions & 20 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ wheels/
1010
.venv
1111

1212
*_bank.json
13+
*_patterns.json

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515

1616
[project.scripts]
1717
simplesampler = "simplesampler.main:main"
18+
simplesampler-seq = "simplesampler.sequencer.app:main"
1819

1920
[build-system]
2021
requires = ["hatchling"]

src/seq.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Dev entry point for the sequencer. Run with: uv run src/seq.py bank.json"""
2+
3+
import sys
4+
import os
5+
6+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "simplesampler", ".."))
7+
8+
from simplesampler.sequencer.app import main
9+
10+
if __name__ == "__main__":
11+
main()

src/simplesampler/audio/playback.py

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,44 @@
22
import wave
33
import numpy as np
44
from collections import deque
5-
from typing import List, Dict
65
import os
76
import sys
87

98

9+
class _Voice:
10+
"""Lightweight voice object for the audio callback hot path.
11+
12+
Uses __slots__ for fast attribute access — dict key hashing is
13+
measurably slower when called thousands of times per second.
14+
"""
15+
16+
__slots__ = ("data", "idx")
17+
18+
def __init__(self, data: np.ndarray):
19+
self.data = data
20+
self.idx = 0
21+
22+
1023
class AudioPlayer:
1124
RATE = 44100
1225
CHANNELS = 2
13-
BLOCKSIZE = 256 # ~5.8ms at 44100 Hz
26+
MAX_VOICES = 64 # Drop oldest voices beyond this limit
27+
28+
# Absolute ceiling: ~33ms at 44100 Hz. Keeps latency bounded
29+
# even if the caller passes a huge value.
30+
_MAX_BLOCKSIZE = 1456
31+
32+
def __init__(self, blocksize: int = 256):
33+
self.blocksize = min(blocksize, self._MAX_BLOCKSIZE)
1434

15-
def __init__(self):
1635
# Lock-free pending queue: play_data() appends here,
1736
# callback drains into its own local list each cycle.
18-
self._pending: deque = deque()
19-
self._voices: List[Dict] = []
37+
self._pending: deque[_Voice] = deque()
38+
self._voices: list[_Voice] = []
2039

2140
self.stream = sd.OutputStream(
2241
samplerate=self.RATE,
23-
blocksize=self.BLOCKSIZE,
42+
blocksize=self.blocksize,
2443
channels=self.CHANNELS,
2544
dtype="float32",
2645
latency="low",
@@ -36,7 +55,7 @@ def play_data(self, data: np.ndarray):
3655
if data is None or len(data) == 0:
3756
return
3857
# deque.append is atomic in CPython — no lock needed
39-
self._pending.append({"data": data, "idx": 0})
58+
self._pending.append(_Voice(data))
4059

4160
def play_wave_file(self, file_path: str):
4261
"""Loads and plays a wav file immediately."""
@@ -56,33 +75,35 @@ def _callback(self, outdata: np.ndarray, frames: int, time, status):
5675
print(f"Audio status: {status}", file=sys.stderr)
5776

5877
# Drain pending voices into our local list (lock-free reads)
59-
while True:
60-
try:
61-
voice = self._pending.popleft()
62-
self._voices.append(voice)
63-
except IndexError:
64-
break
78+
pending = self._pending
79+
voices = self._voices
80+
while pending:
81+
voices.append(pending.popleft())
82+
83+
# Enforce voice cap — drop oldest voices first
84+
if len(voices) > self.MAX_VOICES:
85+
del voices[: len(voices) - self.MAX_VOICES]
6586

6687
# Zero the output buffer
6788
outdata[:] = 0.0
6889

6990
# Mix active voices
70-
i = len(self._voices) - 1
91+
i = len(voices) - 1
7192
while i >= 0:
72-
voice = self._voices[i]
73-
data = voice["data"]
74-
idx = voice["idx"]
93+
voice = voices[i]
94+
data = voice.data
95+
idx = voice.idx
7596

7697
remaining = len(data) - idx
7798
to_read = min(frames, remaining)
7899

79100
if to_read > 0:
80101
outdata[:to_read] += data[idx : idx + to_read]
81-
voice["idx"] += to_read
102+
voice.idx += to_read
82103

83104
# Remove finished voices
84-
if voice["idx"] >= len(data):
85-
self._voices.pop(i)
105+
if voice.idx >= len(data):
106+
voices.pop(i)
86107

87108
i -= 1
88109

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Master configuration for SimpleSampler (ss_config.toml).
3+
4+
Search order:
5+
1. $XDG_CONFIG_HOME/simplesampler/ss_config.toml
6+
2. ./ss_config.toml (working directory)
7+
8+
If not found, all defaults apply silently.
9+
"""
10+
11+
import os
12+
import sys
13+
import tomllib
14+
from pydantic import BaseModel, ValidationError
15+
from typing import Tuple
16+
17+
18+
class MetronomeConfig(BaseModel):
19+
enabled: bool = True
20+
sound: str = "" # Path to WAV — empty means generated sine click
21+
volume: float = 0.7 # 0.0 – 1.0
22+
accent_beat_1: bool = True # Louder click on beat 1
23+
24+
25+
class SequencerConfig(BaseModel):
26+
default_bpm: int = 120
27+
steps_per_beat: int = 4
28+
time_signature: Tuple[int, int] = (4, 4)
29+
pattern_count: int = 4 # Default number of empty patterns to create
30+
31+
32+
class SSConfig(BaseModel):
33+
metronome: MetronomeConfig = MetronomeConfig()
34+
sequencer: SequencerConfig = SequencerConfig()
35+
36+
37+
def load_config(override_path: str | None = None) -> SSConfig:
38+
"""
39+
Load ss_config.toml from the override path, XDG config dir, or cwd.
40+
Returns defaults if no file is found.
41+
"""
42+
paths: list[str] = []
43+
44+
if override_path:
45+
paths.append(override_path)
46+
47+
# XDG_CONFIG_HOME (default ~/.config)
48+
xdg = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
49+
paths.append(os.path.join(xdg, "simplesampler", "ss_config.toml"))
50+
51+
# Current working directory
52+
paths.append(os.path.join(os.getcwd(), "ss_config.toml"))
53+
54+
for path in paths:
55+
if os.path.isfile(path):
56+
try:
57+
with open(path, "rb") as f:
58+
data = tomllib.load(f)
59+
return SSConfig.model_validate(data)
60+
except (tomllib.TOMLDecodeError, ValidationError) as e:
61+
print(f"Warning: ignoring bad config {path}: {e}", file=sys.stderr)
62+
continue
63+
64+
return SSConfig()

src/simplesampler/sequencer/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)