To control a DC motor smoothly without audible whine using PWM for Arduino, use an Arduino Uno R3 (ATmega328P), the TimerOne library to push the hardware PWM frequency to 20kHz, and a TB6612FNG MOSFET motor driver. Default 490Hz PWM causes magnetostriction whine in motor windings, and older BJT-based drivers like the L298N waste torque at low duty cycles due to high voltage drops.

Difficulty: Intermediate | Time: 45 Minutes | Cost: ~$52

The Quick Decision: Which PWM Method and Board to Pick?

Not all PWM implementations are equal. The right choice depends entirely on your load type and channel count. Use this decision tree to lock in your hardware and software approach.

If Your Project Needs...Then Pick This BoardUse This PWM Method
1-6 channels, LEDs or heaters (frequency insensitive)Arduino Uno R3 / Nano v3Native analogWrite() (490Hz / 980Hz)
1-6 channels, DC motors or audio (needs >16kHz to avoid whine)Arduino Uno R3 / Nano v3TimerOne library (Hardware Timer1, up to 31kHz)
7-15 channels, mixed loadsArduino Mega 2560Native analogWrite() on pins 2-13, 44-46
16+ channels, high-res 16-bit, or Wi-Fi integrationESP32 DevKit V1 (WROOM-32)ledc API (Software-mapped PWM, up to 80MHz base)
Concrete Pick: For standard 12V DC gearmotor control on a workbench, default to the Arduino Uno R3 + TimerOne + TB6612FNG. It eliminates audible noise, preserves low-speed torque, and requires zero register-level bitwise math.

Hardware Spec Sheet: TB6612FNG vs. L298N for PWM Motor Control

When applying PWM to a motor, the driver's internal architecture dictates your low-speed performance. The ancient L298N uses Bipolar Junction Transistors (BJTs), while the modern TB6612FNG uses MOSFETs. This difference is critical for PWM tuning.

SpecificationTB6612FNG (MOSFET)L298N (BJT H-Bridge)
Continuous Current1.2A per channel2.0A per channel
Voltage Drop at 1A~0.5V (MOSFET Rds(on))~2.5V to 3.0V (BJT Vce(sat))
Low PWM Duty Cycle TorqueExcellent (voltage reaches motor)Poor (voltage drop eats the pulse)
Switching SpeedFast (Handles 20kHz+ easily)Slower (Can overheat at high freq)
Typical Price (2026)~$12 (SparkFun/Pololu carrier)~$4 (Generic clone modules)

Because the L298N drops up to 3V across its BJT junctions, a 10% duty cycle PWM pulse on a 12V supply might only deliver 6V to the motor for a fraction of a millisecond—resulting in stalling. The TB6612FNG's low MOSFET resistance passes the full 12V pulse, maintaining magnetic field strength even at 5% duty cycles. For deep-dive specs, refer to the Pololu TB6612FNG datasheet and carrier board documentation.

Parts List and Pin Mapping for Arduino Uno R3

This build targets the Arduino Uno R3 (Rev3) with the ATmega328P DIP chip. Do not use an Arduino Uno R4 Minima for this specific code, as the R4 uses a Renesas RA4M1 ARM Cortex-M4 and does not support the AVR-specific TimerOne library.

