diff --git a/CLAUDE.md b/CLAUDE.md
index 4814c9a..f2bb376 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,39 +1,48 @@
-Bitphase is a chiptune tracker for creating music on retro sound chips. Built with Svelte 5, TypeScript, Vite, and Tailwind 4.
+# CLAUDE.md
-## Architecture
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-### Directory Structure
+## Overview
-- `src/lib/chips/` - Chip implementations (currently AY-8910). Each chip has: `schema.ts` (channel/field definitions), `adapter.ts` (data manipulation), `renderer.ts` (pattern display), `types.ts`
-- `src/lib/models/` - Domain models: `project.ts`, `song.ts`, `project-fields.ts`
-- `src/lib/services/` - Business logic: `audio/`, `file/` (import/export), `pattern/` (editing, navigation, clipboard), `project/`, `modal/`
-- `src/lib/stores/` - Reactive state using `.svelte.ts` files with `$state` rune
-- `src/lib/ui-rendering/` - Canvas-based renderers for pattern editor and order list
-- `src/lib/components/` - UI components organized by feature (Menu, Song, Tables, Instruments, Modal, etc.)
-- `src/lib/config/` - App configuration (menu definitions, settings)
-- `tests/` - Test files mirroring src structure
+Bitphase is a chiptune tracker for creating music on retro sound chips. Built with Svelte 5, TypeScript, Vite, and Tailwind 4. Currently supports AY-3-8910/YM2149F chip, with architecture designed for future chip support.
-### Key Patterns
+## Commands
-- **Chip abstraction**: Never hardcode chip-specific features. Use `src/lib/chips/base/schema.ts` for generic definitions. Chips implement adapters and renderers extending base classes.
-- **State management**: Use Svelte 5 runes (`$state`, `$derived`, `$effect`) in `.svelte.ts` files. Do not use writable stores.
-- **Pattern editing**: Field-based editing system in `src/lib/services/pattern/editing/` with strategies per field type.
+- `pnpm dev` — Start dev server (builds WASM first)
+- `pnpm build` — Production build
+- `pnpm check` — TypeScript and Svelte type checking (run this to catch lint errors)
+- `pnpm test` — Run tests in watch mode (vitest)
+- `pnpm test:run` — Run tests once
+- `pnpm vitest run tests/path/to/file.test.ts` — Run a single test file
+- `pnpm build:wasm` — Rebuild WASM only (requires Emscripten SDK with `emcc` in PATH)
-### External Dependencies
+## Architecture
-- `external/ayumi/` - AY-8910 emulator C code, compiled to WASM via Emscripten
-- WASM output goes to `public/ayumi.wasm`
+### Directory Structure
-### Path Alias
+- `src/lib/chips/` — Chip implementations. Each chip has: `schema.ts`, `adapter.ts`, `renderer.ts`, `types.ts`
+ - `base/` — Base interfaces and generic definitions
+ - `ay/` — AY-3-8910 implementation
+- `src/lib/models/` — Domain models: `project.ts`, `song.ts`, `project-fields.ts`
+- `src/lib/services/` — Business logic: `audio/`, `file/` (import/export), `pattern/` (editing, navigation, clipboard), `project/`, `modal/`
+- `src/lib/stores/` — Reactive state using `.svelte.ts` files with `$state` rune
+- `src/lib/ui-rendering/` — Canvas-based renderers for pattern editor and order list
+- `src/lib/components/` — UI components organized by feature
+- `src/lib/config/` — App configuration (menu definitions, settings)
+- `tests/` — Test files mirroring `src/` structure
+- `external/ayumi/` — AY-8910 emulator C code, compiled to WASM → `public/ayumi.wasm`
-`@` maps to `./src` (configured in vite and vitest)
+### Key Patterns
-- Svelte 5 syntax only (runes, `onclick` not `on:click`)
-- No comments in code - write self-documenting code
-- Tailwind 4 for styling
-- Follow KISS, DRY, SOLID principles. Keep very good OOP practices.
-- Tests go in `tests/` directory mirroring `src/` structure
+- **Chip abstraction**: Never hardcode chip-specific features in generic code. Use `src/lib/chips/base/schema.ts` for generic definitions. Chips implement adapters and renderers extending base classes. AY-specific code must stay in `src/lib/chips/ay/`.
+- **State management**: Svelte 5 runes (`$state`, `$derived`, `$effect`) in `.svelte.ts` files. Do not use writable stores.
+- **Pattern editing**: Field-based editing system in `src/lib/services/pattern/editing/` with strategies per field type.
+- **Path alias**: `@` maps to `./src` (configured in both vite and vitest).
-Currently bitphase supports only AY-8910/YM2149F chip, but architecture needs to support other chips in the future. Therefore, it is important to keep the code modular and easy to extend. Using AY specific code in generic parts is not allowed, because when we get to the point of adding another chip, we will have to rewrite a lot of code.
+## Code Style
-Please never allow lint errors. If you see any, fix them. We had lots of production bugs because of lint errors.
+- Svelte 5 syntax only — runes, `onclick` not `on:click`, `{#snippet}` not slots
+- No comments in code — write self-documenting code
+- Tailwind 4 for styling
+- Follow KISS, DRY, SOLID principles with good OOP practices
+- Never allow lint errors — run `pnpm check` to verify. We had production bugs from lint errors.
diff --git a/README.md b/README.md
index 32575eb..2a2a830 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,54 @@
A modern web-based chiptune tracker designed for creating music on retro sound chips. Currently supports the AY-3-8910 / YM2149F chip (used in ZX Spectrum and other 8-bit computers), with plans to support additional chips in the future.
+## Changelog
+
+### Pattern Editor Navigation
+- **Ctrl+Left / Ctrl+Right** — Jump between channels
+- **Ctrl+Up / Ctrl+Down** — Jump to first / last row (Home / End)
+
+### Natural Tuning Tables
+- Natural (just intonation) tuning now supports **all 12 root notes** (C through B)
+- Root note selector appears when Natural tuning table is selected
+- Uses just intonation ratios: 1, 16/15, 9/8, 6/5, 5/4, 4/3, 64/45, 3/2, 8/5, 5/3, 16/9, 15/8
+
+### Instrument Editor
+- **Duplicate row** button (copy icon) per row
+- **Add new row** button at the top of the table
+- Row actions always visible: delete, duplicate, delete-below
+
+### Table Editor (Arpeggios)
+- **Duplicate row** button (copy icon) per row
+- **Add new row** button at the top of the table
+
+### Resizable Right Panel
+- Drag the left edge of the right panel to resize
+- Panel width persisted across sessions (localStorage)
+
+### Persistent Settings
+- **Hex mode** toggle persisted across browser sessions (shared between Instruments and Tables views)
+
+### Alpha Mask (Virtual Channel Gating)
+
+Each instrument row has an **alpha** field (0–F), visible as the **α** column in the instrument editor. Alpha controls **virtual channel priority** — it determines which virtual channel gets to play on a shared hardware channel.
+
+**How it works:**
+
+When multiple virtual channels (e.g. A, A', A'') share one hardware channel, alpha on the **primary** (leftmost) channel acts as a gate:
+
+- **Alpha = F (15)**: Fully opaque — primary channel always wins, even if silent. This is the default, so existing songs are unaffected.
+- **Alpha = 0**: Fully transparent — any active underlying channel punches through.
+- **Alpha 1–E**: Threshold gate — an underlying channel wins only if its alpha **exceeds** the primary's alpha.
+- Among qualifying underlying channels, **leftmost** (highest priority) wins.
+- If no underlying channel qualifies, the primary still plays.
+
+**Key detail:** Alpha is the **sole gating mechanism**. A silent primary with alpha=F produces opaque silence (blocks underlying channels). A silent primary with alpha=0 is transparent (lets underlying channels through). Volume and mixer state do not affect priority.
+
+**Gating instrument pattern:**
+Create a looping instrument on the primary channel with volume=0 throughout. Set alpha=F on ticks where you want silence, and alpha=0 on ticks where underlying channels should play. This creates rhythmic punch-through patterns without the primary producing any sound.
+
+Alpha values are stored in `.btp` files and default to 15 for older files and VT2/PT3 imports. Single-channel playback (no virtual channels) is unaffected by alpha.
+
## Prerequisites
- **Node.js** (v18 or higher)
diff --git a/public/ay-audio-driver.js b/public/ay-audio-driver.js
index 4df8bf2..7c5ed2d 100644
--- a/public/ay-audio-driver.js
+++ b/public/ay-audio-driver.js
@@ -455,6 +455,7 @@ class AYAudioDriver {
this.channelMixerState[channelIndex].noise = false;
this.channelMixerState[channelIndex].envelope = false;
state.channelEnvelopeEnabled[channelIndex] = false;
+ state.channelCurrentAlpha[channelIndex] = 15;
continue;
}
@@ -470,6 +471,7 @@ class AYAudioDriver {
this.channelMixerState[channelIndex].noise = false;
this.channelMixerState[channelIndex].envelope = false;
state.channelEnvelopeEnabled[channelIndex] = false;
+ state.channelCurrentAlpha[channelIndex] = 15;
continue;
}
@@ -483,6 +485,7 @@ class AYAudioDriver {
noiseAdd: 0,
envelopeAdd: 0,
volume: 15,
+ alpha: 15,
amplitudeSliding: false,
amplitudeSlideUp: false,
toneAccumulation: false,
@@ -501,6 +504,7 @@ class AYAudioDriver {
this.channelMixerState[channelIndex].tone = false;
this.channelMixerState[channelIndex].noise = false;
this.channelMixerState[channelIndex].envelope = false;
+ state.channelCurrentAlpha[channelIndex] = 15;
state.instrumentPositions[channelIndex]++;
if (state.instrumentPositions[channelIndex] >= effectiveRowsLength) {
if (effectiveLoop > 0 && effectiveLoop < effectiveRowsLength) {
@@ -561,6 +565,8 @@ class AYAudioDriver {
state.channelInstrumentVolumes[channelIndex] = instrumentRow.volume;
}
+ state.channelCurrentAlpha[channelIndex] = instrumentRow.alpha ?? 15;
+
if (instrumentRow.amplitudeSliding) {
if (instrumentRow.amplitudeSlideUp) {
if (state.channelAmplitudeSliding[channelIndex] < 15) {
diff --git a/public/ayumi-state.js b/public/ayumi-state.js
index 66db46e..84bf85f 100644
--- a/public/ayumi-state.js
+++ b/public/ayumi-state.js
@@ -11,7 +11,8 @@ const AY_CHANNEL_ARRAY_SPECS = [
['channelAmplitudeSliding', 0],
['channelEnvelopeEnabled', false],
['channelMuted', false],
- ['channelSoundEnabled', false]
+ ['channelSoundEnabled', false],
+ ['channelCurrentAlpha', 15]
];
class AyumiState extends TrackerState {
diff --git a/public/virtual-channel-mixer.js b/public/virtual-channel-mixer.js
index e7f43da..29a5baa 100644
--- a/public/virtual-channel-mixer.js
+++ b/public/virtual-channel-mixer.js
@@ -60,15 +60,24 @@ class VirtualChannelMixer {
}
let selectedVch = -1;
- for (const vch of virtualIndices) {
- if (this._isChannelActive(vch, virtualRegisterState, state)) {
- selectedVch = vch;
- break;
- }
- }
+ const primaryVch = virtualIndices[0];
+ const primaryAlpha = state.channelCurrentAlpha?.[primaryVch] ?? 15;
- if (selectedVch === -1) {
- selectedVch = virtualIndices[virtualIndices.length - 1];
+ if (primaryAlpha >= 15) {
+ selectedVch = primaryVch;
+ } else {
+ for (let i = 1; i < virtualIndices.length; i++) {
+ const vch = virtualIndices[i];
+ if (!this._isChannelActive(vch, virtualRegisterState, state)) continue;
+ const underlyingAlpha = state.channelCurrentAlpha?.[vch] ?? 15;
+ if (underlyingAlpha > primaryAlpha) {
+ selectedVch = vch;
+ break;
+ }
+ }
+ if (selectedVch === -1) {
+ selectedVch = primaryVch;
+ }
}
this._copyChannel(virtualRegisterState, selectedVch, this.hardwareRegisterState, hwCh);
diff --git a/src/lib/chips/ay/AYInstrumentEditor.svelte b/src/lib/chips/ay/AYInstrumentEditor.svelte
index 4697f0c..47f1c9d 100644
--- a/src/lib/chips/ay/AYInstrumentEditor.svelte
+++ b/src/lib/chips/ay/AYInstrumentEditor.svelte
@@ -3,6 +3,7 @@
import IconCarbonTrashCan from '~icons/carbon/trash-can';
import IconCarbonDelete from '~icons/carbon/delete';
import IconCarbonAdd from '~icons/carbon/add';
+ import IconCarbonCopy from '~icons/carbon/copy';
import IconCarbonVolumeUp from '~icons/carbon/volume-up';
import IconCarbonArrowsVertical from '~icons/carbon/arrows-vertical';
import IconCarbonChartWinLoss from '~icons/carbon/chart-win-loss';
@@ -55,6 +56,7 @@
noiseAdd: 0,
envelopeAdd: 0,
volume: 0,
+ alpha: 15,
loop: false,
amplitudeSliding: false,
amplitudeSlideUp: false,
@@ -162,6 +164,7 @@
noiseAdd: 0,
envelopeAdd: 0,
volume: 15,
+ alpha: 15,
loop: false,
amplitudeSliding: false,
amplitudeSlideUp: false,
@@ -240,7 +243,7 @@
const allowedPattern = asHex ? /[^0-9a-fA-F-]/g : /[^0-9-]/g;
text = text.replace(/\+/g, '').replace(allowedPattern, '');
- if (field === 'volume') {
+ if (field === 'volume' || field === 'alpha') {
if (asHex) {
if (text.length > 1) {
text = text.substring(0, 1);
@@ -273,7 +276,7 @@
}
if (parsed !== null) {
- if (field === 'volume') {
+ if (field === 'volume' || field === 'alpha') {
parsed = Math.max(0, Math.min(15, parsed));
}
updateRow(index, field, parsed);
@@ -362,6 +365,13 @@
updateArraysAfterRowChange([...rows, { ...EMPTY_ROW }]);
}
+ function duplicateRow(index: number) {
+ if (rows.length >= MAX_ROWS) return;
+ const newRows = [...rows];
+ newRows.splice(index + 1, 0, { ...rows[index] });
+ updateArraysAfterRowChange(newRows);
+ }
+
function setRowCount(targetCount: number) {
const count = Math.max(1, Math.min(MAX_ROWS, targetCount));
if (count === rows.length) return;
@@ -579,6 +589,15 @@
{/if}
+