Skip to main content

Play music with speaker

Welcome to this tutorial about speakers! If you've ever listened to music, watched a movie, or even heard the sound of your own voice through a microphone, you've encountered a speaker. In this tutorial, you'll explore the components of a speaker, how they work, and how to use them in your own projects. Let's get started!

Learning goals

  • Learn how a speaker produces different sounds.
  • Understand the difference between buzzer and speaker.
  • Have a general idea of I2S protocol.
  • Know about different sound waveforms.
  • Learn about audio sampling.
  • Realize the difference between WAVE and MP3 files.

🔸Circuit - Speaker

The speaker connects to the chip MAX98357. And the chip connects to I2S0 (SYNC0, BCLK0, TX0).

Speaker circuit
Speaker PinSwiftIO Micro Pin
3V33V3
GNDGND
SD-
RXI2S0 (TX)
BCLKI2S0 (BCLK)
SYNCI2S0 (SYNC)
Speaker circuit diagram
note

The circuits above are simplified versions for your reference. Download the schematics here.

info

By connecting the pin SD on the Speaker module to GND directly, you can set the MAX98357 into shutdown mode.

🔸Projects

1. Playing scales

Different waveforms can generate different sounds. In this project, you will generate a square wave and a triangle wave manually. Then play scales using two sounds.

Example code

You can download the project source code here.

main.swift
// Import the SwiftIO library to control input and output.
import SwiftIO
// Import the MadBoard to use the id of the pins.
import MadBoard


// Initialize the speaker using I2S communication.
// The default setting is 16k sample rate, 16bit sample bits.
let speaker = I2S(Id.I2S0)

// The frequencies of note C to B in octave 4.
let frequency: [Float] = [
261.626,
293.665,
329.628,
349.228,
391.995,
440.000,
493.883
]

// Set the samples of the waveforms.
let sampleRate = 16_000
let rawSampleLength = 1000
var rawSamples = [Int16](repeating: 0, count: rawSampleLength)
var amplitude: Int16 = 10_000

while true {

let duration: Float = 1.0

// Iterate through the frequencies from C to B to play a scale.
// The sound waveform is a square wave, so you will hear a buzzing sound.
generateSquare(amplitude: amplitude, &rawSamples)
for f in frequency {
playWave(samples: rawSamples, frequency: f, duration: duration)
}
sleep(ms: 1000)

// Iterate through the frequencies from C to B to play a scale.
// The sound waveform is a triangle wave, and the sound is much softer.
generateTriangle(amplitude: amplitude, &rawSamples)
for f in frequency {
playWave(samples: rawSamples, frequency: f, duration: duration)
}
sleep(ms: 1000)

// Decrease the amplitude to lower the sound.
// If it's smaller than zero, it restarts from 20000.
amplitude -= 1000
if amplitude <= 0 {
amplitude = 10_000
}
}

// Generate samples for a square wave with a specified amplitude and store them in an array.
func generateSquare(amplitude: Int16, _ samples: inout [Int16]) {
let count = samples.count
for i in 0..<count / 2 {
samples[i] = -amplitude
}
for i in count / 2..<count {
samples[i] = amplitude
}
}

// Generate samples for a triangle wave with a specified amplitude and store the them in an array.
func generateTriangle(amplitude: Int16, _ samples: inout [Int16]) {
let count = samples.count

let step = Float(amplitude) / Float(count / 2)
for i in 0..<count / 4 {
samples[i] = Int16(step * Float(i))
}
for i in count / 4..<count / 4 * 3 {
samples[i] = amplitude - Int16(step * Float(i))
}
for i in count / 4 * 3..<count {
samples[i] = -amplitude + Int16(step * Float(i))
}
}

// Send the samples over I2s bus and play the note with a specified frequency and duration.
func playWave(samples: [Int16], frequency: Float, duration: Float) {
let playCount = Int(duration * Float(sampleRate))
var data = [Int16](repeating: 0, count: playCount)

let step: Float = frequency * Float(samples.count) / Float(sampleRate)

var volume: Float = 1.0
let volumeStep = 1.0 / Float(playCount)

for i in 0..<playCount {
let pos = Int(Float(i) * step) % samples.count
data[i] = Int16(Float(samples[pos]) * volume)
volume -= volumeStep
}
data.withUnsafeBytes { ptr in
let u8Array = ptr.bindMemory(to: UInt8.self)
speaker.write(Array(u8Array))
}
}

