Skip to content
Closed
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
36 changes: 36 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# CLAUDE.md

Bitphase is a web-based chiptune tracker for retro sound chips (currently AY-3-8910 / YM2149F), built with Svelte 5, TypeScript, Vite, and Tailwind 4.

## Authoritative rules

The coding rules in `.cursor/rules/` are the source of truth and apply to all work here. Read and follow them:

- `.cursor/rules/general.mdc` — architecture, directory layout, chip-abstraction rules, code style.
- `.cursor/rules/svelte5.mdc` — Svelte 5 runes API (`$state`, `$derived`, `$effect`, `$props`, snippets). Svelte 5 syntax only.

Key non-negotiables from those rules:

- **No lint errors, ever.** Past production bugs traced to lint errors. Fix any you see.
- **No comments** — write self-documenting code.
- **No chip-specific code in generic parts.** AY-specific logic stays in `src/lib/chips/ay/`; generic code uses `src/lib/chips/base/`. The architecture must stay ready for additional chips.
- **State via Svelte 5 runes in `.svelte.ts` files**, not writable stores.

## Commands

- `pnpm dev` — build WASM, then start Vite dev server (HMR).
- `pnpm build` — build WASM, then production build.
- `pnpm build:wasm` — build only the Ayumi WASM module (requires Emscripten / `emcc` on PATH; output goes to `public/ayumi.wasm`).
- `pnpm check` — type-check (`svelte-check` + `tsc`). Run before considering work done.
- `pnpm test` — vitest watch mode. `pnpm test:run` — run once.
- `pnpm btp-to-wav` — CLI export of a `.btp` to WAV.

## Notes

- `@` path alias maps to `./src` (vite + vitest).
- Tests live in `tests/`, mirroring `src/` structure; vitest uses jsdom.
- The Ayumi emulator C source is a git submodule at `external/ayumi`; clone with `--recurse-submodules`.
- Stale `public/ayumi.wasm` causes silent no-audio — rebuild WASM after changing the C source or when audio is unexpectedly silent.
- `public/` holds runtime AudioWorklet scripts (`tracker-*.js`, `ay-*.js`, `bitphase-audio-processor.js`) that are served as-is, not bundled.

See `README.md` for full feature list and project structure.
120 changes: 120 additions & 0 deletions cli/btp-to-psg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { loadBtpFromFile } from './btp-loader';
import { FileSystemResourceLoader } from './resource-loader-node';
import { ensureCoreRegistry } from '../src/lib/chips/registry-core';
import type { PsgExportModules } from '../src/lib/services/file/ay/psg-export';
import { generatePSGBuffer } from '../src/lib/services/file/ay/psg-export';
import type { Project } from '../src/lib/models/project';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.join(__dirname, '..');
const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public');

function printUsage(): void {
console.error(`Usage: btp-to-psg <input.btp> [output-base]
Converts a Bitphase project (.btp) to a Bulba .psg register dump.

input.btp Path to the BTP file to convert
output-base Optional output base path (default: input name without extension).
Produces <base>.psg; for multi-AY-song projects, produces
<base>_ayN.psg per AY song.`);
}

async function loadModulesFromPublic(
resourceLoader: FileSystemResourceLoader
): Promise<PsgExportModules> {
const [
ayumiState,
trackerPatternProcessor,
ayAudioDriver,
ayChipRegisterState,
virtualChannelMixer
] = await Promise.all([
resourceLoader.loadModule<{ default: PsgExportModules['AyumiState'] }>('ay/ayumi-state.js'),
resourceLoader.loadModule<{ default: PsgExportModules['TrackerPatternProcessor'] }>(
'tracker/tracker-pattern-processor.js'
),
resourceLoader.loadModule<{ default: PsgExportModules['AYAudioDriver'] }>('ay/ay-audio-driver.js'),
resourceLoader.loadModule<{ default: PsgExportModules['AYChipRegisterState'] }>(
'ay/ay-chip-register-state.js'
),
resourceLoader.loadModule<{ default: PsgExportModules['VirtualChannelMixer'] }>(
'ay/virtual-channel-mixer.js'
)
]);
return {
AyumiState: ayumiState.default,
TrackerPatternProcessor: trackerPatternProcessor.default,
AYAudioDriver: ayAudioDriver.default,
AYChipRegisterState: ayChipRegisterState.default,
VirtualChannelMixer: virtualChannelMixer.default
};
}

function getAYSongIndices(project: Project): number[] {
const aySongIndices: number[] = [];
for (let index = 0; index < project.songs.length; index++) {
const song = project.songs[index];
if (song && (!song.chipType || song.chipType === 'ay')) {
aySongIndices.push(index);
}
}
return aySongIndices;
}

async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length < 1) {
printUsage();
process.exit(1);
}

const inputPath = path.resolve(process.cwd(), args[0]);
const outputBase =
args[1] !== undefined
? path.resolve(process.cwd(), args[1])
: inputPath.replace(/\.btp$/i, '');

if (!fs.existsSync(inputPath)) {
console.error(`Error: Input file not found: ${inputPath}`);
process.exit(1);
}

if (!fs.existsSync(PUBLIC_DIR)) {
console.error(`Error: Public directory not found: ${PUBLIC_DIR}`);
console.error('Run this command from the project root.');
process.exit(1);
}

const resourceLoader = new FileSystemResourceLoader(PUBLIC_DIR);

