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
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(turbo run:*)",
"Bash(npm run check-types)",
"Bash(npm run lint)",
"Bash(npm run fix)",
"Bash(npm run test:*)",
"Bash(pnpm install)",
"Bash(npm i -g pnpm*)"
],
"deny": []
}
}
20 changes: 15 additions & 5 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
pull-requests: write
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
Expand Down Expand Up @@ -53,10 +53,20 @@ jobs:
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"

# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
custom_instructions: |
If reviewing the code, please first and foremost review the correctness of the feature to what it's trying to accomplish.
Also look for bugs and security issues. Be concise with your entire response.
Thanks!

If developing code, please use `turbo run check-types`, `turbo run test`, `turbo run lint`, `turbo run fix` to ensure your code is correct. Or `npm run *` in the respective package.
If checks fail, make minimal edits and try again. Keep going until they pass unless you're blocked on something that you cannot solve.
Keep commits small & descriptive. Do NOT push until all your commits are done. The CI job will push only after checks pass.

To set up pnpm:
npm i -g pnpm@10.15.0
pnpm install

Thanks!

# Optional: Custom environment variables for Claude
# claude_env: |
Expand Down
191 changes: 191 additions & 0 deletions packages/jamtools/core/modules/audio_io/audio_io_example.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useState, useEffect } from 'react';
import springboard from 'springboard';
import {WebAudioModule} from '@jamtools/core/types/audio_io_types';

// Import the audio IO module
import './audio_io_module';