Code analysis

import SwiftIO
import MadBoard

Import the SwiftIO library to set I2S communication and the MadBoard to use pin ids.

let speaker = I2S(Id.I2S0)

Initialize an I2S interface reserved for the speaker. It will have a 16k sample rate and 16-bit sample depth by default.

let frequency: [Float] = [261.626, 293.665, 329.628, 349.228, 391.995, 440.000, 493.883]

Store frequencies for note C, D, E, F, G, A, B in octave 4. That constitutes a scale, which will be played by the speaker.

let sampleRate = 16_000
let rawSampleLength = 1000
var rawSamples = [Int16](repeating: 0, count: rawSampleLength)
var amplitude: Int16 = 10_000

Define the parameters for the audio data:

  • The signal is sampled at 16000 Hz, so there will be 16000 data per second.
  • rawSampleLength decides the count of samples of the generated waves in a period.
  • rawSamples stores the samples of the audio signal in a period. At first, all values are filled with 0 and the count is decided by rawSampleLength.
  • amplitude is the peak value of the wave and should be positive.
func generateSquare(amplitude: Int16, _ samples: inout [Int16]) {
let count = samples.count
for i in 0..<count / 2 {
samples[i] = -amplitude
}
for i in count / 2..<count {
samples[i] = amplitude
}
}

This newly defined function allows you to generate a periodic square wave. You only need to calculate the samples in one period. The other periods of waves will repeat these samples. The parameter samples needs an array to store the audio data, so it is set as inout to be changed inside the function.

A square wave has only two states (0 and 1), so the calculation is quite simple. The first half samples are all negative, and samples of the second half are positive. Their values are all decided by the parameter amplitude.

Generate square wave
func generateTriangle(amplitude: Int16, _ samples: inout [Int16]) {
let count = samples.count

let step = Float(amplitude) / Float(count / 2)
for i in 0..<count / 4 {
samples[i] = Int16(step * Float(i))
}
for i in count / 4..<count / 4 * 3 {
samples[i] = amplitude - Int16(step * Float(i))
}
for i in count / 4 * 3..<count {
samples[i] = -amplitude + Int16(step * Float(i))
}
}

This function is used to generate samples for a triangle wave in a period. The constant count is the total of audio samples. The step is the change between two continuous samples.

The samples change linearly and are divided into three parts:

  • At first, the samples gradually increase to the maximum (amplitude).
  • In the second part, the samples decrease from the maximum (amplitude) to the minimum (minus amplitude).
  • In the third part, the samples go up from the minimum (minus amplitude).
Generate triangle wave
func playWave(samples: [Int16], frequency: Float, duration: Float) {
let playCount = Int(duration * Float(sampleRate))
var data = [Int16](repeating: 0, count: playCount)

let step: Float = frequency * Float(samples.count) / Float(sampleRate)

var volume: Float = 1.0
let volumeStep = 1.0 / Float(playCount)

for i in 0..<playCount {
let pos = Int(Float(i) * step) % samples.count
data[i] = Int16(Float(samples[pos]) * volume)
volume -= volumeStep
}
data.withUnsafeBytes { ptr in
let u8Array = ptr.bindMemory(to: UInt8.self)
speaker.write(Array(u8Array))
}
}

This function sends the samples to audio devices over an I2S bus.

  • playCount calculates the total amount of samples. sampleRate is the amount of samples in 1s, and duration is a specified time in seconds. If the note duration is 2 seconds and the sample rate is 16000Hz, the sample count equals 32000.

  • The array data is used to store the audio data for the speaker. All elements are 0 by default, whose count equals the count of samples calculated before.

  • To better understand the constant step, assuming a square wave that has 20 samples in a period. Its frequency is 2Hz. Therefore, there will be 40 samples in total in one second. If the audio sample rate is at 10Hz, it needs only 10 samples in one second. So you can choose some of the samples: 1 sample every 4 samples, like samples[0], samples[4]... So the step here is 20 * 2 / 10 = 4.

