From f4e6658f4e5405340b88cb74e9d9d958975967ae Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:21 +0700 Subject: [PATCH 01/11] Release 0.0.1: add runtime requirements --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2d8201b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PySide6>=6.7,<7 +numpy>=1.26,<3 +sounddevice>=0.5,<1 From a545503e256dc1cc30f456e5b497e5065397900a Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:24 +0700 Subject: [PATCH 02/11] Release 0.0.1: add development requirements --- requirements-dev.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..99c509f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pyinstaller>=6.10,<7 From a0e116e3618bdddec51914a5a8ecfa3c4c4abcad Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:26 +0700 Subject: [PATCH 03/11] Release 0.0.1: add Windows launcher --- run.bat | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 run.bat diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..1c0f868 --- /dev/null +++ b/run.bat @@ -0,0 +1,14 @@ +@echo off +setlocal +cd /d "%~dp0" +if not exist .venv\Scripts\python.exe ( + echo Creating virtual environment... + py -m venv .venv || python -m venv .venv + call .venv\Scripts\activate.bat + python -m pip install --upgrade pip + pip install -r requirements.txt +) else ( + call .venv\Scripts\activate.bat +) +python app.py +if errorlevel 1 pause From 413d2683ce68d135c09402b22c492835fa4063f4 Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:30 +0700 Subject: [PATCH 04/11] Release 0.0.1: add Unix launcher --- run.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 run.sh diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..4d554ae --- /dev/null +++ b/run.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")" +if [ ! -x .venv/bin/python ]; then + python3 -m venv .venv + . .venv/bin/activate + python -m pip install --upgrade pip + pip install -r requirements.txt +else + . .venv/bin/activate +fi +python app.py From 22987e028451f2ca87eb3f80cbdbfde2eb4e73fa Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:34 +0700 Subject: [PATCH 05/11] Release 0.0.1: add Windows build script --- build_exe.bat | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 build_exe.bat diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 0000000..cdaace2 --- /dev/null +++ b/build_exe.bat @@ -0,0 +1,8 @@ +@echo off +setlocal +cd /d "%~dp0" +if not exist .venv\Scripts\python.exe call run.bat +call .venv\Scripts\activate.bat +pip install -r requirements-dev.txt +pyinstaller --noconfirm --clean --windowed --name TittyTatter --add-data "VERSION;." app.py +pause From 636f32cfca1a6a2292910d7505b2b3585a2c3bf3 Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:38 +0700 Subject: [PATCH 06/11] Release 0.0.1: add core smoke workflow --- .github/workflows/core-smoke.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/core-smoke.yml diff --git a/.github/workflows/core-smoke.yml b/.github/workflows/core-smoke.yml new file mode 100644 index 0000000..649c6d4 --- /dev/null +++ b/.github/workflows/core-smoke.yml @@ -0,0 +1,20 @@ +name: Core smoke + +on: + push: + pull_request: + +jobs: + core-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Compile core modules + run: python -m py_compile model.py presets.py + - name: Run core smoke test + run: python test_core.py From 0d4d928dd3cb8566a18994d6a39b69b5b3213028 Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:40 +0700 Subject: [PATCH 07/11] Release 0.0.1: add rhythm model --- model.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 model.py diff --git a/model.py b/model.py new file mode 100644 index 0000000..cf9333c --- /dev/null +++ b/model.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from presets import OFF, TA, TI + +VALID_STATES = {TI, TA, OFF} + + +@dataclass +class BeatPattern: + subdivision: int = 4 + steps: list[str] = field(default_factory=lambda: [TI, TA, TA, TA]) + + def normalize(self) -> None: + self.subdivision = 3 if int(self.subdivision) == 3 else 4 + cleaned = [s if s in VALID_STATES else OFF for s in self.steps[: self.subdivision]] + while len(cleaned) < self.subdivision: + cleaned.append(OFF) + self.steps = cleaned + + def copy(self) -> "BeatPattern": + return BeatPattern(self.subdivision, list(self.steps)) + + +@dataclass +class BarPattern: + beats: list[BeatPattern] = field(default_factory=lambda: [BeatPattern() for _ in range(4)]) + + def normalize(self) -> None: + self.beats = (self.beats + [BeatPattern() for _ in range(4)])[:4] + for beat in self.beats: + beat.normalize() + + def to_dict(self) -> dict[str, Any]: + self.normalize() + return {"beats": [asdict(b) for b in self.beats]} + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> "BarPattern": + beats: list[BeatPattern] = [] + for item in raw.get("beats", [])[:4]: + beats.append(BeatPattern(int(item.get("subdivision", 4)), list(item.get("steps", [])))) + result = cls(beats) + result.normalize() + return result From 979c3a73ec93baf6306c3f6cf505d281d89d5228 Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:44 +0700 Subject: [PATCH 08/11] Release 0.0.1: add preset library --- presets.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 presets.py diff --git a/presets.py b/presets.py new file mode 100644 index 0000000..de8e1aa --- /dev/null +++ b/presets.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from itertools import product +from typing import Iterable + +TI = "TI" +TA = "TA" +OFF = "OFF" + + +@dataclass(frozen=True) +class CellPreset: + key: str + subdivision: int + steps: tuple[str, ...] + + @property + def family(self) -> str: + return "16" if self.subdivision == 4 else "3" + + @property + def human(self) -> str: + names = {TI: "Ти", TA: "Та", OFF: "·"} + return " ".join(names[s] for s in self.steps) + + @property + def display_name(self) -> str: + prefix = "16-е" if self.subdivision == 4 else "Триоль" + return f"{prefix} • {self.human}" + + +SIXTEENTH_KEYS = [ + "TI-ta-ta-ta", "TI-TI-ta-ta", "TI-TI-TI-ta", "TI-TI-TI-TI", + "ta-TI-ta-ta", "ta-TI-TI-ta", "ta-ta-ta-TI", "TI-ta-TI-ta", + "TI-ta-ta-TI", "TI-ta-TI-TI", "ta-ta-TI-ta", "ta-ta-TI-TI", + "ta-TI-TI-TI", "ta-ta-ta-ta", "TI-TI-ta-TI", "ta-TI-ta-TI", +] +TRIPLET_KEYS = [ + "TI-ta-ta", "TI-TI-ta", "TI-TI-TI", "ta-TI-TI", + "ta-ta-TI", "ta-TI-ta", "TI-ta-TI", "ta-ta-ta", +] + + +def parse_key(key: str) -> tuple[str, ...]: + return tuple(TI if token == "TI" else TA for token in key.split("-")) + + +SIXTEENTH_PRESETS = tuple(CellPreset(k, 4, parse_key(k)) for k in SIXTEENTH_KEYS) +TRIPLET_PRESETS = tuple(CellPreset(k, 3, parse_key(k)) for k in TRIPLET_KEYS) +ALL_PRESETS = SIXTEENTH_PRESETS + TRIPLET_PRESETS +PRESET_BY_KEY = {p.key: p for p in ALL_PRESETS} + + +def presets_for_subdivision(subdivision: int) -> tuple[CellPreset, ...]: + return SIXTEENTH_PRESETS if subdivision == 4 else TRIPLET_PRESETS + + +def preset_from_steps(subdivision: int, steps: Iterable[str]) -> CellPreset | None: + target = tuple(steps) + for preset in presets_for_subdivision(subdivision): + if preset.steps == target: + return preset + return None + + +def all_binary_patterns(subdivision: int) -> list[tuple[str, ...]]: + return list(product((TI, TA), repeat=subdivision)) From 8e3f46560c9742e3563525e3d36e0e5f8142d37a Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:08:48 +0700 Subject: [PATCH 09/11] Release 0.0.1: add core smoke test --- test_core.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 test_core.py diff --git a/test_core.py b/test_core.py new file mode 100644 index 0000000..c2cfa31 --- /dev/null +++ b/test_core.py @@ -0,0 +1,18 @@ +from model import BarPattern, BeatPattern +from presets import ALL_PRESETS, SIXTEENTH_PRESETS, TRIPLET_PRESETS, TI, TA, all_binary_patterns + +assert len(SIXTEENTH_PRESETS) == 16 +assert len(TRIPLET_PRESETS) == 8 +assert len(ALL_PRESETS) == 24 +assert {p.steps for p in SIXTEENTH_PRESETS} == set(all_binary_patterns(4)) +assert {p.steps for p in TRIPLET_PRESETS} == set(all_binary_patterns(3)) + +bar = BarPattern([ + BeatPattern(4, [TI, TA, TI, TA]), + BeatPattern(3, [TA, TI, TA]), + BeatPattern(4, [TA, TA, TI, TI]), + BeatPattern(3, [TI, TI, TA]), +]) +raw = bar.to_dict() +assert BarPattern.from_dict(raw).to_dict() == raw +print("core tests OK") From 41eddecc81c1f4e1e11b06c06b1657c6f3f491e2 Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:09:44 +0700 Subject: [PATCH 10/11] Release 0.0.1: add audio engine --- audio_engine.py | 233 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 audio_engine.py diff --git a/audio_engine.py b/audio_engine.py new file mode 100644 index 0000000..d4066c5 --- /dev/null +++ b/audio_engine.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import copy +import threading +from dataclasses import dataclass, replace +from typing import Any + +import numpy as np +import sounddevice as sd + +from model import BarPattern +from presets import OFF, TA, TI + + +@dataclass(frozen=True) +class EngineConfig: + bpm: float = 80.0 + master_volume: float = 0.85 + ti_enabled: bool = True + ti_sound: str = "Clap" + ti_volume: float = 0.95 + ta_enabled: bool = False + ta_sound: str = "Muted click" + ta_volume: float = 0.58 + metronome_enabled: bool = True + metronome_volume: float = 0.38 + accent_first_beat: bool = True + practice_mode: str = "loop" + bars_per_stage: int = 8 + count_in_bars: int = 1 + inactive_pulse: bool = True + tempo_trainer_enabled: bool = False + tempo_step: int = 2 + tempo_every_bars: int = 4 + tempo_target: int = 140 + + +class AudioEngine: + """Audio-callback rhythm engine; GUI timers never schedule musical events.""" + + def __init__(self, sample_rate: int = 48_000, blocksize: int = 256) -> None: + self.sample_rate = sample_rate + self.blocksize = blocksize + self._lock = threading.RLock() + self._stream: sd.OutputStream | None = None + self._config = EngineConfig() + self._pattern = BarPattern() + self._running = False + self._sample_cursor = 0 + self._next_event_sample = 0.0 + self._beat_index = 0 + self._sub_index = 0 + self._bar_number = 0 + self._stage_index = 0 + self._stage_bar = 0 + self._count_in_beats_left = 0 + self._count_in_total_beats = 0 + self._active_voices: list[tuple[np.ndarray, int]] = [] + self._last_error = "" + self._sounds = self._build_sounds() + + @property + def is_running(self) -> bool: + return self._running + + def set_pattern(self, pattern: BarPattern) -> None: + pattern = copy.deepcopy(pattern) + pattern.normalize() + with self._lock: + self._pattern = pattern + + def set_config(self, **kwargs: Any) -> None: + with self._lock: + valid = {k: v for k, v in kwargs.items() if hasattr(self._config, k)} + if "bpm" in valid: + valid["bpm"] = float(max(20.0, min(320.0, valid["bpm"]))) + self._config = replace(self._config, **valid) + + def get_config(self) -> EngineConfig: + with self._lock: + return self._config + + def start(self) -> None: + if self._running: + return + with self._lock: + self._sample_cursor = 0 + self._next_event_sample = 0.0 + self._beat_index = self._sub_index = self._bar_number = 0 + self._stage_index = self._stage_bar = 0 + self._count_in_total_beats = max(0, int(self._config.count_in_bars)) * 4 + self._count_in_beats_left = self._count_in_total_beats + self._active_voices.clear() + self._last_error = "" + self._stream = sd.OutputStream(samplerate=self.sample_rate, channels=1, dtype="float32", blocksize=self.blocksize, latency="low", callback=self._audio_callback) + self._stream.start() + self._running = True + + def stop(self) -> None: + self._running = False + stream, self._stream = self._stream, None + if stream is not None: + try: + stream.stop(); stream.close() + except Exception: + pass + with self._lock: + self._active_voices.clear() + + def close(self) -> None: + self.stop() + + def status(self) -> dict[str, Any]: + with self._lock: + cfg = self._config + stages = self._stages(cfg.practice_mode) + return {"running": self._running, "bpm": cfg.bpm, "beat": self._beat_index, "sub": self._sub_index, "bar": self._bar_number + 1, "stage_index": self._stage_index, "stage_bar": self._stage_bar + 1, "active_beats": stages[self._stage_index % len(stages)], "count_in": self._count_in_beats_left > 0, "count_in_beat": self._count_in_total_beats - self._count_in_beats_left, "last_error": self._last_error} + + def _audio_callback(self, outdata: np.ndarray, frames: int, _time: Any, status: Any) -> None: + if status: + self._last_error = str(status) + mono = np.zeros(frames, dtype=np.float32) + block_start, block_end = self._sample_cursor, self._sample_cursor + frames + kept: list[tuple[np.ndarray, int]] = [] + for wave, pos in self._active_voices: + n = min(frames, len(wave) - pos) + if n > 0: + mono[:n] += wave[pos:pos+n]; pos += n + if pos < len(wave): + kept.append((wave, pos)) + self._active_voices = kept + safety = 0 + while self._next_event_sample < block_end and safety < 64: + safety += 1 + offset = max(0, int(round(self._next_event_sample - block_start))) + sounds, interval = self._event() + for wave in sounds: + self._mix_sound(mono, offset, wave) + self._next_event_sample += max(1.0, interval) + self._sample_cursor = block_end + mono *= float(self._config.master_volume) + np.clip(mono, -0.98, 0.98, out=mono) + outdata[:, 0] = mono + + def _event(self) -> tuple[list[np.ndarray], float]: + with self._lock: + cfg, pattern = self._config, self._pattern + beat_samples = self.sample_rate * 60.0 / max(20.0, cfg.bpm) + sounds: list[np.ndarray] = [] + if self._count_in_beats_left > 0: + idx = self._count_in_total_beats - self._count_in_beats_left + beat = idx % 4 + if cfg.metronome_enabled: + key = "metro_accent" if beat == 0 and cfg.accent_first_beat else "metro" + sounds.append(self._sounds[key] * float(cfg.metronome_volume)) + self._beat_index, self._sub_index = beat, 0 + self._count_in_beats_left -= 1 + if not self._count_in_beats_left: + self._beat_index = self._sub_index = 0 + return sounds, beat_samples + active_beats = self._stages(cfg.practice_mode)[self._stage_index % len(self._stages(cfg.practice_mode))] + active = self._beat_index < active_beats + beat_def = pattern.beats[self._beat_index] + if active: + n_sub, state = beat_def.subdivision, beat_def.steps[self._sub_index] + else: + n_sub, state = 1, TA if cfg.inactive_pulse else OFF + if self._sub_index == 0 and cfg.metronome_enabled: + key = "metro_accent" if self._beat_index == 0 and cfg.accent_first_beat else "metro" + sounds.append(self._sounds[key] * float(cfg.metronome_volume)) + if state == TI and cfg.ti_enabled: + sounds.append(self._sounds[self._ti_key(cfg.ti_sound)] * float(cfg.ti_volume)) + elif state == TA and cfg.ta_enabled: + sounds.append(self._sounds[self._ta_key(cfg.ta_sound)] * float(cfg.ta_volume)) + interval = beat_samples / n_sub + self._sub_index += 1 + if self._sub_index >= n_sub: + self._sub_index = 0; self._beat_index += 1 + if self._beat_index >= 4: + self._beat_index = 0; self._on_bar_boundary(cfg) + return sounds, interval + + def _on_bar_boundary(self, cfg: EngineConfig) -> None: + self._bar_number += 1; self._stage_bar += 1 + if cfg.tempo_trainer_enabled and cfg.tempo_every_bars > 0 and self._bar_number % cfg.tempo_every_bars == 0: + current, target, step = cfg.bpm, float(cfg.tempo_target), abs(float(cfg.tempo_step)) + new_bpm = min(target, current + step) if current < target else max(target, current - step) if current > target else current + if new_bpm != current: + with self._lock: + self._config = replace(self._config, bpm=new_bpm) + cfg = self._config + stages = self._stages(cfg.practice_mode) + if cfg.practice_mode != "loop" and self._stage_bar >= max(1, cfg.bars_per_stage): + self._stage_bar = 0; self._stage_index = (self._stage_index + 1) % len(stages) + + @staticmethod + def _stages(mode: str) -> tuple[int, ...]: + if mode == "ramp_1_4": return (1, 2, 3, 4) + if mode == "ramp_2_4": return (2, 4) + return (4,) + + def _mix_sound(self, block: np.ndarray, offset: int, wave: np.ndarray) -> None: + if offset >= len(block): + self._active_voices.append((wave, 0)); return + n = min(len(wave), len(block) - offset) + if n > 0: block[offset:offset+n] += wave[:n] + if n < len(wave): self._active_voices.append((wave, n)) + + def _build_sounds(self) -> dict[str, np.ndarray]: + sr = self.sample_rate + rng = np.random.default_rng(20260929) + def env(n: int, decay: float) -> np.ndarray: + return np.exp(-(np.arange(n, dtype=np.float32) / sr) / decay).astype(np.float32) + def tone(freq: float, dur: float, decay: float, amp: float = 1.0) -> np.ndarray: + n = max(1, int(sr * dur)); t = np.arange(n, dtype=np.float32) / sr + return (amp * np.sin(2*np.pi*freq*t) * env(n, decay)).astype(np.float32) + clap_n = int(sr * 0.11); clap = np.zeros(clap_n, dtype=np.float32) + hp = np.diff(rng.normal(0.0, 1.0, clap_n + 1).astype(np.float32)) + for ms, gain, decay in ((0,1.0,0.018),(13,0.72,0.020),(26,0.48,0.024)): + start = int(sr * ms / 1000); n = clap_n - start + if n > 0: clap[start:] += hp[:n] * env(n, decay) * gain + clap /= max(1e-6, np.max(np.abs(clap))); clap *= 0.62 + muted_n = int(sr * 0.045) + muted = rng.normal(0.0,1.0,muted_n).astype(np.float32)*env(muted_n,0.010)*0.18 + tone(180,0.045,0.015,0.45) + return {"ti_clap":clap,"ti_wood":tone(850,0.075,0.025,0.8)+tone(1320,0.075,0.018,0.45),"ti_click":tone(2200,0.025,0.006,0.8),"ti_beep":tone(1050,0.065,0.035,0.65),"ta_muted":muted,"ta_rim":tone(1550,0.03,0.008,0.55)+tone(700,0.03,0.010,0.22),"ta_low":tone(260,0.045,0.012,0.55),"metro":tone(1280,0.026,0.008,0.45),"metro_accent":tone(1900,0.035,0.010,0.66)} + + @staticmethod + def _ti_key(name: str) -> str: + return {"Clap":"ti_clap","Wood":"ti_wood","Click":"ti_click","Beep":"ti_beep"}.get(name,"ti_clap") + + @staticmethod + def _ta_key(name: str) -> str: + return {"Muted click":"ta_muted","Rim":"ta_rim","Low tick":"ta_low"}.get(name,"ta_muted") From 4b1dad4d4f2c3cbe90dc46b661bbf2f65eeead52 Mon Sep 17 00:00:00 2001 From: TheColdVoid1337 Date: Tue, 29 Sep 2026 21:11:14 +0700 Subject: [PATCH 11/11] Release 0.0.1: add application GUI --- app.py | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..1359f6c --- /dev/null +++ b/app.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import json +import random +import sys +import time +from pathlib import Path + +from PySide6.QtCore import QSettings, Qt, QTimer, Signal +from PySide6.QtGui import QCloseEvent, QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QApplication, QCheckBox, QComboBox, QFileDialog, QGridLayout, QGroupBox, + QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPushButton, QSlider, + QSpinBox, QVBoxLayout, QWidget, +) + +from audio_engine import AudioEngine +from model import BarPattern, BeatPattern +from presets import ALL_PRESETS, OFF, TA, TI, CellPreset, presets_for_subdivision + +APP_NAME = "TittyTatter" +VERSION_FILE = Path(__file__).with_name("VERSION") +APP_VERSION = VERSION_FILE.read_text(encoding="utf-8").strip() if VERSION_FILE.exists() else "0.0.1" +STATE_TEXT = {TI: "ТИ", TA: "ТА", OFF: "·"} +STATE_STYLE = { + TI: "background:#3169c6;color:white;font-weight:700;border:2px solid #5f95f2;border-radius:8px;padding:10px;", + TA: "background:#42464f;color:white;font-weight:700;border:2px solid #666b75;border-radius:8px;padding:10px;", + OFF: "background:#22252a;color:#8e949e;border:2px solid #343941;border-radius:8px;padding:10px;", +} + + +class StepButton(QPushButton): + changed = Signal() + def __init__(self) -> None: + super().__init__(); self.state = OFF; self.playing = False; self.setMinimumWidth(54); self.clicked.connect(self.cycle); self.refresh() + def cycle(self) -> None: + order = (TI, TA, OFF); self.set_state(order[(order.index(self.state) + 1) % 3]); self.changed.emit() + def set_state(self, state: str) -> None: + self.state = state if state in (TI, TA, OFF) else OFF; self.refresh() + def set_playing(self, value: bool) -> None: + self.playing = value; self.refresh() + def refresh(self) -> None: + extra = "border:3px solid #f3c969;" if self.playing else ""; self.setText(STATE_TEXT[self.state]); self.setStyleSheet(f"QPushButton{{{STATE_STYLE[self.state]}{extra}}}") + + +class BeatEditor(QGroupBox): + changed = Signal() + def __init__(self, number: int) -> None: + super().__init__(f"Доля {number}"); box = QVBoxLayout(self) + row = QHBoxLayout(); row.addWidget(QLabel("Сетка:")); self.grid = QComboBox(); self.grid.addItem("16-е ×4", 4); self.grid.addItem("Триоль ×3", 3); row.addWidget(self.grid, 1); box.addLayout(row) + row = QHBoxLayout(); self.preset = QComboBox(); self.apply_btn = QPushButton("Применить"); row.addWidget(self.preset, 1); row.addWidget(self.apply_btn); box.addLayout(row) + row = QHBoxLayout(); self.steps = [StepButton() for _ in range(4)] + for b in self.steps: b.changed.connect(self.changed.emit); row.addWidget(b) + box.addLayout(row) + row = QHBoxLayout(); inv = QPushButton("ТИ↔ТА"); clear = QPushButton("Паузы"); row.addWidget(inv); row.addWidget(clear); box.addLayout(row) + self.grid.currentIndexChanged.connect(self._grid_changed); self.apply_btn.clicked.connect(self.apply_preset); inv.clicked.connect(self.invert); clear.clicked.connect(self.clear) + self.set_pattern(BeatPattern(4, [TI, TA, TA, TA])) + def _reload(self) -> None: + self.preset.clear() + for p in presets_for_subdivision(int(self.grid.currentData())): self.preset.addItem(p.human, p) + def _grid_changed(self) -> None: + sub = int(self.grid.currentData()); self._reload() + for i, b in enumerate(self.steps): + b.setVisible(i < sub) + if i >= sub: b.set_state(OFF) + self.changed.emit() + def apply_preset(self) -> None: + p = self.preset.currentData() + if isinstance(p, CellPreset): self.set_pattern(BeatPattern(p.subdivision, list(p.steps))); self.changed.emit() + def invert(self) -> None: + for b in self.steps[:int(self.grid.currentData())]: + if b.state == TI: b.set_state(TA) + elif b.state == TA: b.set_state(TI) + self.changed.emit() + def clear(self) -> None: + for b in self.steps[:int(self.grid.currentData())]: b.set_state(OFF) + self.changed.emit() + def pattern(self) -> BeatPattern: + sub = int(self.grid.currentData()); return BeatPattern(sub, [b.state for b in self.steps[:sub]]) + def set_pattern(self, p: BeatPattern) -> None: + p.normalize(); self.grid.blockSignals(True); self.grid.setCurrentIndex(self.grid.findData(p.subdivision)); self.grid.blockSignals(False); self._reload() + for i, b in enumerate(self.steps): b.setVisible(i < p.subdivision); b.set_state(p.steps[i] if i < p.subdivision else OFF) + for i in range(self.preset.count()): + q = self.preset.itemData(i) + if isinstance(q, CellPreset) and q.steps == tuple(p.steps): self.preset.setCurrentIndex(i); break + def playhead(self, sub: int | None) -> None: + n = int(self.grid.currentData()) + for i, b in enumerate(self.steps): b.set_playing(sub is not None and i == sub and i < n) + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__(); self.setWindowTitle(f"{APP_NAME} {APP_VERSION}"); self.resize(1180, 780); self.settings = QSettings(APP_NAME, APP_NAME); self.engine = AudioEngine(); self.tap_times: list[float] = [] + self._ui(); self._shortcuts(); self._restore(); self._sync(); self.timer = QTimer(self); self.timer.timeout.connect(self._poll); self.timer.start(30) + def _ui(self) -> None: + root = QWidget(); out = QVBoxLayout(root) + row = QHBoxLayout(); self.play = QPushButton("▶ Старт"); self.play.clicked.connect(self.toggle); row.addWidget(self.play); row.addWidget(QLabel("BPM")); self.bpm = QSpinBox(); self.bpm.setRange(20, 320); self.bpm.setValue(80); row.addWidget(self.bpm) + self.bpm_slider = QSlider(Qt.Horizontal); self.bpm_slider.setRange(20, 320); self.bpm_slider.setValue(80); row.addWidget(self.bpm_slider, 1) + for delta in (-5, -1, 1, 5): + b = QPushButton(f"{delta:+d}"); b.clicked.connect(lambda _=False, d=delta: self.bpm.setValue(self.bpm.value()+d)); row.addWidget(b) + self.tap = QPushButton("TAP [T]"); self.tap.clicked.connect(self.tap_tempo); row.addWidget(self.tap); out.addLayout(row) + self.status = QLabel("Готов"); self.status.setAlignment(Qt.AlignCenter); self.status.setStyleSheet("font-size:17px;font-weight:700;padding:6px"); out.addWidget(self.status) + group = QGroupBox("Такт 4/4 — сетка выбирается отдельно для каждой доли"); row = QHBoxLayout(group); self.editors = [BeatEditor(i+1) for i in range(4)] + for e in self.editors: e.changed.connect(self._pattern_changed); row.addWidget(e, 1) + out.addWidget(group) + ab = QGroupBox("Конструктор A / B"); g = QGridLayout(ab); self.a = QComboBox(); self.b = QComboBox() + for p in ALL_PRESETS: self.a.addItem(p.display_name, p); self.b.addItem(p.display_name, p) + g.addWidget(QLabel("A:"),0,0); g.addWidget(self.a,0,1); g.addWidget(QLabel("B:"),1,0); g.addWidget(self.b,1,1) + for col, shape in enumerate(("ABAB","ABBA","AABB")): + btn=QPushButton(shape); btn.clicked.connect(lambda _=False,s=shape:self.apply_ab(s)); g.addWidget(btn,2,col) + ramp=QPushButton("ABAB + разгон 2/4→4/4"); ramp.clicked.connect(self.apply_ab_ramp); g.addWidget(ramp,2,3) + random_btn=QPushButton("Случайный такт"); random_btn.clicked.connect(self.randomize); g.addWidget(random_btn,3,0,1,2) + invert_btn=QPushButton("Инвертировать весь такт"); invert_btn.clicked.connect(lambda:[e.invert() for e in self.editors]); g.addWidget(invert_btn,3,2,1,2); out.addWidget(ab) + settings = QGroupBox("Тренировка / звук"); g = QGridLayout(settings) + self.mode=QComboBox(); self.mode.addItem("Петля 4/4","loop"); self.mode.addItem("Разгон 1/4→2/4→3/4→4/4","ramp_1_4"); self.mode.addItem("Разгон 2/4→4/4","ramp_2_4") + self.bars=QSpinBox(); self.bars.setRange(1,64); self.bars.setValue(8); self.count=QSpinBox(); self.count.setRange(0,8); self.count.setValue(1); self.inactive=QCheckBox("ТА-пульс на незаполненных долях"); self.inactive.setChecked(True) + g.addWidget(QLabel("Режим"),0,0); g.addWidget(self.mode,0,1); g.addWidget(QLabel("Тактов/этап"),0,2); g.addWidget(self.bars,0,3); g.addWidget(QLabel("Count-in"),0,4); g.addWidget(self.count,0,5); g.addWidget(self.inactive,1,0,1,3) + self.tempo_train=QCheckBox("Tempo trainer"); self.tempo_step=QSpinBox(); self.tempo_step.setRange(1,20); self.tempo_step.setValue(2); self.tempo_every=QSpinBox(); self.tempo_every.setRange(1,64); self.tempo_every.setValue(4); self.tempo_target=QSpinBox(); self.tempo_target.setRange(20,320); self.tempo_target.setValue(140) + g.addWidget(self.tempo_train,1,3); g.addWidget(QLabel("± BPM"),1,4); g.addWidget(self.tempo_step,1,5); g.addWidget(QLabel("каждые такты"),2,3); g.addWidget(self.tempo_every,2,4); g.addWidget(QLabel("цель"),2,5); g.addWidget(self.tempo_target,2,6) + self.ti_on=QCheckBox("ТИ"); self.ti_on.setChecked(True); self.ti_sound=QComboBox(); self.ti_sound.addItems(["Clap","Wood","Click","Beep"]); self.ti_vol=QSlider(Qt.Horizontal); self.ti_vol.setRange(0,100); self.ti_vol.setValue(95) + self.ta_on=QCheckBox("ТА"); self.ta_sound=QComboBox(); self.ta_sound.addItems(["Muted click","Rim","Low tick"]); self.ta_vol=QSlider(Qt.Horizontal); self.ta_vol.setRange(0,100); self.ta_vol.setValue(58) + self.metro_on=QCheckBox("Метроном"); self.metro_on.setChecked(True); self.accent=QCheckBox("Акцент 1"); self.accent.setChecked(True); self.metro_vol=QSlider(Qt.Horizontal); self.metro_vol.setRange(0,100); self.metro_vol.setValue(38); self.master=QSlider(Qt.Horizontal); self.master.setRange(0,100); self.master.setValue(85) + g.addWidget(self.ti_on,3,0); g.addWidget(self.ti_sound,3,1); g.addWidget(self.ti_vol,3,2); g.addWidget(self.ta_on,3,3); g.addWidget(self.ta_sound,3,4); g.addWidget(self.ta_vol,3,5); g.addWidget(self.metro_on,4,0); g.addWidget(self.accent,4,1); g.addWidget(self.metro_vol,4,2); g.addWidget(QLabel("Master"),4,3); g.addWidget(self.master,4,4,1,2); out.addWidget(settings) + row=QHBoxLayout(); save=QPushButton("Сохранить сессию"); load=QPushButton("Загрузить сессию"); reset=QPushButton("Сброс"); save.clicked.connect(self.save_session); load.clicked.connect(self.load_session); reset.clicked.connect(self.reset); row.addWidget(save); row.addWidget(load); row.addWidget(reset); row.addStretch(); out.addLayout(row); self.setCentralWidget(root) + self.bpm.valueChanged.connect(self._bpm_from_spin); self.bpm_slider.valueChanged.connect(self._bpm_from_slider) + for w in (self.mode,self.bars,self.count,self.inactive,self.tempo_train,self.tempo_step,self.tempo_every,self.tempo_target,self.ti_on,self.ti_sound,self.ti_vol,self.ta_on,self.ta_sound,self.ta_vol,self.metro_on,self.accent,self.metro_vol,self.master): + sig = getattr(w,"valueChanged",None) or getattr(w,"toggled",None) or getattr(w,"currentIndexChanged",None) + if sig: sig.connect(self._config_changed) + def _shortcuts(self) -> None: + QShortcut(QKeySequence("Space"),self,activated=self.toggle); QShortcut(QKeySequence("T"),self,activated=self.tap_tempo); QShortcut(QKeySequence("Up"),self,activated=lambda:self.bpm.setValue(self.bpm.value()+1)); QShortcut(QKeySequence("Down"),self,activated=lambda:self.bpm.setValue(self.bpm.value()-1)); QShortcut(QKeySequence("Shift+Up"),self,activated=lambda:self.bpm.setValue(self.bpm.value()+5)); QShortcut(QKeySequence("Shift+Down"),self,activated=lambda:self.bpm.setValue(self.bpm.value()-5)) + def current_pattern(self) -> BarPattern: return BarPattern([e.pattern() for e in self.editors]) + def _pattern_changed(self) -> None: self.engine.set_pattern(self.current_pattern()) + def _config_changed(self,*_) -> None: + self.engine.set_config(bpm=self.bpm.value(),practice_mode=self.mode.currentData(),bars_per_stage=self.bars.value(),count_in_bars=self.count.value(),inactive_pulse=self.inactive.isChecked(),tempo_trainer_enabled=self.tempo_train.isChecked(),tempo_step=self.tempo_step.value(),tempo_every_bars=self.tempo_every.value(),tempo_target=self.tempo_target.value(),ti_enabled=self.ti_on.isChecked(),ti_sound=self.ti_sound.currentText(),ti_volume=self.ti_vol.value()/100,ta_enabled=self.ta_on.isChecked(),ta_sound=self.ta_sound.currentText(),ta_volume=self.ta_vol.value()/100,metronome_enabled=self.metro_on.isChecked(),accent_first_beat=self.accent.isChecked(),metronome_volume=self.metro_vol.value()/100,master_volume=self.master.value()/100) + def _sync(self) -> None: self._pattern_changed(); self._config_changed() + def _bpm_from_spin(self,v:int) -> None: self.bpm_slider.blockSignals(True); self.bpm_slider.setValue(v); self.bpm_slider.blockSignals(False); self.engine.set_config(bpm=v) + def _bpm_from_slider(self,v:int) -> None: self.bpm.blockSignals(True); self.bpm.setValue(v); self.bpm.blockSignals(False); self.engine.set_config(bpm=v) + def toggle(self) -> None: + try: + if self.engine.is_running: self.engine.stop(); self.play.setText("▶ Старт"); self.clear_playhead() + else: self._sync(); self.engine.start(); self.play.setText("■ Стоп") + except Exception as exc: + self.engine.stop(); self.play.setText("▶ Старт"); QMessageBox.critical(self,"Ошибка аудио",str(exc)) + def tap_tempo(self) -> None: + now=time.perf_counter() + if self.tap_times and now-self.tap_times[-1]>2.5:self.tap_times=[] + self.tap_times.append(now); self.tap_times=self.tap_times[-7:] + if len(self.tap_times)>1: + ints=[b-a for a,b in zip(self.tap_times,self.tap_times[1:])] + if len(ints)>=4: ints=sorted(ints)[1:-1] + self.bpm.setValue(round(60/(sum(ints)/len(ints)))) + def apply_ab(self,shape:str) -> None: + a,b=self.a.currentData(),self.b.currentData() + if not isinstance(a,CellPreset) or not isinstance(b,CellPreset): return + for e,t in zip(self.editors,shape): + p=a if t=="A" else b; e.set_pattern(BeatPattern(p.subdivision,list(p.steps))) + self._pattern_changed() + def apply_ab_ramp(self) -> None: self.apply_ab("ABAB"); self.mode.setCurrentIndex(self.mode.findData("ramp_2_4")); self._config_changed() + def randomize(self) -> None: + for e in self.editors: + p=random.choice(ALL_PRESETS); e.set_pattern(BeatPattern(p.subdivision,list(p.steps))) + self._pattern_changed() + def _poll(self) -> None: + st=self.engine.status() + if not st["running"]: return + bpm=round(st["bpm"]) + if bpm!=self.bpm.value(): self.bpm.setValue(bpm) + if st["count_in"]: self.status.setText(f"COUNT-IN · {bpm} BPM"); self.highlight(st["beat"],None); return + stage="4/4" if self.mode.currentData()=="loop" else f"{st['active_beats']}/4"; self.status.setText(f"Такт {st['bar']} · этап {stage} · доля {st['beat']+1} · {bpm} BPM"); self.highlight(st["beat"],st["sub"]) + def highlight(self,beat:int,sub:int|None) -> None: + for i,e in enumerate(self.editors): e.playhead(sub if i==beat else None) + def clear_playhead(self) -> None: + for e in self.editors:e.playhead(None) + def session(self) -> dict: + return {"version":1,"bpm":self.bpm.value(),"pattern":self.current_pattern().to_dict(),"practice":{"mode":self.mode.currentData(),"bars":self.bars.value(),"count":self.count.value(),"inactive":self.inactive.isChecked(),"trainer":self.tempo_train.isChecked(),"step":self.tempo_step.value(),"every":self.tempo_every.value(),"target":self.tempo_target.value()},"sound":{"ti_on":self.ti_on.isChecked(),"ti_sound":self.ti_sound.currentText(),"ti_vol":self.ti_vol.value(),"ta_on":self.ta_on.isChecked(),"ta_sound":self.ta_sound.currentText(),"ta_vol":self.ta_vol.value(),"metro_on":self.metro_on.isChecked(),"accent":self.accent.isChecked(),"metro_vol":self.metro_vol.value(),"master":self.master.value()}} + def save_session(self) -> None: + path,_=QFileDialog.getSaveFileName(self,"Сохранить","tittytatter_session.json","JSON (*.json)") + if path: Path(path).write_text(json.dumps(self.session(),ensure_ascii=False,indent=2),encoding="utf-8") + def load_session(self) -> None: + path,_=QFileDialog.getOpenFileName(self,"Загрузить","","JSON (*.json)") + if not path:return + try:self.apply_session(json.loads(Path(path).read_text(encoding="utf-8"))) + except Exception as exc: QMessageBox.warning(self,"Ошибка",str(exc)) + def apply_session(self,d:dict) -> None: + self.bpm.setValue(int(d.get("bpm",80))); bar=BarPattern.from_dict(d.get("pattern",{})) + for e,p in zip(self.editors,bar.beats):e.set_pattern(p) + p=d.get("practice",{}); self.mode.setCurrentIndex(max(0,self.mode.findData(p.get("mode","loop")))); self.bars.setValue(int(p.get("bars",8))); self.count.setValue(int(p.get("count",1))); self.inactive.setChecked(bool(p.get("inactive",True))); self.tempo_train.setChecked(bool(p.get("trainer",False))); self.tempo_step.setValue(int(p.get("step",2))); self.tempo_every.setValue(int(p.get("every",4))); self.tempo_target.setValue(int(p.get("target",140))) + s=d.get("sound",{}); self.ti_on.setChecked(bool(s.get("ti_on",True))); self._combo(self.ti_sound,s.get("ti_sound","Clap")); self.ti_vol.setValue(int(s.get("ti_vol",95))); self.ta_on.setChecked(bool(s.get("ta_on",False))); self._combo(self.ta_sound,s.get("ta_sound","Muted click")); self.ta_vol.setValue(int(s.get("ta_vol",58))); self.metro_on.setChecked(bool(s.get("metro_on",True))); self.accent.setChecked(bool(s.get("accent",True))); self.metro_vol.setValue(int(s.get("metro_vol",38))); self.master.setValue(int(s.get("master",85))); self._sync() + @staticmethod + def _combo(c:QComboBox,text:str) -> None: + i=c.findText(str(text)) + if i>=0:c.setCurrentIndex(i) + def reset(self) -> None: + self.engine.stop(); self.bpm.setValue(80) + for e in self.editors:e.set_pattern(BeatPattern(4,[TI,TA,TA,TA])) + self.mode.setCurrentIndex(0); self.bars.setValue(8); self.count.setValue(1); self.inactive.setChecked(True); self.tempo_train.setChecked(False); self.ti_on.setChecked(True); self.ta_on.setChecked(False); self.metro_on.setChecked(True); self._sync() + def _restore(self) -> None: + g=self.settings.value("geometry") + if g:self.restoreGeometry(g) + self.bpm.setValue(int(self.settings.value("bpm",80))) + def closeEvent(self,event:QCloseEvent) -> None: + self.settings.setValue("geometry",self.saveGeometry()); self.settings.setValue("bpm",self.bpm.value()); self.engine.close(); event.accept() + + +def main() -> int: + app=QApplication(sys.argv); app.setApplicationName(APP_NAME); app.setStyle("Fusion"); w=MainWindow(); w.show(); return app.exec() + + +if __name__=="__main__": raise SystemExit(main())