springboard.registerModule('AudioIOExample', {}, async (moduleAPI) => {
const audioIOModule = moduleAPI.deps.module.moduleRegistry.getModule('audio_io');

// Create shared state for WAM instances
const wamInstancesState = await moduleAPI.statesAPI.createSharedState<WebAudioModule[]>('wamInstances', []);
const masterVolumeState = await moduleAPI.statesAPI.createSharedState<number>('masterVolume', 0.8);

// Subscribe to WAM instance changes
audioIOModule.wamInstancesSubject.subscribe((instances: WebAudioModule[]) => {
wamInstancesState.setState(instances);
});

const ExampleComponent = () => {
const wamInstances = wamInstancesState.useState();
const masterVolume = masterVolumeState.useState();
const [isAudioInitialized, setIsAudioInitialized] = useState(false);

useEffect(() => {
// Initialize audio on component mount
audioIOModule.ensureAudioInitialized().then(() => {
setIsAudioInitialized(true);
});
}, []);

const createSynth = async () => {
try {
const synth = await audioIOModule.instantiateWAM('com.jamtools.oscillator-synth', `synth-${Date.now()}`);
console.log('Created synthesizer:', synth.instanceId);

// Connect to master output
const masterGain = audioIOModule.getMasterGainNode();
if (masterGain) {
synth.audioNode.connect(masterGain);
}
} catch (error) {
console.error('Failed to create synthesizer:', error);
}
};

const createDelay = async () => {
try {
const delay = await audioIOModule.instantiateWAM('com.jamtools.delay', `delay-${Date.now()}`);
console.log('Created delay effect:', delay.instanceId);

// Connect to master output
const masterGain = audioIOModule.getMasterGainNode();
if (masterGain) {
delay.audioNode.connect(masterGain);
}
} catch (error) {
console.error('Failed to create delay:', error);
}
};

const createAnalyzer = async () => {
try {
const analyzer = await audioIOModule.instantiateWAM('com.jamtools.spectrum-analyzer', `analyzer-${Date.now()}`);
console.log('Created spectrum analyzer:', analyzer.instanceId);

// Analyzer is typically inserted in the signal chain, not connected to output
console.log('Analyzer created - connect it between other WAMs for visualization');
} catch (error) {
console.error('Failed to create analyzer:', error);
}
};

const destroyWAM = async (instanceId: string) => {
try {
await audioIOModule.destroyWAMInstance(instanceId);
console.log('Destroyed WAM:', instanceId);
} catch (error) {
console.error('Failed to destroy WAM:', error);
}
};

const handleVolumeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const volume = parseFloat(event.target.value);
audioIOModule.setMasterVolume(volume);
masterVolumeState.setState(volume);
};

const playTestNote = async () => {
// Find a synthesizer instance and play a test note
const synth = wamInstances.find(wam => wam.moduleId === 'com.jamtools.oscillator-synth');
if (synth && synth.onMidi) {
// Play middle C (note 60)
const noteOnData = new Uint8Array([0x90, 60, 100]); // Note on, middle C, velocity 100
synth.onMidi(noteOnData);

// Stop after 1 second
setTimeout(() => {
const noteOffData = new Uint8Array([0x80, 60, 0]); // Note off
synth.onMidi?.(noteOffData);
}, 1000);
} else {
alert('Create a synthesizer first!');
}
};

return (
<div style={{padding: '20px', fontFamily: 'Arial, sans-serif'}}>
<h2>Audio IO Module Example</h2>

<div style={{marginBottom: '20px'}}>
<p>Status: {isAudioInitialized ? '✅ Audio Initialized' : '⏳ Initializing...'}</p>
<p>Active WAM Instances: {wamInstances.length}</p>
</div>

<div style={{marginBottom: '20px'}}>
<label>
Master Volume: {Math.round(masterVolume * 100)}%
<br />
<input
type="range"
min="0"
max="1"
step="0.01"
value={masterVolume}
onChange={handleVolumeChange}
style={{width: '200px'}}
/>
</label>
</div>

<div style={{marginBottom: '20px'}}>
<h3>Create WAM Instances</h3>
<button onClick={createSynth} style={{marginRight: '10px'}}>
Create Synthesizer
</button>
<button onClick={createDelay} style={{marginRight: '10px'}}>
Create Delay Effect
</button>
<button onClick={createAnalyzer} style={{marginRight: '10px'}}>
Create Spectrum Analyzer
</button>
</div>

<div style={{marginBottom: '20px'}}>
<h3>Test Audio</h3>
<button onClick={playTestNote}>
Play Test Note (Middle C)
</button>
</div>

<div>
<h3>Active WAM Instances</h3>
{wamInstances.length === 0 ? (
<p>No WAM instances created yet.</p>
) : (
<ul>
{wamInstances.map((wam: WebAudioModule) => (
<li key={wam.instanceId} style={{marginBottom: '10px'}}>
<strong>{wam.name}</strong> ({wam.instanceId})
<br />
Module: {wam.moduleId}
<br />
<button
onClick={() => destroyWAM(wam.instanceId)}
style={{marginTop: '5px', color: 'red'}}
>
Destroy
</button>
</li>
))}
</ul>
)}
</div>

<div style={{marginTop: '30px', padding: '15px', backgroundColor: '#f0f0f0', borderRadius: '5px'}}>
<h4>How to Use</h4>
<ol>
<li>Click "Create Synthesizer" to create an audio synthesizer</li>

Check failure on line 179 in packages/jamtools/core/modules/audio_io/audio_io_example.tsx

View workflow job for this annotation

GitHub Actions / lint

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`

Check failure on line 179 in packages/jamtools/core/modules/audio_io/audio_io_example.tsx

View workflow job for this annotation

GitHub Actions / lint

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`
<li>Click "Play Test Note" to hear a middle C note</li>

Check failure on line 180 in packages/jamtools/core/modules/audio_io/audio_io_example.tsx

View workflow job for this annotation

GitHub Actions / lint

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`

Check failure on line 180 in packages/jamtools/core/modules/audio_io/audio_io_example.tsx

View workflow job for this annotation

GitHub Actions / lint

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`
<li>Adjust the master volume slider</li>
<li>Create delay effects and spectrum analyzers to enhance the audio</li>
<li>The module automatically integrates with MIDI input devices</li>
</ol>
</div>
</div>
);
};

moduleAPI.registerRoute('audio-io-example', {}, ExampleComponent);
});
146 changes: 146 additions & 0 deletions packages/jamtools/core/modules/audio_io/audio_io_module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {AudioIOModule, setAudioIODependencyCreator} from './audio_io_module';
import {MockAudioService} from '@jamtools/core/test/services/mock_audio_service';
import {MockWAMRegistryService} from '@jamtools/core/test/services/mock_wam_registry_service';
import {Subject} from 'rxjs';

