esp32+max98357做网络音箱

By qq84628151 没有评论

1.从github下载源码用zip方式,https://github.com/pschatzmann/arduino-audio-tools

2.导入到Arduino IDE,项目->导入库->添加.zip库

3.根据需要解码的音频下载安装对应的库,参考文档https://github.com/pschatzmann/arduino-audio-tools/wiki/Encoding-and-Decoding-of-Audiohttps://github.com/pschatzmann/arduino-audio-tools/wiki/Encoding-and-Decoding-of-Audio

4.需要的材料: esp32开发板、Max98357、喇叭

5.接线

6.将代码烧录到esp32,这里我用Mp3解码,因为直接读mp3文件不用转码,如果是从麦克风之类的采集音频数据的话一般是PCM数据,PCM数据建议使用Opus编码

#include "AudioTools.h"
#include <WiFi.h>
#include <WiFiUdp.h>
#include "AudioTools/AudioCodecs/CodecMP3Helix.h"

WiFiUDP udp;
const int localPort = 12345;
const int BUFFER_SIZE = 1024;
uint8_t buffer[BUFFER_SIZE];

I2SStream i2s;                                            //I2S流
VolumeStream volume(i2s);                                 //音量流
EncodedAudioStream dec(&volume, new MP3DecoderHelix());  //Opus解码流

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_AP);                     //WIFI热点模式
  WiFi.softAP("test", "123456789");       //账号密码

  auto cfg = i2s.defaultConfig(TX_MODE);  //输出模式
  cfg.sample_rate = 48000;                //采样率
  cfg.channels = 2;                       //通道
  cfg.bits_per_sample = 16;               //
  cfg.pin_bck = 26;                       //esp32的26引脚接max98357的BCLK
  cfg.pin_ws = 25;                        //esp32的25引脚接max98357的LRC
  cfg.pin_data = 22;                      //esp32的22引脚接max98357的DIN
  i2s.begin(cfg);

  auto dcfg = i2s.defaultConfig();
  dcfg.copyFrom(cfg);
  dec.begin(dcfg);

  auto vcfg = volume.defaultConfig();
  vcfg.copyFrom(cfg);
  volume.begin(vcfg);
  volume.setVolume(0.7);  //设置音量

  udp.begin(localPort);
  IPAddress IP = WiFi.softAPIP();
  Serial.print("AP IP address: ");
  Serial.println(IP);                           //从串口中打印本机IP
}

void loop() {
  int packetSize = udp.parsePacket();           //检查是否有udp数据包并返回数据包大小
  if (packetSize) {
    int len = udp.read(buffer, BUFFER_SIZE);    //从udp数据包读取数据
    dec.write(buffer, len);//将数据写入到Opus解码流中
  }
}

7.这里esp32使用了热点模式,电脑wifi连接热点,然后打开串口看下esp32的ip

8.下载ffmpeg,使用命令推流mp3,记得将ip改为你esp32的ip,还有2.mp3也要改成你电脑上的mp3文件

ffmpeg -re -i 2.mp3 -c:a libmp3lame -vn -ar 48000 -ac 2  -f mp3 udp://192.168.4.1:12345

9.这里给出安卓代码参考,代码只是简单作为参考,不能实际使用,其中原因之一是线程休眠不精准,需要使用高精度定时器或者多媒体定时器代码里的mp3文件我先存http服务,可以选择先放sd卡再读取,一共2个ip,一个是本机读取mp3文件的ip,一个是esp32的ip,本机ip不能使用127.0.0.1

package com.example.myapplication

import android.app.Activity
import android.media.MediaExtractor
import android.media.MediaFormat
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.example.myapplication.ui.theme.MyApplicationTheme
import java.io.IOException
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.nio.ByteBuffer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp


class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MyApplicationTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }

    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val activity = context as Activity
    val isButtonEnabled = remember { mutableStateOf(true) }
    var sliderPosition by remember { mutableStateOf(0f) }
    var isSeek = remember { mutableStateOf(false) }

    Box(modifier = Modifier.fillMaxSize().padding(10.dp),
        contentAlignment = Alignment.Center){
        Column {
            Slider(value = sliderPosition,
                onValueChange = { sliderPosition = it },
                valueRange = 0f..1f,
                onValueChangeFinished = {
                    synchronized(isSeek){
                        isSeek.value = true
                    }
                }
            )
            Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center){
                Button(enabled = isButtonEnabled.value, onClick = {
                    isButtonEnabled.value = false;
                    Thread {
                        val socket = DatagramSocket()
                        val IPAddress = InetAddress.getByName("192.168.4.1")
                        var port = 12345;
                        var previousPresentationTimeUs = 0L

                        try {
                            val extractor = MediaExtractor()
                            extractor.setDataSource("http://192.168.4.2:8012/2.mp3")
                            val buffer = ByteBuffer.allocate(500 * 1024)
                            var sampleSize = 0;
                            extractor.selectTrack(0);
                            val format: MediaFormat = extractor.getTrackFormat(0)
                            var duration = format.getLong(MediaFormat.KEY_DURATION);

                            while(true){
                                if(isSeek.value){
                                    isSeek.value = false
                                    extractor.seekTo((duration.toFloat() * sliderPosition.toFloat()).toLong(), MediaExtractor.SEEK_TO_CLOSEST_SYNC);
                                    previousPresentationTimeUs = 0
                                }

                                sampleSize = extractor.readSampleData(buffer, 0)
                                if (sampleSize <= 0) {
                                    break
                                }
                                val mp3Frame = ByteArray(sampleSize.toInt())
                                buffer.rewind()
                                buffer.get(mp3Frame)

                                val presentationTimeUs = extractor.sampleTime

                                activity.runOnUiThread {
                                    sliderPosition =  (extractor.sampleTime.toFloat() / duration.toFloat()).coerceIn(0F, 100F)
                                }

                                var timeDiffMs = 0L
                                if (previousPresentationTimeUs > 0) {
                                    timeDiffMs = (presentationTimeUs - previousPresentationTimeUs) / 1000
                                    Thread.sleep(timeDiffMs-1)
                                }
                                previousPresentationTimeUs = presentationTimeUs

                                val sendPacket = DatagramPacket(mp3Frame, mp3Frame.size, IPAddress, port)
                                socket.send(sendPacket)
                                extractor.advance()
                            }
                        } catch (e: IOException) {
                            e.printStackTrace()
                        }
                        activity.runOnUiThread {
                            isButtonEnabled.value = true;
                        }
                    }.start()
                }) {
                    Text("esp32播放mp3")
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    MyApplicationTheme {
        Greeting("Android")
    }
}

以下是安卓运行效果:

为什么从麦克风读取PCM数据用Opus而不用Mp3,原因之一就是网络传输,并且个人感觉解码opsu播放比解码mp3播放要快一些,当然这是在esp32测试的,电脑上编解码音频耗时几乎忽略不计,视频编解码则能用GPU编解码就用

对比项OpusMP3
压缩效率高(低码率音质更好)中(低码率易失真)
音质宽频表现佳,语音 / 音乐混合更自然中高码率音质稳定,低码率差
码率范围6kbps – 510kbps(灵活可调)32kbps – 320kbps(常见 128kbps+)
专利开源(免费)受专利保护(需付费)
兼容性现代设备支持好,旧设备可能受限全平台兼容(几乎所有设备支持)
应用场景实时通信(视频会议、直播、VoIP)、网络音频流(如游戏语音)音乐存储播放、音频文件共享、传统多媒体内容
文件大小同音质下更小同音质下更大