Skip to content

Commit

Permalink
Merge pull request #26 from braheezy/player-tui
Browse files Browse the repository at this point in the history
feat: Add TUI for play command
  • Loading branch information
braheezy authored Apr 13, 2024
2 parents 1068937 + ee1af1b commit 6762e72
Show file tree
Hide file tree
Showing 13 changed files with 547 additions and 203 deletions.
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
name: ci

on:
push:
workflow_dispatch:

concurrency:
group: ${{ github.ref }}
cancel-in-progress: true

jobs:
build-windows:
runs-on: windows-latest
Expand Down Expand Up @@ -40,7 +38,6 @@ jobs:
with:
name: windows
path: goqoa.exe

build-mac:
runs-on: macos-latest
env:
Expand Down Expand Up @@ -68,7 +65,6 @@ jobs:
with:
name: mac
path: goqoa-mac

build-linux:
runs-on: ubuntu-latest
env:
Expand Down Expand Up @@ -115,7 +111,6 @@ jobs:
with:
name: linux
path: goqoa-linux

release:
if: startsWith(github.ref, 'refs/tags/')
needs:
Expand All @@ -124,7 +119,6 @@ jobs:
- build-linux
permissions:
contents: write

runs-on: ubuntu-latest
steps:
- name: Checkout code
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ dist/
/fuzz/*.qoa
/assets/*.gif
*.prof
/output*
__debug_bin*
/output_*
/*.wav
__debug_bin*
/*.qoa
TODO
/raw_output.txt
29 changes: 29 additions & 0 deletions cmd/keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cmd

import "github.com/charmbracelet/bubbles/key"

type helpKeyMap struct {
togglePlay key.Binding
quit key.Binding
}

var helpsKeys = helpKeyMap{
togglePlay: key.NewBinding(
key.WithKeys(" ", "p"),
key.WithHelp("space/p", "play/pause"),
),
quit: key.NewBinding(
key.WithKeys("q", "esc", "ctrl+c"),
key.WithHelp("q/esc", "quit"),
),
}

func (k helpKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.togglePlay, k.quit}
}
func (k helpKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.togglePlay}, // first column
{k.quit}, // second column
}
}
166 changes: 47 additions & 119 deletions cmd/play.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,141 +2,69 @@ package cmd

import (
"fmt"
"io"
"os"
"time"
"path/filepath"

"github.com/braheezy/goqoa/pkg/qoa"
"github.com/ebitengine/oto/v3"
"github.com/spf13/cobra"
)

var playCmd = &cobra.Command{
Use: "play <input-file>",
Use: "play <file/directories>",
Short: "Play .qoa audio file(s)",
Long: "Provide one or more QOA files to play.",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
playQOA(args[0:])
// Input is one or more files or directories. Find all QOA files, recursively.
var allFiles []string
for _, arg := range args {
info, err := os.Stat(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error accessing %s: %v\n", arg, err)
continue
}
if info.IsDir() {
files, err := findAllQOAFiles(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error walking %s: %v\n", arg, err)
continue
}
allFiles = append(allFiles, files...)
} else {
valid, err := qoa.IsValidQOAFile(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking file %s: %v\n", arg, err)
continue
}
if valid {
allFiles = append(allFiles, arg)
}
}
}
startTUI(allFiles)
},
}

func init() {
rootCmd.AddCommand(playCmd)
}

func isValidQOAFile(inputFile string) (bool, error) {
// Read first 4 bytes of the file
fileBytes := make([]byte, 4)
file, err := os.Open(inputFile)
if err != nil {
return false, err
}
defer file.Close()

_, err = file.Read(fileBytes)
if err != nil && err != io.EOF {
return false, err
}

// Check if the first 4 bytes are magic word `qoaf`
if string(fileBytes) != "qoaf" {
return false, fmt.Errorf("no magic word 'qoaf' found in %s", inputFile)
}
return true, nil
}
func playQOA(inputFiles []string) {

// Prepare an Oto context (this will use your default audio device)
ctx, ready, err := oto.NewContext(
&oto.NewContextOptions{
SampleRate: 44100,
ChannelCount: 2,
Format: oto.FormatSignedInt16LE,
})
if err != nil {
panic("oto.NewContext failed: " + err.Error())
}

for _, inputFile := range inputFiles {
_, err := isValidQOAFile(inputFile)
if err != nil {
logger.Fatalf("Error validating QOA file: %v", err)
}

qoaBytes, err := os.ReadFile(inputFile)
// Recursive function to find all valid QOA files
func findAllQOAFiles(root string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
logger.Fatalf("Error reading QOA file: %v", err)
return err
}

// Decode the QOA audio data
qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes)
if err != nil {
logger.Fatalf("Error decoding QOA data: %v", err)
if !info.IsDir() {
valid, err := qoa.IsValidQOAFile(path)
if err != nil {
return err
}
if valid {
files = append(files, path)
}
}

// Wait for the context to be ready
<-ready

// Create a new player with the custom QOAAudioReader
player := ctx.NewPlayer(NewQOAAudioReader(qoaAudioData))

// Play the audio
logger.Debug(
"Starting audio",
"File",
inputFile,
"SampleRate",
qoaMetadata.SampleRate,
"ChannelCount",
qoaMetadata.Channels,
"BufferedSize",
player.BufferedSize())
player.Play()

for player.IsPlaying() {
time.Sleep(time.Millisecond)
}

// Close the player
if err := player.Close(); err != nil {
logger.Fatalf("Error closing player: %v", err)
}
}
}

// NewQOAAudioReader creates a new QOAAudioReader instance.
func NewQOAAudioReader(data []int16) *QOAAudioReader {
return &QOAAudioReader{
data: data,
pos: 0,
}
}

// QOAAudioReader is a custom io.Reader that reads from QOA audio data.
type QOAAudioReader struct {
data []int16
pos int
return nil
})
return files, err
}

func (r *QOAAudioReader) Read(p []byte) (n int, err error) {
samplesToRead := len(p) / 2

if r.pos >= len(r.data) {
// Return EOF when there is no more data to read
return 0, io.EOF
}

if samplesToRead > len(r.data)-r.pos {
samplesToRead = len(r.data) - r.pos
}

for i := 0; i < samplesToRead; i++ {
sample := r.data[r.pos]
p[i*2] = byte(sample & 0xFF)
p[i*2+1] = byte(sample >> 8)
r.pos++
}

return samplesToRead * 2, nil
func init() {
rootCmd.AddCommand(playCmd)
}
43 changes: 43 additions & 0 deletions cmd/qoaaudioreader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import "io"

// NewQOAAudioReader creates a new QOAAudioReader instance.
func NewQOAAudioReader(data []int16) *QOAAudioReader {
return &QOAAudioReader{
data: data,
pos: 0,
}
}

// QOAAudioReader is a custom io.Reader that reads from QOA audio data.
type QOAAudioReader struct {
data []int16
pos int
}

func (r *QOAAudioReader) Read(p []byte) (n int, err error) {
samplesToRead := len(p) / 2

if r.pos >= len(r.data) {
// Return EOF when there is no more data to read
return 0, io.EOF
}

if samplesToRead > len(r.data)-r.pos {
samplesToRead = len(r.data) - r.pos
}

for i := 0; i < samplesToRead; i++ {
sample := r.data[r.pos]
p[i*2] = byte(sample & 0xFF)
p[i*2+1] = byte(sample >> 8)
r.pos++
}

return samplesToRead * 2, nil
}

func (r *QOAAudioReader) SamplesPlayed() int {
return r.pos
}
18 changes: 18 additions & 0 deletions cmd/style.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cmd

import "github.com/charmbracelet/lipgloss"

const (
padding = 4
maxWidth = 60
qoaRed = "#7b2165"
qoaPink = "#dd81c7"
black = "#191724"
)

var (
statusStyle = lipgloss.NewStyle().
Italic(true).
Padding(1, 1).
Foreground(lipgloss.Color(qoaPink))
)
Loading

0 comments on commit 6762e72

Please sign in to comment.