audio-ducking
Audio ducking implementation for voice-overs and announcements. Use when implementing message playback over music, announcement systems, or volume automation. Triggers on mentions of ducking, announce
SKILL.md.claude/skills/audio-ducking/SKILL.md
GitHubAudio Ducking for Announcements
What is Ducking?
Ducking automatically lowers the music volume when an announcement or voice-over plays, then restores it after.
Volume
100% ─────┐ ┌─────
│ │
15% ─────┴───────────────────────────┴─────
↑ ↑ ↑
Attack Message Release
(300ms) Playing (500ms)
Audio Graph for Ducking
┌──────────────┐ ┌──────────────┐
│ Music │────▶│ Music Bus │──┐
│ Source │ │ (ducked) │ │
└──────────────┘ └──────────────┘ │ ┌──────────────┐ ┌─────────────┐
├──▶│ Master Bus │──▶│ Destination │
┌──────────────┐ ┌──────────────┐ │ └──────────────┘ └─────────────┘
│ Message │────▶│ Message Bus │──┘
│ Source │ │ (full vol) │
└──────────────┘ └──────────────┘
Standard Parameters
const DUCK_CONFIG = {
duckLevel: 0.15, // 15% of normal volume
attackTime: 0.3, // 300ms fade down
holdTime: 0.1, // 100ms minimum hold before release
releaseTime: 0.5, // 500ms fade back up
};
Implementation
DuckingController Class
class DuckingController {
private context: AudioContext;
private musicBus: GainNode;
private messageBus: GainNode;
private isDucked: boolean = false;
private config: DuckConfig;
constructor(
context: AudioContext,
musicBus: GainNode,
config: Partial<DuckConfig> = {}
) {
this.context = context;
this.musicBus = musicBus;
this.config = { ...DUCK_CONFIG, ...config };
// Create message bus
this.messageBus = context.createGain();
this.messageBus.connect(context.destination);
}
/**
* Duck the music volume
*/
duck(): void {
if (this.isDucked) return;
const now = this.context.currentTime;
const targetTime = now + this.config.attackTime;
// Cancel any pending automation
this.musicBus.gain.cancelScheduledValues(now);
// Exponential ramp sounds more natural than linear
this.musicBus.gain.setValueAtTime(this.musicBus.gain.value, now);
this.musicBus.gain.exponentialRampToValueAtTime(
Math.max(this.config.duckLevel, 0.001), // Can't ramp to 0
targetTime
);
this.isDucked = true;
}
/**
* Restore music to full volume
*/
release(): void {
if (!this.isDucked) return;
const now = this.context.currentTime;
const holdEnd = now + this.config.holdTime;
const releaseEnd = holdEnd + this.config.releaseTime;
this.musicBus.gain.cancelScheduledValues(now);
this.musicBus.gain.setValueAtTime(this.musicBus.gain.value, now);
this.musicBus.gain.setValueAtTime(this.config.duckLevel, holdEnd);
this.musicBus.gain.exponentialRampToValueAtTime(1.0, releaseEnd);
this.isDucked = false;
}
/**
* Play a message with automatic ducking
*/
async playMessage(url: string): Promise<void> {
// Duck the music
this.duck();
// Load and decode the message
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.context.decodeAudioData(arrayBuffer);
// Create source and play
const source = this.context.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.messageBus);
return new Promise((resolve) => {
source.onended = () => {
source.disconnect();
this.release();
resolve();
};
source.start();
});
}
}
Message Queue for Multiple Announcements
interface QueuedMessage {
url: string;
priority: number;
}
class MessageQueue {
private queue: QueuedMessage[] = [];
private isPlaying: boolean = false;
private duckingController: DuckingController;
enqueue(message: QueuedMessage): void {
this.queue.push(message);
// Sort by priority (higher first)
this.queue.sort((a, b) => b.priority - a.priority);
this.processQueue();
}
private async processQueue(): Promise<void> {
if (this.isPlaying || this.queue.length === 0) return;
this.isPlaying = true;
// Duck once for entire sequence
this.duckingController.duck();
while (this.queue.length > 0) {
const message = this.queue.shift()!;
// Play message (without extra duck/release)
await this.playMessageAudio(message.url);
// Small gap between messages
await this.delay(100);
}
// Release after all messages
this.duckingController.release();
this.isPlaying = false;
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Scheduling Messages
Time-Based Scheduling
interface ScheduledMessage {
id: string;
messageId: string;
playTime: string; // "HH:mm"
intervalMinutes?: number; // For recurring (every 30 min)
daysOfWeek?: number[]; // 0=Sun, 1=Mon, etc.
}
class MessageScheduler {
private schedules: ScheduledMessage[] = [];
private checkInterval: number;
start(): void {
// Check every minute
this.checkInterval = setInterval(() => {
this.checkSchedules();
}, 60000);
}
private checkSchedules(): void {
const now = new Date();
const currentTime = now.toTimeString().slice(0, 5); // "HH:mm"
const currentDay = now.getDay();
for (const schedule of this.schedules) {
if (schedule.daysOfWeek && !schedule.daysOfWeek.includes(currentDay)) {
continue;
}
if (schedule.playTime === currentTime) {
this.playScheduledMessage(schedule);
}
}
}
}
Best Practices
- Use exponentialRamp - Sounds more natural than linear
- Hold before release - Prevents abrupt volume changes
- Queue multiple messages - Don't interrupt mid-message
- Priority system - Urgent announcements first
- Don't duck too low - 15% maintains ambiance
- Smooth transitions - 300-500ms feels natural