Skip to content
Merged
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
20 changes: 20 additions & 0 deletions example/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,23 @@
button {
font-size: calc(10px + 2vmin);
}

.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
width: 90vw;
max-width: 960px;
font-size: 16px;
text-align: left;
}

.controls label {
display: flex;
flex-direction: column;
gap: 4px;
}

.controls button {
font-size: 16px;
}
132 changes: 79 additions & 53 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,41 @@ function expandToRests(steps: StepType[]): StepType[] {
return steps.reduce<StepType[]>((acc, step) => [...acc, step, null], []);
}

const stepPatterns: StepType[][] = [
// Dark chords: Cm - Ab - Fm - G, a i-VI-iv-V cadence in C minor. The G
// major triad (with its raised 7th, B) pulls back toward Cm without ever
// landing somewhere comfortable. (Ab/Eb spelled as G#/D# - MidiNote only
// supports sharps.)
expandToRests([
['C3', 'D#3', 'G3'],
null,
['G#2', 'C3', 'D#3'],
null,
['F2', 'G#2', 'C3'],
null,
['G2', 'B2', 'D3'],
null,
]),
// Dark arpeggio: a Cm(maj7) broken chord, up and down. The major 7th (B)
// against the minor 3rd (D#) is what keeps it unresolved rather than sad.
// Each note is doubled into two 16th-note slots (instead of padded with a
// rest like the chords above) so it keeps the same real-world length while
// still landing a real note on every 16th - that's what gives 16th-note
// swing something to actually shift. Duration is half a 16th note so the
// doubled notes stay short and plucky rather than blurring together.
(['C3', 'D#3', 'G3', 'B3', 'C4', 'B3', 'G3', 'D#3'] as MidiNote[])
// Dark chords: Cm - Ab - Fm - G, a i-VI-iv-V cadence in C minor. The G major
// triad (with its raised 7th, B) pulls back toward Cm without ever landing
// somewhere comfortable. (Ab/Eb spelled as G#/D# - MidiNote only supports
// sharps.)
const chordSteps = expandToRests([
['C3', 'D#3', 'G3'],
null,
['G#2', 'C3', 'D#3'],
null,
['F2', 'G#2', 'C3'],
null,
['G2', 'B2', 'D3'],
null,
]);

// Dark arpeggio: a Cm(maj7) broken chord, up and down. The major 7th (B)
// against the minor 3rd (D#) is what keeps it unresolved rather than sad.
// Each note is doubled into two 16th-note slots (instead of padded with a
// rest like the chords above) so it keeps the same real-world length while
// still landing a real note on every 16th - that's what gives 16th-note
// swing something to actually shift. Duration is half a 16th note so the
// doubled notes stay short and plucky rather than blurring together.
//
// When manualDelay is on, the off-beat (second) note of each doubled pair
// gets a `delay` instead - a per-note alternative to Song's global `swing`
// prop, so it can be compared against (or combined with) the swing slider.
function buildArpeggioSteps(manualDelay: boolean) {
return (['C3', 'D#3', 'G3', 'B3', 'C4', 'B3', 'G3', 'D#3'] as MidiNote[])
.flatMap((name) => [name, name])
.map((name) => ({
.map((name, index) => ({
name,
duration: '32n',
})),
];
...(manualDelay && index % 2 === 1 ? { delay: '64n' } : {}),
}));
}

const samplerPatternSteps = expandToRests([
'C3',
Expand Down Expand Up @@ -98,6 +104,12 @@ function App() {
const [swingSubdivision, setSwingSubdivision] = useState('8n');
const [subdivision, setSubdivision] = useState('16n');
const [bpm, setBpm] = useState(70);
const [manualDelay, setManualDelay] = useState(false);

const stepPatterns: StepType[][] = [
chordSteps,
buildArpeggioSteps(manualDelay),
];

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -125,7 +137,7 @@ function App() {
<div className="App">
<header className="App-header">
<p>Hello Vite + React + Reactronica!</p>
<p>
<div className="controls">
<button type="button" onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Stop' : 'Play'}
</button>
Expand Down Expand Up @@ -154,31 +166,37 @@ function App() {
>
Toggle pattern
</button>
<select
value={synthType}
onChange={(event) =>
setSynthType(event.target.value as InstrumentType)
}
>
{synthTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<select
value={effectType}
onChange={(event) =>
setEffectType(event.target.value as EffectType | '')
}
>
<option value="">No effect</option>
{effectTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<label>
Synth type
<select
value={synthType}
onChange={(event) =>
setSynthType(event.target.value as InstrumentType)
}
>
{synthTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</label>
<label>
Effect
<select
value={effectType}
onChange={(event) =>
setEffectType(event.target.value as EffectType | '')
}
>
<option value="">No effect</option>
{effectTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</label>
<label>
Tempo
<input
Expand Down Expand Up @@ -225,6 +243,14 @@ function App() {
))}
</select>
</label>
<label>
Manual delay (arpeggio, per-note)
<input
type="checkbox"
checked={manualDelay}
onChange={(event) => setManualDelay(event.target.checked)}
/>
</label>
<label>
Subdivision
<select
Expand All @@ -238,7 +264,7 @@ function App() {
))}
</select>
</label>
</p>
</div>
</header>

<Song
Expand Down
10 changes: 10 additions & 0 deletions src/__mocks__/tone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ const Transport = {

export const getTransport = () => Transport;

// ----------------------------------------------------------------------------
// Tone.Time()
// ----------------------------------------------------------------------------

// NOTE: Real Tone.Time() converts notation strings (e.g. '32n') using the
// current tempo; that's not needed for tests, which only pass plain numbers.
export const Time = (value) => ({
toSeconds: () => value,
});

// ----------------------------------------------------------------------------
// Tone.start()
// ----------------------------------------------------------------------------
Expand Down
9 changes: 8 additions & 1 deletion src/components/Track.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export interface StepNoteType {
name: MidiNote;
duration?: number | string;
velocity?: number;
/** Nudges the note's start time by this amount (seconds, or Tone notation
* like '32n'). A per-note alternative to Song's global `swing` prop. */
delay?: number | string;
}

export type StepType =
Expand Down Expand Up @@ -89,11 +92,15 @@ const TrackConsumer: React.FC<TrackConsumerProps> = ({
sequencer.current = new Tone.Sequence(
(time, step) => {
step.notes.forEach((note) => {
const noteTime = note.delay
? time + Tone.Time(note.delay).toSeconds()
: time;

instrumentsRef.current.forEach((instrument) => {
instrument.triggerAttackRelease(
note.name,
note.duration || 0.5,
time,
noteTime,
note.velocity,
);
});
Expand Down
1 change: 1 addition & 0 deletions src/lib/buildSequencerStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function buildSequencerStep(step: StepType, i): SequencerStep {
name: (step as StepNoteType).name,
duration: (step as StepNoteType).duration,
velocity: (step as StepNoteType).velocity,
delay: (step as StepNoteType).delay,
},
],
index: i,
Expand Down
Loading