Step
  • volume and volumeStep are used to reduce the volume of each note, so it sounds more natural. You could delete the statement volume -= volumeStep and see how it sounds. If the playCount is 10, the volume will be 1, 0,9, 0.8... for each data to fade out the sound.

  • In the for-in loop, you will store the desired samples into the array data. pos gets the index of the sample in samples. In the wave above, the pos is 0, 4, 8, 12, 16. When pos equals 20, it refers to the first sample in the next period. The samples are the same with those in the first peropd, so it restarts from 0. After the samples are multiplied by volume, you get gradually decreased sound.

  • Send the data using I2S communication so that the speaker plays the note.

while true {
let duration: Float = 1.0

generateSquare(amplitude: amplitude, &rawSamples)
for f in frequency {
playWave(samples: rawSamples, frequency: f, duration: duration)
}
sleep(ms: 1000)

generateTriangle(amplitude: amplitude, &rawSamples)
for f in frequency {
playWave(samples: rawSamples, frequency: f, duration: duration)
}
sleep(ms: 1000)

amplitude -= 1000
if amplitude <= 0 {
amplitude = 10_000
}
}

In the while loop, the speaker will play scales over and over again.

  • At first, the samples are generated from a square wave. Then use these samples to play a scale. So the sound is like what you hear from a buzzer.
  • After that, the samples are from a triangle wave. So the sound is softer and clearer.
  • The amplitude decreases to turn down the speaker. The sound will be lower after each while loop until it reaches the minimum. Then amplitude increase to the maximum and repeats the variation.

2. Music player

Play music using the speaker. You can also pass other scores to play the music.

Music notes

Let's explore some common concepts in music together.

A standard piano keyboard typically consists of 88 keys, ranging from A0 to C8. Each key represents a different musical pitch or frequency from low to high.

Piano keyboard

A half step, or semitone, is the smallest interval between notes. The interval between the key A and key A# is a half step. Two half steps constitute a whole step, like the interval between A and B.

Beat is a basic unit of time in music. When you tap your toes along with a song, you actually follow its beat.

In music, all beats are divided into multiple sections, called measures or bars. A measure usually consists of several beats.

A quarter note is the common note length in music and has one beat. Then other notes are based on it: a half note has two beats, a whole note has four beats, an eighth note has a half beat, etc.

Time signature describes the count of beats in a measure and tells which note serves as one beat. 4/4 time signature is the most widely used. In this case, a measure has 4 beats, and a quarter note is one beat, an eighth note is one half beat, etc. 2/4 means 2 beats per measure and a quarter note serves as one beat. You could learn more about it here.

BPM, or beat per minute, measures the tempo of a piece of music. For example, 60 BPM means 60 beats in a minute, and each one lasts 1 second.

Project overview

  • The score stores the frequency and duration of each note.
  • Generate the samples by calculating the sine value at each phase.
  • To create a fading effect, the amplitude values of the last samples of each note are decreased gradually over a specified fading duration.
  • If there are multiple tracks, the samples from each track can be averaged at specified time intervals.
  • Then send the samples to the speaker using I2S communication.

Example code

You can download the project source code here.

// Play a song using a speaker.
import SwiftIO
import MadBoard


// The sample rate of I2S and Player should be the same.
// Note: the speaker needs stereo channel but only uses the samples of left channel.
// And the frequencies below 200Hz may sound a little fuzzy with this speaker.
let speaker = I2S(Id.I2S0, rate: 16_000)

// BPM is beat count per minute.
// Timer signature specifies beats per bar and note value of a beat.
let player = Player(speaker, sampleRate: 16_000)

player.bpm = Mario.bpm
player.timeSignature = Mario.timeSignature

// Play the music using the tracks.
player.playTracks(Mario.tracks, waveforms: Mario.trackWaveforms, amplitudeRatios: Mario.amplitudeRatios)

while true {
sleep(ms: 1000)
}