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: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,28 @@
# Test
# Test Repository

This repository contains experimental trading modules and standalone utilities.

## Instruments

### `oscillator3x.py`

Generates a WAV file from three configurable oscillators. Parameters can be
specified either on the command line or via a JSON configuration file.

Example:

```bash
python instruments/oscillator3x.py \
--output demo.wav \
--duration 2.0 \
--osc1 "saw,semitone=0,amplitude=0.5" \
--osc2 "sine,semitone=2,amplitude=0.3" \
--osc3 "square,semitone=-2,amplitude=0.2"
```

A sample configuration is provided at `assets/oscillator3x_config.json` and can
be used with:

```bash
python instruments/oscillator3x.py --config assets/oscillator3x_config.json
```
15 changes: 15 additions & 0 deletions assets/oscillator3x_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"output": "output.wav",
"duration": 3.0,
"base_frequency": 440.0,
"sample_rate": 44100,
"oscillators": [
{"shape": "saw", "semitone": 0, "amplitude": 0.5},
{"shape": "sine", "semitone": 2, "amplitude": 0.3},
{"shape": "square", "semitone": -2, "amplitude": 0.2}
],
"attack": 0.01,
"decay": 0.1,
"sustain": 0.8,
"release": 0.1
}
191 changes: 191 additions & 0 deletions instruments/oscillator3x.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import argparse
import json
import struct
import wave
from dataclasses import dataclass
from typing import Dict, List, Optional

import numpy as np

DEFAULT_SAMPLE_RATE = 44100


def _waveform(
shape: str,
frequency: float,
duration: float,
amplitude: float,
sample_rate: int,
phase: float = 0.0,
) -> np.ndarray:
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
t = (t + phase) % duration
if shape == "sine":
signal = np.sin(2 * np.pi * frequency * t)
elif shape == "square":
signal = np.sign(np.sin(2 * np.pi * frequency * t))
elif shape == "saw":
signal = 2 * (t * frequency - np.floor(0.5 + t * frequency))
elif shape == "triangle":
signal = 2 * np.abs(2 * (t * frequency - np.floor(0.5 + t * frequency))) - 1
elif shape == "noise":
signal = np.random.uniform(-1, 1, t.shape)
else:
raise ValueError(f"Unsupported wave shape: {shape}")
return amplitude * signal


def _adsr_envelope(
total_samples: int,
sample_rate: int,
attack: float,
decay: float,
sustain: float,
release: float,
) -> np.ndarray:
a = int(sample_rate * attack)
d = int(sample_rate * decay)
r = int(sample_rate * release)
s = max(total_samples - a - d - r, 0)
attack_env = np.linspace(0, 1, a, False)
decay_env = np.linspace(1, sustain, d, False)
sustain_env = np.full(s, sustain)
release_env = np.linspace(sustain, 0, r, False)
env = np.concatenate([attack_env, decay_env, sustain_env, release_env])
if len(env) < total_samples:
env = np.pad(env, (0, total_samples - len(env)), constant_values=sustain)
return env[:total_samples]


@dataclass
class OscSpec:
shape: str = "sine"
semitone: float = 0.0
cents: float = 0.0
amplitude: float = 1.0
phase: float = 0.0

def frequency(self, base_freq: float) -> float:
factor = 2 ** (self.semitone / 12.0 + self.cents / 1200.0)
return base_freq * factor


@dataclass
class InstrumentConfig:
output: str = "output.wav"
duration: float = 3.0
base_frequency: float = 440.0
sample_rate: int = DEFAULT_SAMPLE_RATE
oscillators: List[OscSpec] = None
attack: float = 0.01
decay: float = 0.1
sustain: float = 0.8
release: float = 0.1

@staticmethod
def from_dict(data: Dict) -> "InstrumentConfig":
osc_specs = [
OscSpec(**osc) for osc in data.get("oscillators", [])
]
return InstrumentConfig(
output=data.get("output", "output.wav"),
duration=data.get("duration", 3.0),
base_frequency=data.get("base_frequency", 440.0),
sample_rate=data.get("sample_rate", DEFAULT_SAMPLE_RATE),
oscillators=osc_specs,
attack=data.get("attack", 0.01),
decay=data.get("decay", 0.1),
sustain=data.get("sustain", 0.8),
release=data.get("release", 0.1),
)


def generate_3x_osc(cfg: InstrumentConfig) -> None:
if not cfg.oscillators or len(cfg.oscillators) != 3:
raise ValueError("Three oscillator definitions required")

total_samples = int(cfg.sample_rate * cfg.duration)
t = np.linspace(0, cfg.duration, total_samples, endpoint=False)
combined = np.zeros_like(t)

for osc in cfg.oscillators:
freq = osc.frequency(cfg.base_frequency)
combined += _waveform(
osc.shape, freq, cfg.duration, osc.amplitude, cfg.sample_rate, osc.phase
)

env = _adsr_envelope(
total_samples,
cfg.sample_rate,
cfg.attack,
cfg.decay,
cfg.sustain,
cfg.release,
)
combined *= env
max_amp = np.max(np.abs(combined))
if max_amp > 0:
combined /= max_amp

with wave.open(cfg.output, "w") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(cfg.sample_rate)
for sample in combined:
wf.writeframes(struct.pack("<h", int(sample * 32767)))