try {
await ensureCoreRegistry();
const project = loadBtpFromFile(inputPath);
const modules = await loadModulesFromPublic(resourceLoader);
const aySongIndices = getAYSongIndices(project);

if (aySongIndices.length === 0) {
console.error('Error: Project has no AY songs to export.');
process.exit(1);
}

const multipleSongs = aySongIndices.length > 1;

for (let index = 0; index < aySongIndices.length; index++) {
const songIndex = aySongIndices[index]!;
process.stderr.write(`\r[${index + 1}/${aySongIndices.length}] Generating PSG... `);
const buffer = await generatePSGBuffer(project, songIndex, { modules });
const base = multipleSongs ? `${outputBase}_ay${index + 1}` : outputBase;
fs.writeFileSync(`${base}.psg`, Buffer.from(buffer));
console.error(`\nWrote: ${base}.psg`);
}
} catch (error) {
console.error('\nError:', error instanceof Error ? error.message : error);
process.exit(1);
}
}

main();
133 changes: 133 additions & 0 deletions cli/btp-to-taym.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { loadBtpFromFile } from './btp-loader';
import { FileSystemResourceLoader } from './resource-loader-node';
import { ensureCoreRegistry } from '../src/lib/chips/registry-core';
import type { PsgExportModules } from '../src/lib/services/file/ay/psg-export';
import { generateTaymFile } from '../src/lib/services/file/taym/taym-export';
import type { TaymTimerMode } from '../src/lib/services/file/taym/taym-export-timers';
import type { Project } from '../src/lib/models/project';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.join(__dirname, '..');
const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public');

function printUsage(): void {
console.error(`Usage: btp-to-taym <input.btp> [output-base] [--hz]
Converts a Bitphase project (.btp) to a TAYM file.

input.btp Path to the BTP file to convert
output-base Optional output base path (default: input name without extension).
Produces <base>.taym; for multi-AY-song projects, produces
<base>_ayN.taym per AY song.
--hz Encode timers as ABS_RATE_HZ (16.16 Hz lanes) instead of the
default CHIP_PERIOD (literal AY-period lanes).`);
}

async function loadModulesFromPublic(
resourceLoader: FileSystemResourceLoader
): Promise<PsgExportModules> {
const [
ayumiState,
trackerPatternProcessor,
ayAudioDriver,
ayChipRegisterState,
virtualChannelMixer,
samplePlayback
] = await Promise.all([
resourceLoader.loadModule<{ default: PsgExportModules['AyumiState'] }>('ay/ayumi-state.js'),
resourceLoader.loadModule<{ default: PsgExportModules['TrackerPatternProcessor'] }>(
'tracker/tracker-pattern-processor.js'
),
resourceLoader.loadModule<{ default: PsgExportModules['AYAudioDriver'] }>('ay/ay-audio-driver.js'),
resourceLoader.loadModule<{ default: PsgExportModules['AYChipRegisterState'] }>(
'ay/ay-chip-register-state.js'
),
resourceLoader.loadModule<{ default: PsgExportModules['VirtualChannelMixer'] }>(
'ay/virtual-channel-mixer.js'
),
resourceLoader.loadModule<{
instrumentHasSample: NonNullable<PsgExportModules['instrumentHasSample']>;
advanceSamplePosition: NonNullable<PsgExportModules['advanceSamplePosition']>;
}>('ay/ay-sample-playback.js')
]);
return {
AyumiState: ayumiState.default,
TrackerPatternProcessor: trackerPatternProcessor.default,
AYAudioDriver: ayAudioDriver.default,
AYChipRegisterState: ayChipRegisterState.default,
VirtualChannelMixer: virtualChannelMixer.default,
instrumentHasSample: samplePlayback.instrumentHasSample,
advanceSamplePosition: samplePlayback.advanceSamplePosition
};
}

function getAYSongIndices(project: Project): number[] {
const aySongIndices: number[] = [];
for (let index = 0; index < project.songs.length; index++) {
const song = project.songs[index];
if (song && (!song.chipType || song.chipType === 'ay')) {
aySongIndices.push(index);
}
}
return aySongIndices;
}

async function main(): Promise<void> {
const allArgs = process.argv.slice(2);
const timerMode: TaymTimerMode = allArgs.includes('--hz') ? 'abs-rate-hz' : 'chip-period';
const args = allArgs.filter((arg) => arg !== '--hz');
if (args.length < 1) {
printUsage();
process.exit(1);
}

const inputPath = path.resolve(process.cwd(), args[0]);
const outputBase = (
args[1] !== undefined
? path.resolve(process.cwd(), args[1])
: inputPath.replace(/\.btp$/i, '')
).replace(/\.taym$/i, '');

if (!fs.existsSync(inputPath)) {
console.error(`Error: Input file not found: ${inputPath}`);
process.exit(1);
}

if (!fs.existsSync(PUBLIC_DIR)) {
console.error(`Error: Public directory not found: ${PUBLIC_DIR}`);
console.error('Run this command from the project root.');
process.exit(1);
}

const resourceLoader = new FileSystemResourceLoader(PUBLIC_DIR);

try {
await ensureCoreRegistry();
const project = loadBtpFromFile(inputPath);
const modules = await loadModulesFromPublic(resourceLoader);
const aySongIndices = getAYSongIndices(project);

if (aySongIndices.length === 0) {
console.error('Error: Project has no AY songs to export.');
process.exit(1);
}

const multipleSongs = aySongIndices.length > 1;

for (let index = 0; index < aySongIndices.length; index++) {
const songIndex = aySongIndices[index]!;
process.stderr.write(`\r[${index + 1}/${aySongIndices.length}] Generating TAYM... `);
const taym = await generateTaymFile(project, songIndex, { modules, timerMode });
const base = multipleSongs ? `${outputBase}_ay${index + 1}` : outputBase;
fs.writeFileSync(`${base}.taym`, Buffer.from(taym));
console.error(`\nWrote: ${base}.taym`);
}
} catch (error) {
console.error('\nError:', error instanceof Error ? error.message : error);
process.exit(1);
}
}

main();
Loading
Loading