Closing chord
A falling chord for an end card.
Customize
import {Scene, Video} from "odori";
import {StingClose} from "../components/sting-close/sting-close";
<Video>
<Scene id="sting-close" duration="2.5s">
<StingClose />
</Scene>
</Video>Installation
pnpm odori add audio sting-closenpx odori add audio sting-closebunx odori add audio sting-closeEdit the source in videos/components/sting-close/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/sting-close/sting-close.tsx
import {
chord,
defineCue,
gain,
lowPass,
mix,
normalize,
note,
sequence,
shape,
sine,
type CueDefinition,
} from "odori";
export type StingCloseOptions = {
/** Root the sting resolves onto, as a note name. */
root?: string;
/** Peak level before the mix normalizes the whole track. */
peak?: number;
};
/**
* The closing bookend: the fifth first, the root landing under it, and a long
* tail that lets the end card sit. Two and a half seconds, and it resolves
* downward, because a video that ends on a rise sounds unfinished.
*/
export const stingClose = ({root = "d3", peak = 0.8}: StingCloseOptions = {}): CueDefinition => {
const samples = 48000 * 2.5;
const [tonic, , fifth] = chord(root, "minor");
return defineCue({
name: "sting.close",
durationInFrames: 75,
params: {root, peak},
render: () =>
normalize(
mix(
sequence(
[
{
at: 0,
play: (length: number) => shape(sine(length, fifth), {attack: 0.01, decay: 1.4, sustain: 0.1, release: 0.6}),
gain: 0.55,
},
{
at: 1,
play: (length: number) => shape(sine(length, tonic), {attack: 0.012, decay: 1.6, sustain: 0.12, release: 0.7}),
gain: 0.8,
},
],
{bpm: 96, division: 8, samples},
),
// Weight under the landing, low passed so it is felt rather than heard.
gain(lowPass(shape(sine(samples, note(root) / 2), {attack: 0.03, decay: 1.8, sustain: 0}), 160), 0.5),
),
peak,
),
});
};
videos/components/sting-close/sting-close.preview.tsx
import {CueWave, defineComponentPreview} from "odori/preview";
import {stingClose, type StingCloseOptions} from "./sting-close";
const Wave = ({root, peak}: StingCloseOptions) => <CueWave cue={stingClose({root, peak})} />;
export default defineComponentPreview({
title: "Closing chord",
category: "Sound/Stings",
description: "A falling chord for an end card.",
component: Wave,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
root: {type: "select", defaultValue: "d3", options: ["c3", "d3", "f3", "g3"]},
peak: {type: "number", defaultValue: 0.8, min: 0.1, max: 1, step: 0.05},
},
examples: [{name: "Default", props: {}}, {name: "Lower", props: {root: "c3"}}],
});
Add it to a scene
import {Scene, Video} from "odori";
import {StingClose} from "../components/sting-close/sting-close";
<Video>
<Scene id="sting-close" duration="2.5s">
<StingClose />
</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
- sting.close
- Recommended duration
- 75 frames · 2.5s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the waveform renders without a playhead
- Requires
- mono font
- Content limits
- none