Go语言开发的命令行MP3播放器

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 功能特点

  1. 命令行参数支持

    • -file:指定单个MP3文件路径
    • -dir:指定MP3文件目录路径
    • -n:限制加载的歌曲数量,默认为10
  2. 键盘控制

    • +:切换到下一首
    • -:切换到上一首
    • q:退出程序
  3. 实时进度显示

    • 动态进度条显示当前播放进度
    • 显示当前播放文件在列表中的位置
  4. 错误处理

    • 播放失败时自动切换到下一首
    • 键盘监听失败时回退到标准输入

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播放器,具有以下特点:

  1. 简洁高效:使用Go语言的协程和通道实现异步键盘监听和播放控制
  2. 跨平台:使用纯Go语言依赖,理论上支持所有Go语言支持的平台
  3. 易于扩展:模块化设计,各功能模块独立,便于后续扩展
  4. 用户友好:清晰的命令行提示和实时进度显示

5.2 后续功能扩展建议

  1. 播放控制增强

    • 添加暂停/继续功能
    • 支持快进/快退
    • 音量控制
  2. 播放模式支持

    • 顺序播放
    • 随机播放
    • 单曲循环
    • 列表循环
  3. 元数据显示

    • 读取并显示MP3文件的ID3标签信息(歌名、歌手、专辑等)
  4. 可视化效果

    • 添加音频频谱可视化
    • 更丰富的进度条样式
  5. 配置文件支持

    • 支持通过配置文件保存播放历史和偏好设置
  6. 网络功能

    • 支持网络MP3文件播放
    • 在线音乐搜索和播放
  7. GUI界面

    • 开发图形用户界面,提升用户体验

通过这些扩展,可以将这个简单的命令行MP3播放器发展成为一个功能更加完善的音乐播放应用。

相关推荐
wwz1614 小时前
Dagor —— 一个高性能 DAG 算子执行框架,开箱即用!
go
源代码•宸15 小时前
goframe框架签到系统项目开发(补签逻辑实现、编写Lua脚本实现断签提醒功能、简历示例)
数据库·后端·中间件·go·lua·跨域·refreshtoken
Grassto2 天前
Go Module 的版本选择算法:Minimal Version Selection(MVS)
后端·golang·go·go module
汪小成2 天前
Go CLI 入口设计:参数解析、错误处理与项目分层实战
后端·go
gitboyzcf2 天前
Go(GoLang)语言基础、知识速查
后端·go
汪小成3 天前
Go 项目结构总是写乱?这个 50 行代码的 Demo 教你标准姿势
后端·go
littleschemer4 天前
go结构体扫描
游戏·go·解析·struct
一只鱼丸yo5 天前
服务容错:限流、熔断、降级如何落地?
微服务·架构·go
rocksun6 天前
Neovim,会是你的下一款“真香”开发神器吗?
linux·python·go