describe('AudioIOModule', () => {
let audioIOModule: AudioIOModule;
let mockCoreDeps: any;
let mockModDeps: any;
let mockModuleAPI: any;

beforeEach(() => {
// Set up mock dependencies
setAudioIODependencyCreator(async () => ({
audio: new MockAudioService(),
wamRegistry: new MockWAMRegistryService(),
}));

mockCoreDeps = {
modules: {
io: {
midiInputSubject: new Subject(),
},
},
};

mockModDeps = {
toast: () => {},
};

mockModuleAPI = {
statesAPI: {
createSharedState: async (name: string, initialState: any) => ({
getState: () => initialState,
setState: (state: any) => {
Object.assign(initialState, state);
},
useState: () => initialState,
}),
},
};

audioIOModule = new AudioIOModule(mockCoreDeps, mockModDeps);
});

it('should initialize with correct module ID', () => {
expect(audioIOModule.moduleId).toBe('audio_io');
});

it('should initialize with default state', () => {
expect(audioIOModule.state).toEqual({
audioContext: null,
wamInstances: [],
isAudioInitialized: false,
masterVolume: 0.8,
});
});

it('should initialize audio IO dependencies', async () => {
await audioIOModule.initialize(mockModuleAPI);
expect(audioIOModule['audioIODeps']).toBeDefined();
expect(audioIOModule['audioIODeps'].audio).toBeInstanceOf(MockAudioService);
expect(audioIOModule['audioIODeps'].wamRegistry).toBeInstanceOf(MockWAMRegistryService);
});

it('should ensure audio initialization', async () => {
await audioIOModule.initialize(mockModuleAPI);
await audioIOModule.ensureAudioInitialized();
expect(audioIOModule['isAudioInitialized']).toBe(true);
});

it('should set master volume', async () => {
await audioIOModule.initialize(mockModuleAPI);
audioIOModule.setMasterVolume(0.5);

const state = audioIOModule['audioIOState'].getState();
expect(state.masterVolume).toBe(0.5);
});

it('should instantiate WAM', async () => {
await audioIOModule.initialize(mockModuleAPI);

const wam = await audioIOModule.instantiateWAM('com.jamtools.oscillator-synth', 'test-synth');
expect(wam).toBeDefined();
expect(wam.moduleId).toBe('com.jamtools.oscillator-synth');
expect(wam.instanceId).toBe('test-synth');
});

it('should destroy WAM instance', async () => {
await audioIOModule.initialize(mockModuleAPI);

await audioIOModule.instantiateWAM('com.jamtools.oscillator-synth', 'test-synth');
await audioIOModule.destroyWAMInstance('test-synth');

const instance = audioIOModule.getWAMInstance('test-synth');
expect(instance).toBeNull();
});

it('should get registered WAMs', async () => {
await audioIOModule.initialize(mockModuleAPI);

const registeredWAMs = audioIOModule.getRegisteredWAMs();
expect(registeredWAMs.length).toBeGreaterThan(0);

const synthWAM = registeredWAMs.find(wam => wam.moduleId === 'com.jamtools.oscillator-synth');
expect(synthWAM).toBeDefined();
expect(synthWAM?.name).toBe('Mock Oscillator Synthesizer');
});

it('should handle MIDI input', async () => {
await audioIOModule.initialize(mockModuleAPI);

const wam = await audioIOModule.instantiateWAM('com.jamtools.oscillator-synth', 'test-synth');

// Mock MIDI event
const midiEvent = {
type: 'noteon',
number: 60,
velocity: 100,
channel: 0,
};

// Trigger MIDI input
mockCoreDeps.modules.io.midiInputSubject.next(midiEvent);

// WAM should receive MIDI data (tested in mock implementation)
expect(wam.onMidi).toBeDefined();
});

it('should convert MIDI events to bytes correctly', () => {
const convertMidiEventToBytes = audioIOModule['convertMidiEventToBytes'];

const noteOnEvent = {type: 'noteon', number: 60, velocity: 100, channel: 0};
const noteOnBytes = convertMidiEventToBytes(noteOnEvent);
expect(Array.from(noteOnBytes)).toEqual([0x90, 60, 100]);

const noteOffEvent = {type: 'noteoff', number: 60, velocity: 0, channel: 0};
const noteOffBytes = convertMidiEventToBytes(noteOffEvent);
expect(Array.from(noteOffBytes)).toEqual([0x80, 60, 0]);

const ccEvent = {type: 'controlchange', number: 7, velocity: 127, channel: 0};
const ccBytes = convertMidiEventToBytes(ccEvent);
expect(Array.from(ccBytes)).toEqual([0xB0, 7, 127]);
});
});
Loading
Loading