def _parse_osc(text: str) -> OscSpec:
parts = text.split(",")
kwargs: Dict[str, float] = {}
shape = parts[0]
for part in parts[1:]:
key, _, value = part.partition("=")
kwargs[key] = float(value)
return OscSpec(shape=shape, **kwargs)


def _load_config(path: Optional[str], args: argparse.Namespace) -> InstrumentConfig:
if path:
with open(path, "r") as f:
data = json.load(f)
return InstrumentConfig.from_dict(data)

osc_texts = [args.osc1, args.osc2, args.osc3]
oscillators = [_parse_osc(t) for t in osc_texts if t]
cfg = InstrumentConfig(
output=args.output,
duration=args.duration,
base_frequency=args.frequency,
sample_rate=args.sample_rate,
oscillators=oscillators,
attack=args.attack,
decay=args.decay,
sustain=args.sustain,
release=args.release,
)
return cfg


def main() -> None:
parser = argparse.ArgumentParser(description="3x oscillator generator")
parser.add_argument("--output", default="output.wav")
parser.add_argument("--duration", type=float, default=3.0)
parser.add_argument("--frequency", type=float, default=440.0)
parser.add_argument("--sample-rate", type=int, default=DEFAULT_SAMPLE_RATE)
parser.add_argument("--osc1", default="saw,semitone=0,amplitude=0.5")
parser.add_argument("--osc2", default="sine,semitone=2,amplitude=0.3")
parser.add_argument("--osc3", default="square,semitone=-2,amplitude=0.2")
parser.add_argument("--attack", type=float, default=0.01)
parser.add_argument("--decay", type=float, default=0.1)
parser.add_argument("--sustain", type=float, default=0.8)
parser.add_argument("--release", type=float, default=0.1)
parser.add_argument("--config", help="JSON configuration file")

args = parser.parse_args()
cfg = _load_config(args.config, args)
generate_3x_osc(cfg)


if __name__ == "__main__":
main()
77 changes: 77 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
aiodns==3.4.0
aiohappyeyeballs==2.6.1
aiohttp==3.10.11
aiosignal==1.3.2
anyio==4.9.0
attrs==25.3.0
black==25.1.0
blessed==1.21.0
ccxt==4.4.82
certifi==2025.4.26
cffi==1.17.1
cfgv==3.4.0
charset-normalizer==3.4.2
click==8.1.8
contourpy==1.3.2
cryptography==45.0.2
cycler==0.12.1
distlib==0.3.9
fastapi==0.97.0
filelock==3.18.0
flake8==7.2.0
fonttools==4.58.0
frozenlist==1.6.0
h11==0.16.0
identify==2.6.10
idna==3.10
isort==6.0.1
kiwisolver==1.4.8
loguru==0.7.3
markdown-it-py==3.0.0
matplotlib==3.10.3
mccabe==0.7.0
mdurl==0.1.2
multidict==6.4.4
mypy==1.15.0
mypy_extensions==1.1.0
narwhals==1.40.0
nodeenv==1.9.1
numpy==2.2.6
packaging==25.0
pandas==2.2.3
pathspec==0.12.1
pillow==11.2.1
platformdirs==4.3.8
plotly==6.1.1
pre_commit==4.2.0
propcache==0.3.1
pycares==4.8.0
pycodestyle==2.13.0
pycparser==2.22
pydantic==1.10.15
pyflakes==3.3.2
pygame==2.6.1
Pygments==2.19.1
pyparsing==3.2.3
pyright==1.1.400
python-dateutil==2.9.0.post0
python-dotenv==1.1.0
pytz==2025.2
PyYAML==6.0.2
requests==2.32.3
rich==14.0.0
ruff==0.11.10
seaborn==0.13.2
shellingham==1.5.4
six==1.17.0
sniffio==1.3.1
starlette==0.27.0
typer==0.15.4
typing_extensions==4.13.2
tzdata==2025.2
urllib3==2.4.0
uvicorn==0.27.0.post1
virtualenv==20.31.2
wcwidth==0.2.13
websockets==15.0.1
yarl==1.20.0
38 changes: 38 additions & 0 deletions tests/test_oscillator3x.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import os
import tempfile
import unittest
import wave

from instruments.oscillator3x import (
DEFAULT_SAMPLE_RATE,
InstrumentConfig,
OscSpec,
generate_3x_osc,
)


class Oscillator3xTests(unittest.TestCase):
def test_generate_wav_length(self):
fd, path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
cfg = InstrumentConfig(
output=path,
duration=1.0,
base_frequency=440.0,
sample_rate=DEFAULT_SAMPLE_RATE,
oscillators=[
OscSpec(shape="sine"),
OscSpec(shape="sine", semitone=12, amplitude=0.5),
OscSpec(shape="square", semitone=-12, amplitude=0.5),
],
)
generate_3x_osc(cfg)
with wave.open(path) as wf:
self.assertEqual(wf.getnchannels(), 1)
self.assertEqual(wf.getframerate(), DEFAULT_SAMPLE_RATE)
self.assertEqual(wf.getnframes(), DEFAULT_SAMPLE_RATE)
os.remove(path)


if __name__ == "__main__":
unittest.main()