Pulse
A repeating pulse over a sustained chord.
Customize
import {Scene, Video} from "odori";
import {BedPulse} from "../components/bed-pulse/bed-pulse";
<Video>
<Scene id="bed-pulse" duration="5.7s">
<BedPulse />
</Scene>
</Video>Installation
pnpm odori add audio bed-pulsenpx odori add audio bed-pulsebunx odori add audio bed-pulseEdit the source in videos/components/bed-pulse/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/bed-pulse/bed-pulse.tsx
import {
chord,
defineCue,
gain,
lowPass,
mix,
normalize,
note,
pad,
sequence,
shape,
sine,
step,
type CueDefinition,
} from "odori";
export type BedPulseOptions = {
/** Tempo of the pulse. Slower reads calmer under narration. */
bpm?: number;
/** Root of the held chord, as a note name. */
root?: string;
/** Peak level before the mix normalizes the whole track. */
peak?: number;
};
/**
* Two bars of soft repeating pulse: a held minor seventh underneath, and a
* muted low tone on each beat to give the bed a heartbeat without giving it a
* groove. Written to sit under a voice, so nothing in it competes for the
* frequencies a voice uses.
*
* It loops, so the placement repeats it for as long as the scene runs and the
* score is rendered once.
*/
export const bedPulse = ({bpm = 84, root = "c2", peak = 0.55}: BedPulseOptions = {}): CueDefinition => {
const beat = step(bpm, 4);
const bars = 2;
const samples = beat * 4 * bars;
return defineCue({
name: "bed.pulse",
// Two bars at this tempo, in frames at 30fps.
durationInFrames: Math.round((samples / 48000) * 30),
loops: true,
params: {bpm, root, peak},
render: () =>
normalize(
mix(
// The held chord, low passed so it stays under everything else.
gain(lowPass(pad(chord(root, "minor7"), samples, {envelope: {attack: 0.9, sustain: 1, release: 0.9}}), 900), 0.9),
// The pulse itself: one soft tone per beat, decaying immediately.
sequence(
Array.from({length: 4 * bars}, (_, index) => ({
at: index,
play: (length: number) =>
shape(sine(length, note(root) * 2), {attack: 0.01, decay: 0.35, sustain: 0, release: 0.1}),
// The downbeat is louder, which is what makes four beats read as a bar.
gain: index % 4 === 0 ? 0.5 : 0.28,
})),
{bpm, samples},
),
),
peak,
),
});
};
videos/components/bed-pulse/bed-pulse.preview.tsx
import {CueWave, defineComponentPreview} from "odori/preview";
import {bedPulse, type BedPulseOptions} from "./bed-pulse";
const Wave = ({bpm, root, peak}: BedPulseOptions) => <CueWave cue={bedPulse({bpm, root, peak})} />;
export default defineComponentPreview({
title: "Pulse",
category: "Sound/Beds",
description: "A repeating pulse over a sustained chord.",
component: Wave,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
bpm: {type: "number", defaultValue: 84, min: 64, max: 112, step: 4},
root: {type: "select", defaultValue: "c2", options: ["a1", "c2", "d2", "f2"]},
peak: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},
},
examples: [{name: "Default", props: {}}, {name: "Slower", props: {bpm: 72}}],
});
Add it to a scene
import {Scene, Video} from "odori";
import {BedPulse} from "../components/bed-pulse/bed-pulse";
<Video>
<Scene id="bed-pulse" duration="5.7s">
<BedPulse />
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Sound
- Brand cue
- bed.pulse
- Recommended duration
- 171 frames · 5.7s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the waveform renders without a playhead
- Requires
- mono font
- Content limits
- none