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
63 changes: 36 additions & 27 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why removing this rule for Claude? ^^ is it covered by other new rules perhaps?

## 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.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a case of file that worked fine before these changes but now doesn't work properly despite all instruments getting 0xF alpha by default. Gonna send you a dm, because our beloved .btp isn't supported on github 👍

- **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)
Expand Down
6 changes: 6 additions & 0 deletions public/ay-audio-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -483,6 +485,7 @@ class AYAudioDriver {
noiseAdd: 0,
envelopeAdd: 0,
volume: 15,
alpha: 15,
amplitudeSliding: false,
amplitudeSlideUp: false,
toneAccumulation: false,
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion public/ayumi-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 17 additions & 8 deletions public/virtual-channel-mixer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
69 changes: 58 additions & 11 deletions src/lib/chips/ay/AYInstrumentEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,6 +56,7 @@
noiseAdd: 0,
envelopeAdd: 0,
volume: 0,
alpha: 15,
loop: false,
amplitudeSliding: false,
amplitudeSlideUp: false,
Expand Down Expand Up @@ -162,6 +164,7 @@
noiseAdd: 0,
envelopeAdd: 0,
volume: 15,
alpha: 15,
loop: false,
amplitudeSliding: false,
amplitudeSlideUp: false,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -579,14 +589,23 @@
</div>
</div>
{/if}
<div class="flex items-center justify-center py-1">
<button
class="flex cursor-pointer items-center justify-center rounded p-0.5 text-[var(--color-app-text-muted)] transition-colors hover:bg-[var(--color-app-surface-hover)] hover:text-green-400"
onclick={addRow}
title="Add new row">
<IconCarbonAdd class="mr-1 h-3.5 w-3.5" />
<span class="mr-1 text-xs">Add new row</span>
</button>
</div>
<table
bind:this={tableRef}
class="row-editor-table table-fixed border-collapse bg-[var(--color-app-surface)] font-mono text-xs select-none">
<thead>
<tr>
<th class={isExpanded ? 'w-14 min-w-14 px-2 py-1.5' : 'px-1 py-1'}
>row</th>
<th class={isExpanded ? 'w-8 px-1.5' : 'w-6 px-0.5'}></th>
<th class={isExpanded ? 'w-20 min-w-20 px-1' : 'w-16 min-w-16 px-0.5'}></th>
<th
class={isExpanded ? 'w-6 px-1.5' : 'w-4 px-0.5'}
bind:this={loopColumnRef}>{isExpanded ? 'loop' : 'lp'}</th>
Expand Down Expand Up @@ -706,6 +725,13 @@
class={isExpanded ? 'h-3.5 w-3.5' : 'h-3 w-3'} />
</div>
</th>
<th
class={isExpanded
? 'w-14 min-w-14 px-1'
: 'w-14 px-0.5 text-[0.65rem]'}
title="Alpha (transparency for fill layer)">
<span class={isExpanded ? 'text-xs' : 'text-[0.65rem]'}>α</span>
</th>
<th
class={isExpanded
? 'w-8 min-w-8 px-1'
Expand Down Expand Up @@ -735,6 +761,7 @@
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
{/if}
</thead>
Expand All @@ -752,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'}">
<div
class="flex items-center justify-center {isExpanded
? 'gap-1'
Expand All @@ -768,18 +795,26 @@
<IconCarbonTrashCan
class={isExpanded ? 'h-3.5 w-3.5' : 'h-3 w-3'} />
</button>
<button
class="flex cursor-pointer items-center justify-center rounded p-0.5 text-[var(--color-app-text-muted)] transition-colors hover:bg-[var(--color-app-surface-hover)] hover:text-blue-400"
onclick={(e) => {
e.stopPropagation();
duplicateRow(index);
}}
title="Duplicate this row">
<IconCarbonCopy
class={isExpanded ? 'h-3.5 w-3.5' : 'h-3 w-3'} />
</button>
{#if index < rows.length - 1}
<button
class="flex cursor-pointer items-center justify-center rounded p-0.5 text-[var(--color-app-text-muted)] transition-colors hover:bg-[var(--color-app-surface-hover)] hover:text-red-500"
onclick={(e) => {
e.stopPropagation();
removeRowsFromBottom(index);
}}
title="Remove all rows from bottom up to this one">
title="Remove all rows below this one">
<IconCarbonDelete
class={isExpanded
? 'h-3.5 w-3.5'
: 'h-3 w-3'} />
class={isExpanded ? 'h-3.5 w-3.5' : 'h-3 w-3'} />
</button>
{/if}
</div>
Expand Down Expand Up @@ -963,6 +998,18 @@
onfocus={(e) => (e.target as HTMLInputElement).select()}
oninput={(e) => updateNumericField(index, 'volume', e)} />
</td>
<!-- Alpha -->
<td class={isExpanded ? 'w-12 min-w-12 px-1.5' : 'w-12 px-0.5'}>
<input
type="text"
class="w-full min-w-0 overflow-x-auto rounded border border-[var(--color-app-border)] bg-[var(--color-app-surface)] {isExpanded
? 'px-2 py-1 text-xs'
: 'px-1 py-0.5 text-[0.65rem]'} text-[var(--color-app-text-secondary)] placeholder-[var(--color-app-text-muted)] focus:border-[var(--color-app-primary)] focus:outline-none"
value={formatNum(row.alpha ?? 15)}
onkeydown={(e) => handleNumericKeyDown(index, e)}
onfocus={(e) => (e.target as HTMLInputElement).select()}
oninput={(e) => updateNumericField(index, 'alpha', e)} />
</td>
<!-- Amplitude Slide (merged: off/up/down) -->
<td
class="w-8 min-w-8 {isExpanded
Expand Down Expand Up @@ -992,7 +1039,7 @@
</tbody>
<tfoot>
<tr>
<td colspan="16" class="px-2 py-1">
<td colspan="17" class="px-2 py-1">
<div class="flex items-center justify-center">
<button
class="flex cursor-pointer items-center justify-center rounded p-0.5 text-[var(--color-app-text-muted)] transition-colors hover:bg-[var(--color-app-surface-hover)] hover:text-green-400"
Expand All @@ -1005,7 +1052,7 @@
</td>
</tr>
<tr>
<td colspan="16" class="border-t border-[var(--color-app-border)] p-0">
<td colspan="17" class="border-t border-[var(--color-app-border)] p-0">
<RowResizeHandle
rowCount={rows.length}
onRowCountChange={setRowCount}
Expand Down
Loading