Bill of Materials

  • Microcontroller: Arduino Uno R3 (Rev3) - ~$25
  • Motor Driver: TB6612FNG Dual Motor Driver Carrier (Pololu #713 or SparkFun #14451) - ~$12
  • Actuator: 12V DC Gearmotor (60 RPM, 1A stall current max) - ~$15
  • Input: 10kΩ Linear Trimpot (Potentiometer) - ~$1
  • Power: 12V 2A DC Barrel Jack Power Supply

Pin Mapping Table

Arduino Uno R3 PinTB6612FNG PinFunction / Notes
D9 (Hardware PWM)PWMATimer1 controlled 20kHz PWM signal
D7 (Digital Out)AIN1Motor A Direction Logic 1
D8 (Digital Out)AIN2Motor A Direction Logic 2
5VVCCLogic level power (Do NOT confuse with VMOT)
GNDGNDCommon ground (Must share with 12V supply GND)
N/AVMOTConnected directly to 12V Power Supply +
N/ASTBYJumper to VCC (5V) to keep driver enabled
A0 (Analog In)Trimpot WiperManual speed/direction input (0-5V)

Complete Compilable Code: Smooth Acceleration with Error Handling

This sketch uses the TimerOne library to override the default Arduino analogWrite 490Hz frequency. It includes an exponential moving average filter to eliminate potentiometer ADC jitter, a deadzone to prevent creep, and a 'kickstart' routine to overcome static friction at low PWM duty cycles.

#include <TimerOne.h>

// ==========================================
// PIN DEFINITIONS (Arduino Uno R3 ATmega328P)
// ==========================================
#define PIN_POT    A0
#define PIN_PWMA   9   // Must be Pin 9 or 10 for TimerOne
#define PIN_AIN1   7
#define PIN_AIN2   8

// ==========================================
// SYSTEM CONSTANTS
// ==========================================
const int DEADZONE = 50;         // Prevents motor creep at center
const int KICKSTART_DUTY = 1023; // 100% duty cycle (10-bit resolution)
const int KICKSTART_MS = 50;     // 50ms pulse to break static friction

// ==========================================
// STATE VARIABLES
// ==========================================
float smoothedPot = 512.0;
bool isStopped = true;

void setup() {
  Serial.begin(115200);
  
  pinMode(PIN_AIN1, OUTPUT);
  pinMode(PIN_AIN2, OUTPUT);
  pinMode(PIN_POT, INPUT);
  
  // Initialize Timer1 for 20kHz PWM (50 microsecond period)
  // Default analogWrite is 490Hz, which causes audible motor whine
  Timer1.initialize(50); 
  Timer1.pwm(PIN_PWMA, 0);
  
  // Verify initialization
  if (Timer1.period != 50) {
    Serial.println("ERROR: Timer1 failed to initialize. Check board variant.");
    while(1); // Halt execution
  }
  
  Serial.println("PWM Motor Control Initialized at 20kHz.");
}

void loop() {
  int rawPot = analogRead(PIN_POT);
  
  // Error Handling / Noise Filtering: Exponential Moving Average
  // Smooths out 50/60Hz mains noise and wiper contact bounce
  smoothedPot = (0.85 * smoothedPot) + (0.15 * rawPot);
  
  // Map 0-1023 to -1023 to 1023 for bidirectional control
  int targetSpeed = map((int)smoothedPot, 0, 1023, -1023, 1023);
  
  // Apply Deadzone
  if (abs(targetSpeed) < DEADZONE) {
    targetSpeed = 0;
  }
  
  // Set Direction Logic
  if (targetSpeed > 0) {
    digitalWrite(PIN_AIN1, HIGH);
    digitalWrite(PIN_AIN2, LOW);
  } else if (targetSpeed < 0) {
    digitalWrite(PIN_AIN1, LOW);
    digitalWrite(PIN_AIN2, HIGH);
  } else {
    digitalWrite(PIN_AIN1, LOW);
    digitalWrite(PIN_AIN2, LOW);
  }
  
  // Kickstart Routine: Overcomes static friction at low duty cycles
  int absSpeed = abs(targetSpeed);
  
  if (absSpeed > 0 && isStopped) {
    Timer1.setPwmDuty(PIN_PWMA, KICKSTART_DUTY);
    delay(KICKSTART_MS);
    isStopped = false;
  } else if (absSpeed == 0) {
    isStopped = true;
  }
  
  // Apply final PWM duty cycle (TimerOne uses 0-1023 for 10-bit resolution)
  Timer1.setPwmDuty(PIN_PWMA, absSpeed);
  
  // Small delay to stabilize ADC readings and prevent watchdog timeouts
  delay(20);
}

Debugging PWM Failures: The First Three Things to Check

When your motor misbehaves, do not immediately rewrite the code. Hardware and physics dictate PWM behavior. Follow this ranked troubleshooting path.

1. Symptom: Motor emits a high-pitched whine or hum

  • Cause: You are using the default analogWrite() function, which runs at 490Hz (or 980Hz on pins 5/6). This frequency falls squarely in the human hearing range, and the rapid magnetic expansion/contraction (magnetostriction) in the motor windings acts as a speaker.
  • Fix: Verify you are using the TimerOne library as shown in the code above. If you see the compiler error fatal error: TimerOne.h: No such file or directory, open the Arduino IDE Library Manager (Ctrl+Shift+I) and install 'TimerOne' by Paul Stoffregen.

2. Symptom: Motor stalls or hums without spinning at low speeds (10-20% duty cycle)

  • Cause: Static friction in the gearbox requires significantly more torque to start moving than to keep moving. A 15% PWM pulse doesn't deliver enough average current to break static friction.
  • Fix: Implement the 'Kickstart Routine' included in the provided code. This applies 100% duty cycle for 50 milliseconds the moment the motor transitions from stopped to moving, breaking friction before dropping to the target low speed.

3. Symptom: Motor jitters or creeps when the potentiometer is centered

  • Cause: Analog-to-Digital Converter (ADC) noise. The Uno's 10-bit ADC will naturally fluctuate by ±2 to ±4 bits even with a stable voltage, causing the motor to rapidly switch directions or pulse at the deadzone edge.
  • Fix: Check the Exponential Moving Average (EMA) filter in the code. If jitter persists, increase the DEADZONE constant from 50 to 100, or add a 0.1µF ceramic capacitor directly between the trimpot wiper pin and GND on your breadboard to filter high-frequency noise.
Safety Note: When debugging motor stalls, the TB6612FNG can handle 3.2A peak, but your 12V power supply might not. If your power supply lacks over-current protection, a stalled motor will draw maximum current indefinitely, potentially melting breadboard jumper wires (24 AWG solid core is only rated for ~3.5A). Always use a power supply with built-in hiccup-mode current limiting.

Extending and Simplifying the Build

Once the baseline 20kHz PWM control is stable, you will likely need to adapt the system for real-world constraints.

How to Simplify (Cost & Space Reduction)

If you only need unidirectional speed control (e.g., a fan or a conveyor belt) and do not need to reverse the motor, drop the TB6612FNG entirely. Replace it with a single IRLB8721 N-Channel MOSFET (approx. $1.50). Connect the Arduino PWM pin to the gate via a 100Ω resistor, the source to GND, and the drain to the motor's negative terminal. This cuts component count and eliminates the H-bridge voltage drop completely.

How to Extend (Closed-Loop Control)

Open-loop PWM assumes the motor always spins at the exact speed dictated by the duty cycle. In reality, mechanical load variations will cause speed droop. To extend this build into a closed-loop system:

  1. Add a quadrature rotary encoder (e.g., 600 PPR) to the motor shaft.
  2. Wire the encoder A/B channels to Arduino Uno pins 2 and 3 (hardware interrupt pins).
  3. Replace the direct Timer1.setPwmDuty() call with a PID controller (using the Arduino PID library). The PID loop will dynamically adjust the PWM duty cycle 100 times a second to maintain a target RPM regardless of the physical load applied to the motor.

By selecting the correct MOSFET driver, pushing the PWM frequency out of the audible spectrum, and handling static friction in software, you eliminate the most common failure modes of Arduino motor control. Stick to the Uno R3 and TimerOne for single-motor benchmarks, and migrate to ESP32 ledc APIs only when your project scales beyond 6 channels.