From db91ffc731951a766f79af9e9cd22f61f96d11f1 Mon Sep 17 00:00:00 2001 From: "Alice V." Date: Thu, 26 Feb 2026 18:25:29 +0000 Subject: [PATCH 1/6] Add alpha field to instrument system (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alpha (0-15, default 15) controls per-tick transparency for future fill column system. Fully backward-compatible — old .btp files default to 15. Includes UI column in instrument editor and "Add row" button at top. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 63 ++++++++++++---------- public/ay-audio-driver.js | 1 + src/lib/chips/ay/AYInstrumentEditor.svelte | 39 ++++++++++++-- src/lib/services/file/file-import.ts | 1 + src/lib/services/file/vt-converter.ts | 9 ++-- 5 files changed, 79 insertions(+), 34 deletions(-) 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/public/ay-audio-driver.js b/public/ay-audio-driver.js index 4df8bf2..18310ee 100644 --- a/public/ay-audio-driver.js +++ b/public/ay-audio-driver.js @@ -483,6 +483,7 @@ class AYAudioDriver { noiseAdd: 0, envelopeAdd: 0, volume: 15, + alpha: 15, amplitudeSliding: false, amplitudeSlideUp: false, toneAccumulation: false, diff --git a/src/lib/chips/ay/AYInstrumentEditor.svelte b/src/lib/chips/ay/AYInstrumentEditor.svelte index 4697f0c..d74a3a4 100644 --- a/src/lib/chips/ay/AYInstrumentEditor.svelte +++ b/src/lib/chips/ay/AYInstrumentEditor.svelte @@ -55,6 +55,7 @@ noiseAdd: 0, envelopeAdd: 0, volume: 0, + alpha: 15, loop: false, amplitudeSliding: false, amplitudeSlideUp: false, @@ -162,6 +163,7 @@ noiseAdd: 0, envelopeAdd: 0, volume: 15, + alpha: 15, loop: false, amplitudeSliding: false, amplitudeSlideUp: false, @@ -240,7 +242,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 +275,7 @@ } if (parsed !== null) { - if (field === 'volume') { + if (field === 'volume' || field === 'alpha') { parsed = Math.max(0, Math.min(15, parsed)); } updateRow(index, field, parsed); @@ -579,6 +581,15 @@ {/if} +
+ +
@@ -706,6 +717,13 @@ class={isExpanded ? 'h-3.5 w-3.5' : 'h-3 w-3'} /> + + {/if} @@ -963,6 +982,18 @@ onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => updateNumericField(index, 'volume', e)} /> + + - - + @@ -771,8 +779,8 @@ class="border border-[var(--color-app-border)] {selected ? ROW_SELECTION_STYLES.cell : 'bg-[var(--color-app-surface-secondary)]'} {isExpanded - ? 'px-1.5' - : 'px-0.5'}"> + ? 'w-20 min-w-20 px-1' + : 'w-16 min-w-16 px-0.5'}">
{ + e.stopPropagation(); + duplicateRow(index); + }} + title="Duplicate this row"> + + {#if index < rows.length - 1} {/if}
diff --git a/src/lib/chips/ay/schema.ts b/src/lib/chips/ay/schema.ts index 5e94845..ac106f6 100644 --- a/src/lib/chips/ay/schema.ts +++ b/src/lib/chips/ay/schema.ts @@ -1,5 +1,9 @@ import type { ChipSchema } from '../base/schema'; -import { PT3TuneTables, generate12TETTuningTable } from '../../models/pt3/tuning-tables'; +import { + PT3TuneTables, + generate12TETTuningTable, + generateNaturalTuningTable +} from '../../models/pt3/tuning-tables'; export const AY_CHIP_SCHEMA: ChipSchema = { chipType: 'ay', @@ -179,22 +183,50 @@ export const AY_CHIP_SCHEMA: ChipSchema = { group: 'chip', notifyAudioService: true, showWhen: { key: 'tuningTableIndex', value: 5 } + }, + { + key: 'naturalRoot', + label: 'Root note', + type: 'select', + options: [ + { label: 'C', value: 0 }, + { label: 'C#', value: 1 }, + { label: 'D', value: 2 }, + { label: 'D#', value: 3 }, + { label: 'E', value: 4 }, + { label: 'F', value: 5 }, + { label: 'F#', value: 6 }, + { label: 'G', value: 7 }, + { label: 'G#', value: 8 }, + { label: 'A', value: 9 }, + { label: 'A#', value: 10 }, + { label: 'B', value: 11 } + ], + defaultValue: 0, + group: 'chip', + notifyAudioService: true, + showWhen: { key: 'tuningTableIndex', value: 4 } } ], resolveTuningTable(song) { const index = Number(song.tuningTableIndex ?? 2); const chipFreq = Number(song.chipFrequency ?? 1773400); const a4 = Math.min(880, Math.max(220, Number(song.a4TuningHz ?? 440))); - return resolveAYTuningTable(index, chipFreq, a4); + const naturalRoot = Number(song.naturalRoot ?? 0); + return resolveAYTuningTable(index, chipFreq, a4, naturalRoot); }, - tuningTableSettingKeys: ['tuningTableIndex', 'a4TuningHz', 'chipFrequency'] + tuningTableSettingKeys: ['tuningTableIndex', 'a4TuningHz', 'chipFrequency', 'naturalRoot'] }; export function resolveAYTuningTable( index: number, chipFrequencyHz: number, - a4TuningHz: number + a4TuningHz: number, + naturalRoot: number = 0 ): number[] { + if (index === 4) { + return generateNaturalTuningTable(naturalRoot); + } if (index >= 0 && index < PT3TuneTables.length) { return [...PT3TuneTables[index]]; } diff --git a/src/lib/components/Instruments/InstrumentsView.svelte b/src/lib/components/Instruments/InstrumentsView.svelte index ccdfa4c..bf09e71 100644 --- a/src/lib/components/Instruments/InstrumentsView.svelte +++ b/src/lib/components/Instruments/InstrumentsView.svelte @@ -64,7 +64,7 @@ ) ); - let asHex = $state(false); + let asHex = $state(localStorage.getItem('instrumentHexMode') === 'true'); let selectedInstrumentIndex = $state(0); let instrumentEditorRef: any = $state(null); let selectedInstrumentRowIndices = $state([]); @@ -165,7 +165,7 @@ { label: 'Hex', icon: hexIcon, - onClick: () => (asHex = !asHex), + onClick: () => { asHex = !asHex; localStorage.setItem('instrumentHexMode', String(asHex)); }, class: asHex ? 'text-[var(--color-app-primary)]' : '' }, { diff --git a/src/lib/components/Song/SongView.svelte b/src/lib/components/Song/SongView.svelte index 3760c10..67c147f 100644 --- a/src/lib/components/Song/SongView.svelte +++ b/src/lib/components/Song/SongView.svelte @@ -56,6 +56,48 @@ let rightPanelActiveTabId = $state('instruments'); let isRightPanelExpanded = $state(false); let selectedColumn = $state(0); + + const RIGHT_PANEL_WIDTH_KEY = 'rightPanelWidth'; + const DEFAULT_PANEL_WIDTH = 512; + const MIN_PANEL_WIDTH = 380; + const MAX_PANEL_WIDTH = 900; + let rightPanelWidth = $state( + Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, + parseInt(localStorage.getItem(RIGHT_PANEL_WIDTH_KEY) ?? '', 10) || DEFAULT_PANEL_WIDTH + )) + ); + let isResizingPanel = $state(false); + let panelResizeStartX = $state(0); + let panelResizeStartWidth = $state(0); + + function startPanelResize(e: MouseEvent) { + isResizingPanel = true; + panelResizeStartX = e.clientX; + panelResizeStartWidth = rightPanelWidth; + e.preventDefault(); + } + + function onPanelResize(e: MouseEvent) { + if (!isResizingPanel) return; + const delta = panelResizeStartX - e.clientX; + rightPanelWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, panelResizeStartWidth + delta)); + } + + function endPanelResize() { + if (!isResizingPanel) return; + isResizingPanel = false; + localStorage.setItem(RIGHT_PANEL_WIDTH_KEY, String(rightPanelWidth)); + } + + $effect(() => { + if (!isResizingPanel) return; + window.addEventListener('mousemove', onPanelResize); + window.addEventListener('mouseup', endPanelResize); + return () => { + window.removeEventListener('mousemove', onPanelResize); + window.removeEventListener('mouseup', endPanelResize); + }; + }); let selectedFieldKey = $state(null); $effect(() => { @@ -555,15 +597,24 @@ role="region" aria-label="Instruments and tables" tabindex={0} - class="relative z-10 flex h-full shrink-0 flex-col border-l border-[var(--color-app-border)] bg-[var(--color-app-surface-secondary)] outline-none transition-all duration-300 focus:outline-none {isRightPanelExpanded - ? 'w-[1200px]' - : 'w-[32rem]'}" + class="relative z-10 flex h-full shrink-0 flex-col border-l border-[var(--color-app-border)] bg-[var(--color-app-surface-secondary)] outline-none focus:outline-none {isRightPanelExpanded + ? 'w-[1200px] transition-all duration-300' + : ''}" + style={isRightPanelExpanded ? '' : `width: ${rightPanelWidth}px`} onmousedown={(e: MouseEvent) => { const target = e.target as HTMLElement; if (!target.closest('input, textarea, button, select, [contenteditable="true"], a')) { rightPanelEl?.focus(); } }}> + {#if !isRightPanelExpanded} +
+ {/if}
{#snippet children(tabId)} diff --git a/src/lib/components/Tables/TableEditor.svelte b/src/lib/components/Tables/TableEditor.svelte index 8a05d9f..356903d 100644 --- a/src/lib/components/Tables/TableEditor.svelte +++ b/src/lib/components/Tables/TableEditor.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 Input from '../Input/Input.svelte'; import RowResizeHandle from '../RowResizeHandle/RowResizeHandle.svelte'; import SelectableRowNumberCell from '../RowEditorTable/SelectableRowNumberCell.svelte'; @@ -233,6 +234,13 @@ updateArraysAfterRowChange(rows.slice(0, rowsToKeep)); } + function duplicateRow(index: number) { + if (rows.length >= MAX_ROWS) return; + const newRows = [...rows]; + newRows.splice(index + 1, 0, rows[index]); + updateArraysAfterRowChange(newRows); + } + function setLoop(index: number) { loopRow = index; updateTable({ loop: loopRow }); @@ -438,6 +446,17 @@
+
+ +
+
{#if loopRow >= 0 && loopRow < rows.length && loopColumnRef && tableRef} @@ -509,6 +528,15 @@ title="Remove this row"> + {#if index < rows.length - 1}
+ α +
+ handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => updateNumericField(index, 'alpha', e)} /> + +
+ Date: Thu, 26 Feb 2026 18:26:56 +0000 Subject: [PATCH 2/6] Add fill column / alpha mask description to README Documents the intent and current state of the alpha field in the instrument system, explaining the upcoming fill column feature. Co-Authored-By: Claude Opus 4.6 --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 32575eb..d7fd22d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ 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. +## Upcoming: Fill Column & Alpha Mask + +The instrument system now includes an **alpha** field (0-15) per instrument row, visible as the **α** column in the instrument editor. This is the foundation for a fill column system where: + +- **Alpha = F (15)**: Fully opaque — the primary instrument plays normally (default, backward-compatible) +- **Alpha < F**: Transparent ticks — during these ticks, a background fill layer can play instead +- This enables layered textures where a lead instrument has "holes" that a secondary fill channel fills in, all controlled per-tick by the instrument definition + +Alpha values are stored in `.btp` files and default to 15 for older files and VT2/PT3 imports. The fill column playback engine (Phase 2) is not yet implemented. + ## Prerequisites - **Node.js** (v18 or higher) From 561fbfe94b3e63dc42dd48d3592c4e78ea5513a6 Mon Sep 17 00:00:00 2001 From: "Alice V." Date: Thu, 26 Feb 2026 21:00:21 +0000 Subject: [PATCH 3/6] Add navigation shortcuts, natural tuning roots, editor UX improvements - Ctrl+Arrow shortcuts for pattern editor: jump channels (L/R), home/end (U/D) - Natural tuning tables for all 12 root notes using just intonation ratios - Duplicate row button in instrument and table editors - Add new row button at top of instrument and table editors - Resizable right panel with localStorage persistence - Persistent Hex mode toggle across browser sessions - Updated README with changelog Co-Authored-By: Claude Opus 4.6 --- README.md | 29 ++++++++- src/lib/chips/ay/AYInstrumentEditor.svelte | 30 ++++++--- src/lib/chips/ay/schema.ts | 40 ++++++++++-- .../Instruments/InstrumentsView.svelte | 4 +- src/lib/components/Song/SongView.svelte | 57 ++++++++++++++++- src/lib/components/Tables/TableEditor.svelte | 28 +++++++++ src/lib/components/Tables/TablesView.svelte | 4 +- src/lib/config/keybindings.ts | 24 +++++++ src/lib/models/pt3/tuning-tables.ts | 22 +++++++ .../pattern/pattern-keyboard-shortcuts.ts | 44 ++++++++++++- .../services/pattern/pattern-navigation.ts | 62 +++++++++++++++++++ 11 files changed, 324 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d7fd22d..f03614a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,34 @@ 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. -## Upcoming: Fill Column & Alpha Mask +## 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) + +### Upcoming: Fill Column & Alpha Mask The instrument system now includes an **alpha** field (0-15) per instrument row, visible as the **α** column in the instrument editor. This is the foundation for a fill column system where: diff --git a/src/lib/chips/ay/AYInstrumentEditor.svelte b/src/lib/chips/ay/AYInstrumentEditor.svelte index d74a3a4..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'; @@ -364,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; @@ -597,7 +605,7 @@
row {isExpanded ? 'loop' : 'lp'}