Go语言开发的命令行MP3播放器
1. 项目概述
本案例是一个基于Go语言开发的命令行MP3播放器,支持批量播放MP3文件、键盘控制切换歌曲、实时进度条显示等功能。项目使用了github.com/hajimehoshi/go-mp3进行MP3解码,github.com/hajimehoshi/oto/v2进行音频播放,以及github.com/eiannone/keyboard进行键盘事件监听。
2. 开发流程图
graph TD
A[启动程序] --> B[解析命令行参数]
B --> C{参数类型}
C -->|单个文件| D[加载单个MP3文件]
C -->|目录| E[遍历目录加载MP3文件列表]
E --> F[根据-n参数限制加载数量]
D --> G[初始化播放控制循环]
F --> G
G --> H[启动键盘监听协程]
H --> I[播放当前文件]
I --> J[显示播放列表和控制提示]
J --> K[播放MP3文件并显示进度条]
K --> L{键盘事件}
L -->|上一首| M[切换到上一首]
L -->|下一首| N[切换到下一首]
L -->|退出| O[退出程序]
L -->|其他| P[忽略无效命令]
M --> G
N --> G
P --> K
运行效果
不带-n参数 默认播放10首歌曲
go run test_goMp3All_stdin.go -dir="mp3"

按下+号键,切换一下首歌曲

带-n 20参数 播放20首歌曲
go run test_goMp3All_stdin.go -dir="mp3" -n 20

带-n 0 参数播放目录中所有歌曲
go run test_goMp3All_stdin.go -dir="mp3" -n 0

