From 93aae3c0487009aa6dd0c4c3f44de280dac0d4f1 Mon Sep 17 00:00:00 2001 From: wax <50220267+tyranosurasmax@users.noreply.github.com> Date: Wed, 21 May 2025 14:46:09 -0400 Subject: [PATCH] Enhance 3x oscillator instrument --- README.md | 29 ++++- assets/oscillator3x_config.json | 15 +++ instruments/oscillator3x.py | 191 ++++++++++++++++++++++++++++++++ requirements.txt | 77 +++++++++++++ tests/test_oscillator3x.py | 38 +++++++ 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 assets/oscillator3x_config.json create mode 100644 instruments/oscillator3x.py create mode 100644 requirements.txt create mode 100644 tests/test_oscillator3x.py diff --git a/README.md b/README.md index 21e60f8..497923e 100644 --- a/README.md +++ b/README.md @@ -1 +1,28 @@ -# Test \ No newline at end of file +# 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 +``` diff --git a/assets/oscillator3x_config.json b/assets/oscillator3x_config.json new file mode 100644 index 0000000..8534a02 --- /dev/null +++ b/assets/oscillator3x_config.json @@ -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 +} diff --git a/instruments/oscillator3x.py b/instruments/oscillator3x.py new file mode 100644 index 0000000..cfdd96a --- /dev/null +++ b/instruments/oscillator3x.py @@ -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(" 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d35583 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/test_oscillator3x.py b/tests/test_oscillator3x.py new file mode 100644 index 0000000..0e8e0ce --- /dev/null +++ b/tests/test_oscillator3x.py @@ -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()