3. 功能实现步骤与源代码
3.1 项目结构与依赖
go
package main
import (
"bufio"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
keyboard "github.com/eiannone/keyboard"
mp3 "github.com/hajimehoshi/go-mp3"
oto "github.com/hajimehoshi/oto/v2"
)
// 进度条宽度
const progressBarWidth = 50
3.2 主函数与命令行参数解析
主函数负责解析命令行参数、加载MP3文件列表并启动播放控制循环。
go
func main() {
// 1. 解析命令行参数
filePath := flag.String("file", "", "单个MP3音频文件路径")
dirPath := flag.String("dir", "", "MP3音频文件目录路径")
limit := flag.Int("n", 10, "加载歌曲列表的数量,默认为10")
flag.Parse()
// 存储待播放的MP3文件列表
var mp3Files []string
// 2. 处理参数逻辑
if *dirPath != "" {
// 读取目录下的MP3文件
files, err := listMP3Files(*dirPath, *limit)
if err != nil {
log.Fatalf("读取目录失败:%v", err)
}
if len(files) == 0 {
log.Fatal("指定目录下未找到MP3文件")
}
mp3Files = files
fmt.Printf("📋 待播放MP3列表(共 %d 个):\n", len(mp3Files))
printMP3List(mp3Files, -1) // 初始无播放文件,索引为-1
} else if *filePath != "" {
mp3Files = append(mp3Files, *filePath)
} else {
log.Fatal("请使用 -dir 指定目录 或 -file 指定单个MP3文件路径")
}
// 3. 播放控制循环
currentIdx := 0
totalFiles := len(mp3Files)
// 启动键盘输入监听
inputCh := make(chan string, 10)
go listenKeyboard(inputCh)
for {
// 播放当前文件
fmt.Printf("\n=====================================\n")
fmt.Printf("🎯 开始播放第 %d 个文件:%s\n", currentIdx+1, mp3Files[currentIdx])
printMP3List(mp3Files, currentIdx) // 标记当前播放的文件
fmt.Println("🎮 按 '+' 或 ↓ 切换到下一首,按 '-' 或 ↑ 切换到上一首,按 'q' 退出程序")
// 播放单个MP3文件并处理控制事件
nextIdx, err := playMP3FileWithControlEvents(mp3Files, currentIdx, totalFiles, inputCh)
if err != nil {
log.Printf("播放文件失败:%v", err)
currentIdx++
} else {
// 根据播放函数返回的索引更新当前播放索引
currentIdx = nextIdx
}
// 确保索引在有效范围内
if currentIdx >= totalFiles {
currentIdx = 0 // 循环到第一个
}
if currentIdx < 0 {
currentIdx = totalFiles - 1 // 循环到最后一个
}
}
}
3.3 键盘监听功能
listenKeyboard函数负责监听键盘输入,并将输入转换为控制命令发送到通道。
go
// listenKeyboard 监听键盘输入
func listenKeyboard(inputCh chan<- string) {
// 尝试打开键盘监听
if err := keyboard.Open(); err != nil {
fmt.Printf("❌ 无法打开键盘监听: %v\n", err)
// 回退到标准输入
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
inputCh <- strings.TrimSpace(scanner.Text())
}
return
}
defer keyboard.Close()
// 监听键盘事件
for {
// 读取键盘按键事件(旧版本返回rune类型)
r, key, err := keyboard.GetKey()
if err != nil {
fmt.Printf("❌ 键盘监听错误: %v\n", err)
break
}
// 发送按键到通道
if r != 0 {
// 处理普通字符
inputCh <- string(r)
} else {
// 处理特殊键
switch key {
case keyboard.KeyArrowUp:
inputCh <- "-" // 上箭头相当于"-"(上一首)
case keyboard.KeyArrowDown:
inputCh <- "+" // 下箭头相当于"+"(下一首)
case keyboard.KeyArrowLeft, keyboard.KeyArrowRight:
// 忽略左右箭头
case keyboard.KeyEnter, keyboard.KeyTab, keyboard.KeySpace:
// 忽略这些键
}
}
}
}
3.4 MP3文件列表加载
listMP3Files函数负责遍历指定目录,筛选出MP3文件并返回文件路径列表。
go
// listMP3Files 读取指定目录下的MP3文件,返回前n个
func listMP3Files(dir string, limit int) ([]string, error) {
var mp3Files []string
// 遍历目录
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// 跳过目录,只处理文件
if d.IsDir() {
return nil
}
// 筛选MP3文件(不区分大小写)
ext := strings.ToLower(filepath.Ext(path))
if ext == ".mp3" {
mp3Files = append(mp3Files, path)
// 达到数量限制则停止遍历
if limit > 0 && len(mp3Files) >= limit {
return fs.SkipAll
}
}
return nil
})
if err != nil {
return nil, err
}
return mp3Files, nil
}
3.5 MP3列表打印
printMP3List函数负责打印MP3文件列表,并标记当前正在播放的文件。
go
// printMP3List 打印MP3列表,标记当前播放的文件索引
func printMP3List(files []string, playingIdx int) {
for i, file := range files {
// 简化文件名显示(只显示文件名,不显示完整路径)
filename := filepath.Base(file)
if i == playingIdx {
fmt.Printf(" [%d] 🎶 %s (正在播放)\n", i+1, filename)
} else {
fmt.Printf(" [%d] %s\n", i+1, filename)
}
}
}
3.6 MP3文件播放与控制
playMP3FileWithControlEvents函数负责播放单个MP3文件,显示播放进度条,并处理键盘控制事件。
go
// playMP3FileWithControlEvents 播放单个MP3文件,显示进度条,支持键盘控制切换歌曲
// 返回值:下一个要播放的索引,错误信息
func playMP3FileWithControlEvents(mp3Files []string, currentIdx, totalFiles int, inputCh <-chan string) (int, error) {
// 打开MP3文件
f, err := os.Open(mp3Files[currentIdx])
if err != nil {
return currentIdx + 1, fmt.Errorf("打开文件失败:%v", err)
}
defer func() {
if err := f.Close(); err != nil {
log.Printf("关闭文件失败:%v", err)
}
}()
// 解码MP3文件
decoder, err := mp3.NewDecoder(f)
if err != nil {
return currentIdx + 1, fmt.Errorf("解码MP3失败:%v", err)
}
// 获取音频总长度(字节)
totalBytes := decoder.Length()
if totalBytes == 0 {
return currentIdx + 1, fmt.Errorf("音频文件长度为0")
}
// 初始化音频上下文
otoCtx, ready, err := oto.NewContext(decoder.SampleRate(), 2, 2)
if err != nil {
return currentIdx + 1, fmt.Errorf("初始化音频上下文失败:%v", err)
}
<-ready
// 创建播放器
player := otoCtx.NewPlayer(decoder)
defer func() {
if err := player.Close(); err != nil {
log.Printf("关闭播放器失败:%v", err)
}
}()
// 启动播放
player.Play()
// 计算音频总时长(秒)
// 总字节数 / (采样率 * 声道数 * 每个样本的字节数)
totalDuration := float64(totalBytes) / float64(decoder.SampleRate()*2*2)
// 记录开始播放时间
startTime := time.Now()
// 后台监控播放状态并显示进度条
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
// 持续监控播放状态和键盘事件
for player.IsPlaying() {
select {
case input := <-inputCh:
player.Close() // 立即关闭当前播放器
switch input {
case "-": // 上一首
newIdx := currentIdx - 1
if newIdx < 0 {
newIdx = totalFiles - 1 // 循环到最后一个
fmt.Println("\n🔄 已到达第一首,循环到最后一首")
} else {
fmt.Printf("\n🔼 切换到上一首,当前播放第 %d 首\n", newIdx+1)
}
return newIdx, nil
case "+": // 下一首
newIdx := currentIdx + 1
if newIdx >= totalFiles {
newIdx = 0 // 循环到第一个
fmt.Println("\n🔄 已到达最后一首,循环到第一首")
} else {
fmt.Printf("\n🔽 切换到下一首,当前播放第 %d 首\n", newIdx+1)
}
return newIdx, nil
case "q": // 退出程序
fmt.Println("\n👋 正在退出程序...")
os.Exit(0)
default:
// 如果输入不是控制命令,继续播放
fmt.Printf("⚠️ 无效命令: %s (使用 '+'下一首, '-'上一首, 'q'退出)\n", input)
}
case <-ticker.C:
// 计算已播放时间
elapsedTime := time.Since(startTime).Seconds()
// 计算进度百分比,确保不超过100%
progress := elapsedTime / totalDuration
if progress > 1.0 {
progress = 1.0
}
// 绘制进度条
drawProgressBar(progress, currentIdx+1, totalFiles)
}
}
// 播放完成
drawProgressBar(1.0, currentIdx+1, totalFiles)
fmt.Println("\n✅ 播放完成!")
return currentIdx + 1, nil // 播放自然结束,返回下一个索引
}
3.7 进度条绘制
drawProgressBar函数负责绘制实时播放进度条。
go
// drawProgressBar 绘制播放进度条
func drawProgressBar(progress float64, currentFile, totalFiles int) {
// 计算进度条填充长度
filled := int(progress * progressBarWidth)
// 构建进度条字符串
bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled)
// 计算百分比
percent := int(progress * 100)
// 格式化输出(覆盖当前行)
fmt.Printf("\r[%d/%d] 播放进度: |%s| %d%%",
currentFile, totalFiles, bar, percent)
}
4. 功能特点与使用说明
4.1 功能特点
-
命令行参数支持:
-file:指定单个MP3文件路径-dir:指定MP3文件目录路径-n:限制加载的歌曲数量,默认为10
-
键盘控制:
+或↓:切换到下一首-或↑:切换到上一首q:退出程序
-
实时进度显示:
- 动态进度条显示当前播放进度
- 显示当前播放文件在列表中的位置
-
错误处理:
- 播放失败时自动切换到下一首
- 键盘监听失败时回退到标准输入
4.2 使用示例
bash
# 播放单个MP3文件
go run test_goMp3All_stdin.go -file="mp3/歌曲.mp3"
# 播放目录下的前10个MP3文件
go run test_goMp3All_stdin.go -dir="mp3"
# 播放目录下的前20个MP3文件
go run test_goMp3All_stdin.go -dir="mp3" -n 20
# 播放目录下的所有MP3文件
go run test_goMp3All_stdin.go -dir="mp3" -n 0
4.4 完整源代码
go
package main
import (
"bufio"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
keyboard "github.com/eiannone/keyboard"
mp3 "github.com/hajimehoshi/go-mp3"
oto "github.com/hajimehoshi/oto/v2"
)
//go run test_goMp3All_stdin.go -dir="G:\\MP3\\"
// 进度条宽度
const progressBarWidth = 50
func main() {
// 1. 解析命令行参数
filePath := flag.String("file", "", "单个MP3音频文件路径")
dirPath := flag.String("dir", "", "MP3音频文件目录路径")
limit := flag.Int("n", 10, "加载歌曲列表的数量,默认为10")
flag.Parse()
// 存储待播放的MP3文件列表
var mp3Files []string
// 2. 处理参数逻辑
if *dirPath != "" {
// 读取目录下的MP3文件
files, err := listMP3Files(*dirPath, *limit)
if err != nil {
log.Fatalf("读取目录失败:%v", err)
}
if len(files) == 0 {
log.Fatal("指定目录下未找到MP3文件")
}
mp3Files = files
fmt.Printf("📋 待播放MP3列表(共 %d 个):\n", len(mp3Files))
printMP3List(mp3Files, -1) // 初始无播放文件,索引为-1
} else if *filePath != "" {
mp3Files = append(mp3Files, *filePath)
} else {
log.Fatal("请使用 -dir 指定目录 或 -file 指定单个MP3文件路径")
}
// 3. 播放控制循环
currentIdx := 0
totalFiles := len(mp3Files)
// 启动键盘输入监听
inputCh := make(chan string, 10)
go listenKeyboard(inputCh)
for {
// 播放当前文件
fmt.Printf("\n=====================================\n")
fmt.Printf("🎯 开始播放第 %d 个文件:%s\n", currentIdx+1, mp3Files[currentIdx])
printMP3List(mp3Files, currentIdx) // 标记当前播放的文件
fmt.Println("🎮 按 '+' 或 ↓ 切换到下一首,按 '-' 或 ↑ 切换到上一首,按 'q' 退出程序")
// 播放单个MP3文件并处理控制事件
nextIdx, err := playMP3FileWithControlEvents(mp3Files, currentIdx, totalFiles, inputCh)
if err != nil {
log.Printf("播放文件失败:%v", err)
currentIdx++
} else {
// 根据播放函数返回的索引更新当前播放索引
currentIdx = nextIdx
}
// 确保索引在有效范围内
if currentIdx >= totalFiles {
currentIdx = 0 // 循环到第一个
}
if currentIdx < 0 {
currentIdx = totalFiles - 1 // 循环到最后一个
}
}
}
// listenKeyboard 监听键盘输入
func listenKeyboard(inputCh chan<- string) {
// 尝试打开键盘监听
if err := keyboard.Open(); err != nil {
fmt.Printf("❌ 无法打开键盘监听: %v\n", err)
// 回退到标准输入
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
inputCh <- strings.TrimSpace(scanner.Text())
}
return
}
defer keyboard.Close()
// 监听键盘事件
for {
// 读取键盘按键事件(旧版本返回rune类型)
r, key, err := keyboard.GetKey()
if err != nil {
fmt.Printf("❌ 键盘监听错误: %v\n", err)
break
}
// 发送按键到通道
if r != 0 {
// 处理普通字符
inputCh <- string(r)
} else {
// 处理特殊键
switch key {
case keyboard.KeyArrowUp:
inputCh <- "-" // 上箭头相当于"-"(上一首)
case keyboard.KeyArrowDown:
inputCh <- "+" // 下箭头相当于"+"(下一首)
case keyboard.KeyArrowLeft, keyboard.KeyArrowRight:
// 忽略左右箭头
case keyboard.KeyEnter, keyboard.KeyTab, keyboard.KeySpace:
// 忽略这些键
}
}
}
}
// listMP3Files 读取指定目录下的MP3文件,返回前n个
func listMP3Files(dir string, limit int) ([]string, error) {
var mp3Files []string
// 遍历目录
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// 跳过目录,只处理文件
if d.IsDir() {
return nil
}
// 筛选MP3文件(不区分大小写)
ext := strings.ToLower(filepath.Ext(path))
if ext == ".mp3" {
mp3Files = append(mp3Files, path)
// 达到数量限制则停止遍历
if limit > 0 && len(mp3Files) >= limit {
return fs.SkipAll
}
}
return nil
})
if err != nil {
return nil, err
}
return mp3Files, nil
}
// printMP3List 打印MP3列表,标记当前播放的文件索引
func printMP3List(files []string, playingIdx int) {
for i, file := range files {
// 简化文件名显示(只显示文件名,不显示完整路径)
filename := filepath.Base(file)
if i == playingIdx {
fmt.Printf(" [%d] 🎶 %s (正在播放)\n", i+1, filename)
} else {
fmt.Printf(" [%d] %s\n", i+1, filename)
}
}
}
// playMP3FileWithControlEvents 播放单个MP3文件,显示进度条,支持键盘控制切换歌曲
// 返回值:下一个要播放的索引,错误信息
func playMP3FileWithControlEvents(mp3Files []string, currentIdx, totalFiles int, inputCh <-chan string) (int, error) {
// 打开MP3文件
f, err := os.Open(mp3Files[currentIdx])
if err != nil {
return currentIdx + 1, fmt.Errorf("打开文件失败:%v", err)
}
defer func() {
if err := f.Close(); err != nil {
log.Printf("关闭文件失败:%v", err)
}
}()
// 解码MP3文件
decoder, err := mp3.NewDecoder(f)
if err != nil {
return currentIdx + 1, fmt.Errorf("解码MP3失败:%v", err)
}
// 获取音频总长度(字节)
totalBytes := decoder.Length()
if totalBytes == 0 {
return currentIdx + 1, fmt.Errorf("音频文件长度为0")
}
// 初始化音频上下文
otoCtx, ready, err := oto.NewContext(decoder.SampleRate(), 2, 2)
if err != nil {
return currentIdx + 1, fmt.Errorf("初始化音频上下文失败:%v", err)
}
<-ready
// 创建播放器
player := otoCtx.NewPlayer(decoder)
defer func() {
if err := player.Close(); err != nil {
log.Printf("关闭播放器失败:%v", err)
}
}()
// 启动播放
player.Play()
// 计算音频总时长(秒)
// 总字节数 / (采样率 * 声道数 * 每个样本的字节数)
totalDuration := float64(totalBytes) / float64(decoder.SampleRate()*2*2)
// 记录开始播放时间
startTime := time.Now()
// 后台监控播放状态并显示进度条
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
// 持续监控播放状态和键盘事件
for player.IsPlaying() {
select {
case input := <-inputCh:
player.Close() // 立即关闭当前播放器
switch input {
case "-": // 上一首
newIdx := currentIdx - 1
if newIdx < 0 {
newIdx = totalFiles - 1 // 循环到最后一个
fmt.Println("\n🔄 已到达第一首,循环到最后一首")
} else {
fmt.Printf("\n🔼 切换到上一首,当前播放第 %d 首\n", newIdx+1)
}
return newIdx, nil
case "+": // 下一首
newIdx := currentIdx + 1
if newIdx >= totalFiles {
newIdx = 0 // 循环到第一个
fmt.Println("\n🔄 已到达最后一首,循环到第一首")
} else {
fmt.Printf("\n🔽 切换到下一首,当前播放第 %d 首\n", newIdx+1)
}
return newIdx, nil
case "q": // 退出程序
fmt.Println("\n👋 正在退出程序...")
os.Exit(0)
default:
// 如果输入不是控制命令,继续播放
fmt.Printf("⚠️ 无效命令: %s (使用 '+'下一首, '-'上一首, 'q'退出)\n", input)
}
case <-ticker.C:
// 计算已播放时间
elapsedTime := time.Since(startTime).Seconds()
// 计算进度百分比,确保不超过100%
progress := elapsedTime / totalDuration
if progress > 1.0 {
progress = 1.0
}
// 绘制进度条
drawProgressBar(progress, currentIdx+1, totalFiles)
}
}
// 播放完成
drawProgressBar(1.0, currentIdx+1, totalFiles)
fmt.Println("\n✅ 播放完成!")
return currentIdx + 1, nil // 播放自然结束,返回下一个索引
}
// drawProgressBar 绘制播放进度条
func drawProgressBar(progress float64, currentFile, totalFiles int) {
// 计算进度条填充长度
filled := int(progress * progressBarWidth)
// 构建进度条字符串
bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled)
// 计算百分比
percent := int(progress * 100)
// 格式化输出(覆盖当前行)
fmt.Printf("\r[%d/%d] 播放进度: |%s| %d%%",
currentFile, totalFiles, bar, percent)
}
5. 总结与后续功能扩展
5.1 项目总结
本项目成功实现了一个基于Go语言的命令行MP3播放器,具有以下特点:
- 简洁高效:使用Go语言的协程和通道实现异步键盘监听和播放控制
- 跨平台:使用纯Go语言依赖,理论上支持所有Go语言支持的平台
- 易于扩展:模块化设计,各功能模块独立,便于后续扩展
- 用户友好:清晰的命令行提示和实时进度显示
5.2 后续功能扩展建议
-
播放控制增强:
- 添加暂停/继续功能
- 支持快进/快退
- 音量控制
-
播放模式支持:
- 顺序播放
- 随机播放
- 单曲循环
- 列表循环
-
元数据显示:
- 读取并显示MP3文件的ID3标签信息(歌名、歌手、专辑等)
-
可视化效果:
- 添加音频频谱可视化
- 更丰富的进度条样式
-
配置文件支持:
- 支持通过配置文件保存播放历史和偏好设置
-
网络功能:
- 支持网络MP3文件播放
- 在线音乐搜索和播放
-
GUI界面:
- 开发图形用户界面,提升用户体验
通过这些扩展,可以将这个简单的命令行MP3播放器发展成为一个功能更加完善的音乐播放应用。