From 514bbb0de9d6afe9f3575afb4d3a405a73b07a71 Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 09:32:21 -0700 Subject: [PATCH 01/11] Don't run upx on windows --- .github/workflows/ci.yml | 38 +++----------------------------------- 1 file changed, 3 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a1f827..583ee54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,10 @@ name: ci - -on: - push: - workflow_dispatch: - +"on": + push: null + workflow_dispatch: null concurrency: group: ${{ github.ref }} cancel-in-progress: true - jobs: build-windows: runs-on: windows-latest @@ -16,31 +13,20 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.21" - - name: Install dependencies run: go get ./... - - name: Build run: go build -ldflags="-s -w" -gcflags=all="-l -B" -trimpath -buildvcs=false -v . - - - name: Run UPX - uses: crazy-max/ghaction-upx@v3 - with: - files: goqoa.exe - - name: Go Test run: go test -v ./... - - uses: actions/upload-artifact@v4 with: name: windows path: goqoa.exe - build-mac: runs-on: macos-latest env: @@ -49,26 +35,20 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.21" - - name: Install dependencies run: go get ./... - - name: Build run: go build -ldflags="-s -w" -gcflags=all="-l -B" -trimpath -buildvcs=false -o goqoa-mac -v . - - name: Go Test run: go test -v ./... - - uses: actions/upload-artifact@v4 with: name: mac path: goqoa-mac - build-linux: runs-on: ubuntu-latest env: @@ -77,45 +57,36 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.21" - - name: Install dependencies run: | go get ./... sudo apt-get update sudo apt-get install -y libasound2-dev - - name: Build run: | go build -ldflags="-s -w" -gcflags=all="-l -B" -trimpath -buildvcs=false -o goqoa-linux -v . upx --best goqoa-linux - - name: Go Test run: go test -v ./... - - name: Cache large spec pack uses: actions/cache@v3 with: key: qoa_test_samples_2023_02_18.zip path: qoa_test_samples_2023_02_18.zip - - name: Download large spec pack run: wget --timestamping https://qoaformat.org/samples/qoa_test_samples_2023_02_18.zip - - name: Spec Test run: | sudo cp goqoa-linux /usr/bin/goqoa bash check_spec.sh - - uses: actions/upload-artifact@v4 with: name: linux path: goqoa-linux - release: if: startsWith(github.ref, 'refs/tags/') needs: @@ -124,14 +95,11 @@ jobs: - build-linux permissions: contents: write - runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - - name: Release uses: softprops/action-gh-release@v1 with: From ad1d9ebbafaa5dfbe1a23c9ebb5f92b0f9a269c0 Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 13:38:42 -0700 Subject: [PATCH 02/11] Add basic TUI player --- cmd/play.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++------ go.mod | 10 ++++ go.sum | 20 ++++++++ 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/cmd/play.go b/cmd/play.go index 7bd6d2d..0177097 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -4,9 +4,12 @@ import ( "fmt" "io" "os" + "strings" "time" "github.com/braheezy/goqoa/pkg/qoa" + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" "github.com/ebitengine/oto/v3" "github.com/spf13/cobra" ) @@ -25,6 +28,122 @@ func init() { rootCmd.AddCommand(playCmd) } +type tickMsg time.Time + +type playerMsg int + +const ( + start playerMsg = iota + stop +) + +type model struct { + filename string + player *oto.Player + progress progress.Model + ctx *oto.Context + qoaData []int16 + qoaMetadata qoa.QOA + startTime time.Time + totalLength time.Duration +} + +func (m model) Init() tea.Cmd { + return tea.Batch(func() tea.Msg { + return playerMsg(start) // Initial command to start playback + }) +} +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var teaCommands []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + if m.player.IsPlaying() { + m.player.Close() // Ensure player resources are freed + } + return m, tea.Quit + case "p": // Adding a pause/play toggle + if m.player.IsPlaying() { + teaCommands = append(teaCommands, func() tea.Msg { + return playerMsg(stop) // Initial command to stop playback + }) + } else if m.player != nil { + teaCommands = append(teaCommands, func() tea.Msg { + return playerMsg(start) // Initial command to start playback + }) + } + } + case playerMsg: + switch msg { + case start: + if !m.player.IsPlaying() { + m.player.Play() + m.startTime = time.Now() + } + teaCommands = append(teaCommands, tickCmd()) + case stop: + if m.player.IsPlaying() { + m.player.Pause() + } + } + + case tickMsg: + if m.progress.Percent() >= 1.0 { + return m, tea.Quit + } + if m.player.IsPlaying() { + elapsed := time.Since(m.startTime) + newPercent := elapsed.Seconds() / m.totalLength.Seconds() + cmd := m.progress.SetPercent(newPercent) // Ensure this sets the progress correctly + // if newPercent >= 1.0 { + // return m, tea.Quit + // } + teaCommands = append(teaCommands, tickCmd(), cmd) // Continuously re-trigger tickCmd + } + + case progress.FrameMsg: + progressModel, cmd := m.progress.Update(msg) + m.progress = progressModel.(progress.Model) + teaCommands = append(teaCommands, cmd) + + } + return m, tea.Batch(teaCommands...) +} + +func (m model) View() string { + pad := strings.Repeat(" ", 2) + statusLine := "Press 'p' to pause/play, 'q' to quit." + return fmt.Sprintf("\nPlaying: %s\n\n%s%s\n\n%s%s\n", m.filename, pad, m.progress.View(), pad, statusLine) +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func startTUI(filename string, ctx *oto.Context, qoaData []int16, qoaMetadata qoa.QOA) { + prog := progress.New(progress.WithDefaultGradient()) + prog.ShowPercentage = false + prog.SetSpringOptions(36.0, 1.0) + + player := ctx.NewPlayer(NewQOAAudioReader(qoaData)) + p := tea.NewProgram(model{ + filename: filename, + qoaData: qoaData, + qoaMetadata: qoaMetadata, + progress: prog, + player: player, + totalLength: time.Duration(qoaMetadata.Samples/qoaMetadata.SampleRate) * time.Second, + }) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} + func isValidQOAFile(inputFile string) (bool, error) { // Read first 4 bytes of the file fileBytes := make([]byte, 4) @@ -78,9 +197,6 @@ func playQOA(inputFiles []string) { // 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", @@ -89,19 +205,9 @@ func playQOA(inputFiles []string) { "SampleRate", qoaMetadata.SampleRate, "ChannelCount", - qoaMetadata.Channels, - "BufferedSize", - player.BufferedSize()) - player.Play() + qoaMetadata.Channels) - for player.IsPlaying() { - time.Sleep(time.Millisecond) - } - - // Close the player - if err := player.Close(); err != nil { - logger.Fatalf("Error closing player: %v", err) - } + startTUI(inputFile, ctx, qoaAudioData, *qoaMetadata) } } diff --git a/go.mod b/go.mod index 9b44eb1..fde0da4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.21.0 require ( github.com/braheezy/shine-mp3 v0.1.0 + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/log v0.4.0 github.com/ebitengine/oto/v3 v3.1.1 github.com/go-audio/audio v1.0.0 @@ -17,7 +19,9 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/lipgloss v0.10.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/ebitengine/purego v0.6.1 // indirect github.com/go-audio/riff v1.0.0 // indirect @@ -27,14 +31,20 @@ require ( github.com/jfreymuth/vorbis v1.0.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.7.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 66c0c59..a42e165 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,18 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8= github.com/braheezy/shine-mp3 v0.1.0/go.mod h1:0H/pmcpFAd+Fnrj6Pc7du7wL36U/HqtfcgPJuCgc1L4= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -37,6 +45,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -44,6 +54,10 @@ github.com/mewkiz/flac v1.0.10 h1:go+Pj8X/HeJm1f9jWhEs484ABhivtjY9s5TYhxWMqNM= github.com/mewkiz/flac v1.0.10/go.mod h1:l7dt5uFY724eKVkHQtAJAQSkhpC3helU3RDxN0ESAqo= github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 h1:tnAPMExbRERsyEYkmR1YjhTgDM0iqyiBYf8ojRXxdbA= github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14/go.mod h1:QYCFBiH5q6XTHEbWhR0uhR3M9qNPoD2CSQzr0g75kE4= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= @@ -78,11 +92,14 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= @@ -90,9 +107,12 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= From 5c6afb0a554fd5cfabc0bd1699fda2bf5305bf82 Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 19:15:17 -0700 Subject: [PATCH 03/11] Play TUI: multiple files, endless loop, pause --- cmd/play.go | 404 +++++++++++++++++++++++++++--------------- cmd/qoaaudioreader.go | 43 +++++ 2 files changed, 302 insertions(+), 145 deletions(-) create mode 100644 cmd/qoaaudioreader.go diff --git a/cmd/play.go b/cmd/play.go index 0177097..c790672 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "time" @@ -20,10 +21,55 @@ var playCmd = &cobra.Command{ Long: "Provide one or more QOA files to play.", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - playQOA(args[0:]) + 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 := 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) }, } +// 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 { + return err + } + if !info.IsDir() { + valid, err := isValidQOAFile(path) + if err != nil { + return err + } + if valid { + files = append(files, path) + } + } + return nil + }) + return files, err +} func init() { rootCmd.AddCommand(playCmd) } @@ -32,27 +78,170 @@ type tickMsg time.Time type playerMsg int +func sendPlayerMsg(msg playerMsg) tea.Cmd { + return func() tea.Msg { + return msg + } +} + +type changeSong int + +const ( + next changeSong = iota + prev +) + +func sendChangeSongMsg(msg changeSong) tea.Cmd { + return func() tea.Msg { + return msg + } +} + const ( start playerMsg = iota stop ) type model struct { - filename string - player *oto.Player - progress progress.Model - ctx *oto.Context - qoaData []int16 - qoaMetadata qoa.QOA - startTime time.Time - totalLength time.Duration + filenames []string + currentIndex int + qoaPlayer *qoaPlayer + ctx *oto.Context +} + +type qoaPlayer struct { + qoaData []int16 + player *oto.Player + qoaMetadata qoa.QOA + startTime time.Time + lastPauseTime time.Time // Tracks when the last pause started + totalPausedTime time.Duration // Accumulates total time spent paused + totalLength time.Duration + filename string + progress progress.Model + paused bool } +func newModel(filenames []string) *model { + _, err := isValidQOAFile(filenames[0]) + if err != nil { + logger.Fatalf("Error validating QOA file: %v", err) + } + + qoaBytes, err := os.ReadFile(filenames[0]) + if err != nil { + logger.Fatalf("Error reading QOA file: %v", err) + } + + // Decode the QOA audio data + qoaMetadata, _, err := qoa.Decode(qoaBytes) + if err != nil { + logger.Fatalf("Error decoding QOA data: %v", err) + } + + // Prepare an Oto context (this will use the default audio device) + ctx, ready, err := oto.NewContext( + &oto.NewContextOptions{ + SampleRate: int(qoaMetadata.SampleRate), + // only 1 or 2 are supported by oto + ChannelCount: 2, + Format: oto.FormatSignedInt16LE, + }) + if err != nil { + panic("oto.NewContext failed: " + err.Error()) + } + // Wait for the context to be ready + <-ready + + m := &model{ + filenames: filenames, + currentIndex: 0, + ctx: ctx, + } + m.qoaPlayer = m.newQOAPlayer(filenames[0]) + return m +} + +func (m *model) newQOAPlayer(filename string) *qoaPlayer { + _, err := isValidQOAFile(filename) + if err != nil { + logger.Fatalf("Error validating QOA file: %v", err) + } + + qoaBytes, err := os.ReadFile(filename) + if err != nil { + logger.Fatalf("Error reading QOA file: %v", err) + } + + // Decode the QOA audio data + qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes) + if err != nil { + logger.Fatalf("Error decoding QOA data: %v", err) + } + + prog := progress.New(progress.WithDefaultGradient()) + prog.ShowPercentage = false + + player := m.ctx.NewPlayer(NewQOAAudioReader(qoaAudioData)) + return &qoaPlayer{ + filename: filename, + qoaData: qoaAudioData, + qoaMetadata: *qoaMetadata, + progress: prog, + player: player, + totalLength: time.Duration(qoaMetadata.Samples/qoaMetadata.SampleRate) * time.Second, + } +} + +func startTUI(inputFiles []string) { + // If inputFiles[0] is a directory, get the immediate contents. Only files ending in .qoa. + fileInfo, err := os.Stat(inputFiles[0]) + if err != nil { + logger.Fatalf("Error reading file: %v", err) + } + if fileInfo.IsDir() { + files, err := os.ReadDir(inputFiles[0]) + if err != nil { + logger.Fatalf("Error reading directory: %v", err) + } + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".qoa") { + inputFiles = append(inputFiles, file.Name()) + } + } + if len(inputFiles) == 0 { + logger.Fatal("No .qoa files found in directory") + } + // Remove the first element, the directory name + inputFiles = inputFiles[1:] + } + p := tea.NewProgram(newModel(inputFiles)) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} func (m model) Init() tea.Cmd { - return tea.Batch(func() tea.Msg { - return playerMsg(start) // Initial command to start playback - }) + return tea.Batch(sendPlayerMsg(start)) +} + +// Start playback and initialize timing +func (qp *qoaPlayer) StartPlayback() { + qp.player.Play() + if qp.startTime.IsZero() { + qp.startTime = time.Now() + } else { + qp.totalPausedTime += time.Since(qp.lastPauseTime) + qp.lastPauseTime = time.Time{} // Reset last pause time + } +} + +// Pause playback and track pause timing +func (qp *qoaPlayer) PausePlayback() { + qp.player.Pause() + qp.lastPauseTime = time.Now() } + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var teaCommands []tea.Cmd @@ -60,62 +249,89 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": - if m.player.IsPlaying() { - m.player.Close() // Ensure player resources are freed + if m.qoaPlayer.player.IsPlaying() { + m.qoaPlayer.player.Close() // Ensure player resources are freed } return m, tea.Quit case "p": // Adding a pause/play toggle - if m.player.IsPlaying() { - teaCommands = append(teaCommands, func() tea.Msg { - return playerMsg(stop) // Initial command to stop playback - }) - } else if m.player != nil { - teaCommands = append(teaCommands, func() tea.Msg { - return playerMsg(start) // Initial command to start playback - }) + if m.qoaPlayer.player.IsPlaying() { + teaCommands = append(teaCommands, sendPlayerMsg(stop)) + } else if m.qoaPlayer.player != nil { + teaCommands = append(teaCommands, sendPlayerMsg(start)) } } case playerMsg: switch msg { case start: - if !m.player.IsPlaying() { - m.player.Play() - m.startTime = time.Now() + if !m.qoaPlayer.player.IsPlaying() { + m.qoaPlayer.player.Play() + m.qoaPlayer.paused = false + if m.qoaPlayer.startTime.IsZero() { + m.qoaPlayer.startTime = time.Now() + } else { + m.qoaPlayer.totalPausedTime += time.Since(m.qoaPlayer.lastPauseTime) + m.qoaPlayer.lastPauseTime = time.Time{} // Reset last pause time + } + teaCommands = append(teaCommands, tickCmd()) } - teaCommands = append(teaCommands, tickCmd()) case stop: - if m.player.IsPlaying() { - m.player.Pause() - } - } + m.qoaPlayer.player.Pause() + m.qoaPlayer.lastPauseTime = time.Now() + m.qoaPlayer.paused = true + } + case changeSong: + switch msg { + case next: + m = nextSong(m) + teaCommands = append(teaCommands, sendPlayerMsg(start)) + } case tickMsg: - if m.progress.Percent() >= 1.0 { - return m, tea.Quit + if !m.qoaPlayer.player.IsPlaying() && !m.qoaPlayer.paused { + teaCommands = append(teaCommands, sendChangeSongMsg(next)) + return m, tea.Batch(teaCommands...) } - if m.player.IsPlaying() { - elapsed := time.Since(m.startTime) - newPercent := elapsed.Seconds() / m.totalLength.Seconds() - cmd := m.progress.SetPercent(newPercent) // Ensure this sets the progress correctly - // if newPercent >= 1.0 { - // return m, tea.Quit - // } - teaCommands = append(teaCommands, tickCmd(), cmd) // Continuously re-trigger tickCmd + if m.qoaPlayer.player.IsPlaying() { + elapsed := time.Since(m.qoaPlayer.startTime) - m.qoaPlayer.totalPausedTime + newPercent := elapsed.Seconds() / m.qoaPlayer.totalLength.Seconds() + cmd := m.qoaPlayer.progress.SetPercent(newPercent) + teaCommands = append(teaCommands, cmd, tickCmd()) + return m, tea.Batch(teaCommands...) + + } else if m.qoaPlayer.progress.Percent() >= 1.0 { + + teaCommands = append(teaCommands, sendChangeSongMsg(next)) + return m, tea.Batch(teaCommands...) } case progress.FrameMsg: - progressModel, cmd := m.progress.Update(msg) - m.progress = progressModel.(progress.Model) + progressModel, cmd := m.qoaPlayer.progress.Update(msg) + m.qoaPlayer.progress = progressModel.(progress.Model) teaCommands = append(teaCommands, cmd) } return m, tea.Batch(teaCommands...) } +func nextSong(m model) model { + m.qoaPlayer.player.Close() + + // Select next song in filenames list, but wrap around to 0 if at end + nextIndex := (m.currentIndex + 1) % len(m.filenames) + nextFile := m.filenames[nextIndex] + + // Create a new QOA player for the next song + m.qoaPlayer = m.newQOAPlayer(nextFile) + m.currentIndex = nextIndex + + // Return the new QOA player and a command to update the progress bar + return m +} + func (m model) View() string { pad := strings.Repeat(" ", 2) statusLine := "Press 'p' to pause/play, 'q' to quit." - return fmt.Sprintf("\nPlaying: %s\n\n%s%s\n\n%s%s\n", m.filename, pad, m.progress.View(), pad, statusLine) + return fmt.Sprintf("\nPlaying: %s (index: %v)\n\n%s%s\n\n%s%s\n", m.qoaPlayer.filename, m.currentIndex, pad, m.qoaPlayer.progress.View(), pad, statusLine) } func tickCmd() tea.Cmd { @@ -124,26 +340,6 @@ func tickCmd() tea.Cmd { }) } -func startTUI(filename string, ctx *oto.Context, qoaData []int16, qoaMetadata qoa.QOA) { - prog := progress.New(progress.WithDefaultGradient()) - prog.ShowPercentage = false - prog.SetSpringOptions(36.0, 1.0) - - player := ctx.NewPlayer(NewQOAAudioReader(qoaData)) - p := tea.NewProgram(model{ - filename: filename, - qoaData: qoaData, - qoaMetadata: qoaMetadata, - progress: prog, - player: player, - totalLength: time.Duration(qoaMetadata.Samples/qoaMetadata.SampleRate) * time.Second, - }) - if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) - os.Exit(1) - } -} - func isValidQOAFile(inputFile string) (bool, error) { // Read first 4 bytes of the file fileBytes := make([]byte, 4) @@ -164,85 +360,3 @@ func isValidQOAFile(inputFile string) (bool, error) { } 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) - if err != nil { - logger.Fatalf("Error reading QOA file: %v", err) - } - - // Decode the QOA audio data - qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes) - if err != nil { - logger.Fatalf("Error decoding QOA data: %v", err) - } - - // Wait for the context to be ready - <-ready - - // Play the audio - logger.Debug( - "Starting audio", - "File", - inputFile, - "SampleRate", - qoaMetadata.SampleRate, - "ChannelCount", - qoaMetadata.Channels) - - startTUI(inputFile, ctx, qoaAudioData, *qoaMetadata) - } -} - -// 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 -} diff --git a/cmd/qoaaudioreader.go b/cmd/qoaaudioreader.go new file mode 100644 index 0000000..0467301 --- /dev/null +++ b/cmd/qoaaudioreader.go @@ -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 +} From df0f9cfbc1093f4a4618ba24700ecb4dc0cad062 Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 22:03:19 -0700 Subject: [PATCH 04/11] Refactor TUI --- .gitignore | 3 + cmd/play.go | 297 +--------------------------------------------------- cmd/tui.go | 279 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 295 deletions(-) create mode 100644 cmd/tui.go diff --git a/.gitignore b/.gitignore index 441d7c9..b7d7932 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ /fuzz/*.qoa /assets/*.gif *.prof +/output_* +/*.wav +__debug_bin* diff --git a/cmd/play.go b/cmd/play.go index c790672..4f969d2 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -2,16 +2,10 @@ package cmd import ( "fmt" - "io" "os" "path/filepath" - "strings" - "time" "github.com/braheezy/goqoa/pkg/qoa" - "github.com/charmbracelet/bubbles/progress" - tea "github.com/charmbracelet/bubbletea" - "github.com/ebitengine/oto/v3" "github.com/spf13/cobra" ) @@ -36,7 +30,7 @@ var playCmd = &cobra.Command{ } allFiles = append(allFiles, files...) } else { - valid, err := isValidQOAFile(arg) + valid, err := qoa.IsValidQOAFile(arg) if err != nil { fmt.Fprintf(os.Stderr, "Error checking file %s: %v\n", arg, err) continue @@ -58,7 +52,7 @@ func findAllQOAFiles(root string) ([]string, error) { return err } if !info.IsDir() { - valid, err := isValidQOAFile(path) + valid, err := qoa.IsValidQOAFile(path) if err != nil { return err } @@ -73,290 +67,3 @@ func findAllQOAFiles(root string) ([]string, error) { func init() { rootCmd.AddCommand(playCmd) } - -type tickMsg time.Time - -type playerMsg int - -func sendPlayerMsg(msg playerMsg) tea.Cmd { - return func() tea.Msg { - return msg - } -} - -type changeSong int - -const ( - next changeSong = iota - prev -) - -func sendChangeSongMsg(msg changeSong) tea.Cmd { - return func() tea.Msg { - return msg - } -} - -const ( - start playerMsg = iota - stop -) - -type model struct { - filenames []string - currentIndex int - qoaPlayer *qoaPlayer - ctx *oto.Context -} - -type qoaPlayer struct { - qoaData []int16 - player *oto.Player - qoaMetadata qoa.QOA - startTime time.Time - lastPauseTime time.Time // Tracks when the last pause started - totalPausedTime time.Duration // Accumulates total time spent paused - totalLength time.Duration - filename string - progress progress.Model - paused bool -} - -func newModel(filenames []string) *model { - _, err := isValidQOAFile(filenames[0]) - if err != nil { - logger.Fatalf("Error validating QOA file: %v", err) - } - - qoaBytes, err := os.ReadFile(filenames[0]) - if err != nil { - logger.Fatalf("Error reading QOA file: %v", err) - } - - // Decode the QOA audio data - qoaMetadata, _, err := qoa.Decode(qoaBytes) - if err != nil { - logger.Fatalf("Error decoding QOA data: %v", err) - } - - // Prepare an Oto context (this will use the default audio device) - ctx, ready, err := oto.NewContext( - &oto.NewContextOptions{ - SampleRate: int(qoaMetadata.SampleRate), - // only 1 or 2 are supported by oto - ChannelCount: 2, - Format: oto.FormatSignedInt16LE, - }) - if err != nil { - panic("oto.NewContext failed: " + err.Error()) - } - // Wait for the context to be ready - <-ready - - m := &model{ - filenames: filenames, - currentIndex: 0, - ctx: ctx, - } - m.qoaPlayer = m.newQOAPlayer(filenames[0]) - return m -} - -func (m *model) newQOAPlayer(filename string) *qoaPlayer { - _, err := isValidQOAFile(filename) - if err != nil { - logger.Fatalf("Error validating QOA file: %v", err) - } - - qoaBytes, err := os.ReadFile(filename) - if err != nil { - logger.Fatalf("Error reading QOA file: %v", err) - } - - // Decode the QOA audio data - qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes) - if err != nil { - logger.Fatalf("Error decoding QOA data: %v", err) - } - - prog := progress.New(progress.WithDefaultGradient()) - prog.ShowPercentage = false - - player := m.ctx.NewPlayer(NewQOAAudioReader(qoaAudioData)) - return &qoaPlayer{ - filename: filename, - qoaData: qoaAudioData, - qoaMetadata: *qoaMetadata, - progress: prog, - player: player, - totalLength: time.Duration(qoaMetadata.Samples/qoaMetadata.SampleRate) * time.Second, - } -} - -func startTUI(inputFiles []string) { - // If inputFiles[0] is a directory, get the immediate contents. Only files ending in .qoa. - fileInfo, err := os.Stat(inputFiles[0]) - if err != nil { - logger.Fatalf("Error reading file: %v", err) - } - if fileInfo.IsDir() { - files, err := os.ReadDir(inputFiles[0]) - if err != nil { - logger.Fatalf("Error reading directory: %v", err) - } - for _, file := range files { - if !file.IsDir() && strings.HasSuffix(file.Name(), ".qoa") { - inputFiles = append(inputFiles, file.Name()) - } - } - if len(inputFiles) == 0 { - logger.Fatal("No .qoa files found in directory") - } - // Remove the first element, the directory name - inputFiles = inputFiles[1:] - } - p := tea.NewProgram(newModel(inputFiles)) - if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) - os.Exit(1) - } -} -func (m model) Init() tea.Cmd { - return tea.Batch(sendPlayerMsg(start)) -} - -// Start playback and initialize timing -func (qp *qoaPlayer) StartPlayback() { - qp.player.Play() - if qp.startTime.IsZero() { - qp.startTime = time.Now() - } else { - qp.totalPausedTime += time.Since(qp.lastPauseTime) - qp.lastPauseTime = time.Time{} // Reset last pause time - } -} - -// Pause playback and track pause timing -func (qp *qoaPlayer) PausePlayback() { - qp.player.Pause() - qp.lastPauseTime = time.Now() -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var teaCommands []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - if m.qoaPlayer.player.IsPlaying() { - m.qoaPlayer.player.Close() // Ensure player resources are freed - } - return m, tea.Quit - case "p": // Adding a pause/play toggle - if m.qoaPlayer.player.IsPlaying() { - teaCommands = append(teaCommands, sendPlayerMsg(stop)) - } else if m.qoaPlayer.player != nil { - teaCommands = append(teaCommands, sendPlayerMsg(start)) - } - } - case playerMsg: - switch msg { - case start: - if !m.qoaPlayer.player.IsPlaying() { - m.qoaPlayer.player.Play() - m.qoaPlayer.paused = false - if m.qoaPlayer.startTime.IsZero() { - m.qoaPlayer.startTime = time.Now() - } else { - m.qoaPlayer.totalPausedTime += time.Since(m.qoaPlayer.lastPauseTime) - m.qoaPlayer.lastPauseTime = time.Time{} // Reset last pause time - } - teaCommands = append(teaCommands, tickCmd()) - } - case stop: - m.qoaPlayer.player.Pause() - m.qoaPlayer.lastPauseTime = time.Now() - m.qoaPlayer.paused = true - - } - case changeSong: - switch msg { - case next: - m = nextSong(m) - teaCommands = append(teaCommands, sendPlayerMsg(start)) - } - case tickMsg: - if !m.qoaPlayer.player.IsPlaying() && !m.qoaPlayer.paused { - teaCommands = append(teaCommands, sendChangeSongMsg(next)) - return m, tea.Batch(teaCommands...) - } - if m.qoaPlayer.player.IsPlaying() { - elapsed := time.Since(m.qoaPlayer.startTime) - m.qoaPlayer.totalPausedTime - newPercent := elapsed.Seconds() / m.qoaPlayer.totalLength.Seconds() - cmd := m.qoaPlayer.progress.SetPercent(newPercent) - teaCommands = append(teaCommands, cmd, tickCmd()) - return m, tea.Batch(teaCommands...) - - } else if m.qoaPlayer.progress.Percent() >= 1.0 { - - teaCommands = append(teaCommands, sendChangeSongMsg(next)) - return m, tea.Batch(teaCommands...) - } - - case progress.FrameMsg: - progressModel, cmd := m.qoaPlayer.progress.Update(msg) - m.qoaPlayer.progress = progressModel.(progress.Model) - teaCommands = append(teaCommands, cmd) - - } - return m, tea.Batch(teaCommands...) -} - -func nextSong(m model) model { - m.qoaPlayer.player.Close() - - // Select next song in filenames list, but wrap around to 0 if at end - nextIndex := (m.currentIndex + 1) % len(m.filenames) - nextFile := m.filenames[nextIndex] - - // Create a new QOA player for the next song - m.qoaPlayer = m.newQOAPlayer(nextFile) - m.currentIndex = nextIndex - - // Return the new QOA player and a command to update the progress bar - return m -} - -func (m model) View() string { - pad := strings.Repeat(" ", 2) - statusLine := "Press 'p' to pause/play, 'q' to quit." - return fmt.Sprintf("\nPlaying: %s (index: %v)\n\n%s%s\n\n%s%s\n", m.qoaPlayer.filename, m.currentIndex, pad, m.qoaPlayer.progress.View(), pad, statusLine) -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -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 -} diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..b135bba --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,279 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/braheezy/goqoa/pkg/qoa" + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" + "github.com/ebitengine/oto/v3" +) + +type tickMsg time.Time + +type playerMsg int + +func sendPlayerMsg(msg playerMsg) tea.Cmd { + return func() tea.Msg { + return msg + } +} + +type changeSong int + +const ( + next changeSong = iota + prev +) + +func sendChangeSongMsg(msg changeSong) tea.Cmd { + return func() tea.Msg { + return msg + } +} + +const ( + start playerMsg = iota + stop +) + +type model struct { + filenames []string + currentIndex int + qoaPlayer *qoaPlayer + ctx *oto.Context +} + +type qoaPlayer struct { + qoaData []int16 + player *oto.Player + qoaMetadata qoa.QOA + startTime time.Time + lastPauseTime time.Time // Tracks when the last pause started + totalPausedTime time.Duration // Accumulates total time spent paused + totalLength time.Duration + filename string + progress progress.Model + paused bool +} + +func newModel(filenames []string) *model { + _, err := qoa.IsValidQOAFile(filenames[0]) + if err != nil { + logger.Fatalf("Error validating QOA file: %v", err) + } + + qoaBytes, err := os.ReadFile(filenames[0]) + if err != nil { + logger.Fatalf("Error reading QOA file: %v", err) + } + + // Decode the QOA audio data + qoaMetadata, _, err := qoa.Decode(qoaBytes) + if err != nil { + logger.Fatalf("Error decoding QOA data: %v", err) + } + + // Prepare an Oto context (this will use the default audio device) + ctx, ready, err := oto.NewContext( + &oto.NewContextOptions{ + SampleRate: int(qoaMetadata.SampleRate), + // only 1 or 2 are supported by oto + ChannelCount: 2, + Format: oto.FormatSignedInt16LE, + }) + if err != nil { + panic("oto.NewContext failed: " + err.Error()) + } + // Wait for the context to be ready + <-ready + + m := &model{ + filenames: filenames, + currentIndex: 0, + ctx: ctx, + } + m.qoaPlayer = m.newQOAPlayer(filenames[0]) + return m +} + +func (m *model) newQOAPlayer(filename string) *qoaPlayer { + _, err := qoa.IsValidQOAFile(filename) + if err != nil { + logger.Fatalf("Error validating QOA file: %v", err) + } + + qoaBytes, err := os.ReadFile(filename) + if err != nil { + logger.Fatalf("Error reading QOA file: %v", err) + } + + // Decode the QOA audio data + qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes) + if err != nil { + logger.Fatalf("Error decoding QOA data: %v", err) + } + + prog := progress.New(progress.WithDefaultGradient()) + prog.ShowPercentage = false + + player := m.ctx.NewPlayer(NewQOAAudioReader(qoaAudioData)) + return &qoaPlayer{ + filename: filename, + qoaData: qoaAudioData, + qoaMetadata: *qoaMetadata, + progress: prog, + player: player, + totalLength: time.Duration(qoaMetadata.Samples/qoaMetadata.SampleRate) * time.Second, + } +} + +func startTUI(inputFiles []string) { + // If inputFiles[0] is a directory, get the immediate contents. Only files ending in .qoa. + fileInfo, err := os.Stat(inputFiles[0]) + if err != nil { + logger.Fatalf("Error reading file: %v", err) + } + if fileInfo.IsDir() { + files, err := os.ReadDir(inputFiles[0]) + if err != nil { + logger.Fatalf("Error reading directory: %v", err) + } + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".qoa") { + inputFiles = append(inputFiles, file.Name()) + } + } + if len(inputFiles) == 0 { + logger.Fatal("No .qoa files found in directory") + } + // Remove the first element, the directory name + inputFiles = inputFiles[1:] + } + p := tea.NewProgram(newModel(inputFiles)) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} +func (m model) Init() tea.Cmd { + return tea.Batch(sendPlayerMsg(start)) +} + +// Start playback and initialize timing +func (qp *qoaPlayer) StartPlayback() { + qp.player.Play() + if qp.startTime.IsZero() { + qp.startTime = time.Now() + } else { + qp.totalPausedTime += time.Since(qp.lastPauseTime) + qp.lastPauseTime = time.Time{} // Reset last pause time + } +} + +// Pause playback and track pause timing +func (qp *qoaPlayer) PausePlayback() { + qp.player.Pause() + qp.lastPauseTime = time.Now() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var teaCommands []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + if m.qoaPlayer.player.IsPlaying() { + m.qoaPlayer.player.Close() // Ensure player resources are freed + } + return m, tea.Quit + case "p": // Adding a pause/play toggle + if m.qoaPlayer.player.IsPlaying() { + teaCommands = append(teaCommands, sendPlayerMsg(stop)) + } else if m.qoaPlayer.player != nil { + teaCommands = append(teaCommands, sendPlayerMsg(start)) + } + } + case playerMsg: + switch msg { + case start: + if !m.qoaPlayer.player.IsPlaying() { + m.qoaPlayer.player.Play() + m.qoaPlayer.paused = false + if m.qoaPlayer.startTime.IsZero() { + m.qoaPlayer.startTime = time.Now() + } else { + m.qoaPlayer.totalPausedTime += time.Since(m.qoaPlayer.lastPauseTime) + m.qoaPlayer.lastPauseTime = time.Time{} // Reset last pause time + } + teaCommands = append(teaCommands, tickCmd()) + } + case stop: + m.qoaPlayer.player.Pause() + m.qoaPlayer.lastPauseTime = time.Now() + m.qoaPlayer.paused = true + + } + case changeSong: + switch msg { + case next: + m = nextSong(m) + teaCommands = append(teaCommands, sendPlayerMsg(start)) + } + case tickMsg: + if !m.qoaPlayer.player.IsPlaying() && !m.qoaPlayer.paused { + teaCommands = append(teaCommands, sendChangeSongMsg(next)) + return m, tea.Batch(teaCommands...) + } + if m.qoaPlayer.player.IsPlaying() { + elapsed := time.Since(m.qoaPlayer.startTime) - m.qoaPlayer.totalPausedTime + newPercent := elapsed.Seconds() / m.qoaPlayer.totalLength.Seconds() + cmd := m.qoaPlayer.progress.SetPercent(newPercent) + teaCommands = append(teaCommands, cmd, tickCmd()) + return m, tea.Batch(teaCommands...) + + } else if m.qoaPlayer.progress.Percent() >= 1.0 { + + teaCommands = append(teaCommands, sendChangeSongMsg(next)) + return m, tea.Batch(teaCommands...) + } + + case progress.FrameMsg: + progressModel, cmd := m.qoaPlayer.progress.Update(msg) + m.qoaPlayer.progress = progressModel.(progress.Model) + teaCommands = append(teaCommands, cmd) + + } + return m, tea.Batch(teaCommands...) +} + +func nextSong(m model) model { + m.qoaPlayer.player.Close() + + // Select next song in filenames list, but wrap around to 0 if at end + nextIndex := (m.currentIndex + 1) % len(m.filenames) + nextFile := m.filenames[nextIndex] + + // Create a new QOA player for the next song + m.qoaPlayer = m.newQOAPlayer(nextFile) + m.currentIndex = nextIndex + + // Return the new QOA player and a command to update the progress bar + return m +} + +func (m model) View() string { + pad := strings.Repeat(" ", 2) + statusLine := "Press 'p' to pause/play, 'q' to quit." + return fmt.Sprintf("\nPlaying: %s (index: %v)\n\n%s%s\n\n%s%s\n", m.qoaPlayer.filename, m.currentIndex, pad, m.qoaPlayer.progress.View(), pad, statusLine) +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} From f93556ca62175992c5445b881fd03307877707b5 Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 22:07:35 -0700 Subject: [PATCH 05/11] Make LMS private --- pkg/qoa/decode.go | 8 ++++---- pkg/qoa/encode.go | 26 +++++++++++++------------- pkg/qoa/qoa.go | 10 +++++----- pkg/qoa/qoa_test.go | 4 ++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/pkg/qoa/decode.go b/pkg/qoa/decode.go index 5f04dca..4576949 100644 --- a/pkg/qoa/decode.go +++ b/pkg/qoa/decode.go @@ -72,9 +72,9 @@ func (q *QOA) decodeFrame(bytes []byte, size uint, sampleData []int16, frameLen p += 16 for i := 0; i < QOALMSLen; i++ { - q.LMS[c].History[i] = int16(history >> 48) + q.lms[c].History[i] = int16(history >> 48) history <<= 16 - q.LMS[c].Weights[i] = int16(weights >> 48) + q.lms[c].Weights[i] = int16(weights >> 48) weights <<= 16 } } @@ -90,7 +90,7 @@ func (q *QOA) decodeFrame(bytes []byte, size uint, sampleData []int16, frameLen sliceEnd := uint32(clamp(int(sampleIndex)+QOASliceLen, 0, int(samples)))*channels + c for si := sliceStart; si < sliceEnd; si += channels { - predicted := q.LMS[c].predict() + predicted := q.lms[c].predict() quantized := int((slice >> 57) & 0x7) dequantized := qoaDequantTable[scaleFactor][quantized] reconstructed := clampS16(predicted + int(dequantized)) @@ -98,7 +98,7 @@ func (q *QOA) decodeFrame(bytes []byte, size uint, sampleData []int16, frameLen sampleData[si] = reconstructed slice <<= 3 - q.LMS[c].update(reconstructed, dequantized) + q.lms[c].update(reconstructed, dequantized) } } } diff --git a/pkg/qoa/encode.go b/pkg/qoa/encode.go index c486946..392bab0 100644 --- a/pkg/qoa/encode.go +++ b/pkg/qoa/encode.go @@ -41,8 +41,8 @@ func (q *QOA) encodeFrame(sampleData []int16, frameLen uint32, bytes []byte) uin history := uint64(0) weights := uint64(0) for i := 0; i < QOALMSLen; i++ { - history = history<<16 | uint64(q.LMS[c].History[i])&0xffff - weights = weights<<16 | uint64(q.LMS[c].Weights[i])&0xffff + history = history<<16 | uint64(q.lms[c].History[i])&0xffff + weights = weights<<16 | uint64(q.lms[c].Weights[i])&0xffff } binary.BigEndian.PutUint64(bytes[p:], history) p += 8 @@ -60,7 +60,7 @@ func (q *QOA) encodeFrame(sampleData []int16, frameLen uint32, bytes []byte) uin var bestSlice uint64 q.prevScaleFactor[c], bestError, bestSlice, bestLMS = q.findBestScaleFactor(sampleIndex, c, sliceLen, &sampleData) - q.LMS[c] = *bestLMS + q.lms[c] = *bestLMS q.ErrorCount += bestError /* If this slice was shorter than QOA_SLICE_LEN, we have to left- @@ -91,10 +91,10 @@ func (q *QOA) findBestScaleFactor(sampleIndex uint32, currentChannel uint32, sli // If the weights have grown too large, we introduce a penalty here. This prevents pops/clicks // in certain problem cases. weightsPenalty := (int( - q.LMS[currentChannel].Weights[0]*q.LMS[currentChannel].Weights[0]+ - q.LMS[currentChannel].Weights[1]*q.LMS[currentChannel].Weights[1]+ - q.LMS[currentChannel].Weights[2]*q.LMS[currentChannel].Weights[2]+ - q.LMS[currentChannel].Weights[3]*q.LMS[currentChannel].Weights[3]) >> 18) - 0x8ff + q.lms[currentChannel].Weights[0]*q.lms[currentChannel].Weights[0]+ + q.lms[currentChannel].Weights[1]*q.lms[currentChannel].Weights[1]+ + q.lms[currentChannel].Weights[2]*q.lms[currentChannel].Weights[2]+ + q.lms[currentChannel].Weights[3]*q.lms[currentChannel].Weights[3]) >> 18) - 0x8ff var weightsPenaltySquared uint64 if weightsPenalty < 0 { weightsPenalty = 0 @@ -111,7 +111,7 @@ func (q *QOA) findBestScaleFactor(sampleIndex uint32, currentChannel uint32, sli /* Reset the LMS state to the last known good one before trying each scaleFactor, as each pass updates the LMS state when encoding. */ - lms := q.LMS[currentChannel] + lms := q.lms[currentChannel] slice := uint64(scaleFactor) currentRank := uint64(0) currentError := uint64(0) @@ -173,15 +173,15 @@ func (q *QOA) Encode(sampleData []int16) ([]byte, error) { for c := uint32(0); c < q.Channels; c++ { /* Set the initial LMS weights to {0, 0, -1, 2}. This helps with the prediction of the first few ms of a file. */ - q.LMS[c].Weights[0] = 0 - q.LMS[c].Weights[1] = 0 - q.LMS[c].Weights[2] = -(1 << 13) - q.LMS[c].Weights[3] = 1 << 14 + q.lms[c].Weights[0] = 0 + q.lms[c].Weights[1] = 0 + q.lms[c].Weights[2] = -(1 << 13) + q.lms[c].Weights[3] = 1 << 14 /* Explicitly set the history samples to 0, as we might have some garbage in there. */ for i := 0; i < QOALMSLen; i++ { - q.LMS[c].History[i] = 0 + q.lms[c].History[i] = 0 } } diff --git a/pkg/qoa/qoa.go b/pkg/qoa/qoa.go index 6919e20..e15f853 100644 --- a/pkg/qoa/qoa.go +++ b/pkg/qoa/qoa.go @@ -128,11 +128,11 @@ type qoaLMS struct { // QOA stores the QOA audio file description. type QOA struct { - Channels uint32 // Number of audio channels - SampleRate uint32 // Sample rate of the audio - Samples uint32 // Total number of audio samples - LMS [QOAMaxChannels]qoaLMS // LMS state per channel - ErrorCount int // Count of errors during encoding/decoding + Channels uint32 // Number of audio channels + SampleRate uint32 // Sample rate of the audio + Samples uint32 // Total number of audio samples + lms [8]qoaLMS // LMS state per channel + ErrorCount int // Count of errors during encoding/decoding prevScaleFactor []int } diff --git a/pkg/qoa/qoa_test.go b/pkg/qoa/qoa_test.go index 3010fba..a5863b9 100644 --- a/pkg/qoa/qoa_test.go +++ b/pkg/qoa/qoa_test.go @@ -18,7 +18,7 @@ func TestEncodeHeader(t *testing.T) { Channels: 2, SampleRate: 44100, Samples: 88200, - LMS: [QOAMaxChannels]qoaLMS{}, + lms: [QOAMaxChannels]qoaLMS{}, } expectedHeader := []byte{ @@ -245,7 +245,7 @@ func TestBasicDecode(t *testing.T) { assert.NotEmpty(t, q.Samples, "Expected samples") assert.NotEmpty(t, q.Channels, "Expected channels") assert.NotEmpty(t, q.SampleRate, "Expected sample rate") - assert.NotEmpty(t, q.LMS[0], "Expected LMS data") + assert.NotEmpty(t, q.lms[0], "Expected LMS data") } func TestBasicEncode(t *testing.T) { From 78f4ac16a549cd64b1e829cde200f3fc0b6ab84c Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 22:09:32 -0700 Subject: [PATCH 06/11] Add helper function --- pkg/qoa/qoa.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/qoa/qoa.go b/pkg/qoa/qoa.go index e15f853..85336ce 100644 --- a/pkg/qoa/qoa.go +++ b/pkg/qoa/qoa.go @@ -267,3 +267,24 @@ func clampS16(v int) int16 { } return int16(v) } + +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 +} From cb27be6bdb5b684774e8c745fc17017d909133be Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 22:10:09 -0700 Subject: [PATCH 07/11] Small optimizations Function inlining and like --- pkg/qoa/encode.go | 22 +++++++++++++--------- pkg/qoa/qoa.go | 32 ++++++++++++-------------------- pkg/qoa/qoa_test.go | 22 ---------------------- 3 files changed, 25 insertions(+), 51 deletions(-) diff --git a/pkg/qoa/encode.go b/pkg/qoa/encode.go index 392bab0..828fa62 100644 --- a/pkg/qoa/encode.go +++ b/pkg/qoa/encode.go @@ -15,10 +15,10 @@ func (q *QOA) encodeHeader(header []byte) { // encodeFrame encodes a QOA frame using the provided sample data and returns the size of the encoded frame. // Each frame contains an 8 byte frame header, the current 16 byte en-/decoder state per channel and 256 slices per channel. Each slice is 8 bytes wide and encodes 20 samples of audio data. -func (q *QOA) encodeFrame(sampleData []int16, frameLen uint32, bytes []byte) uint { +func (q *QOA) encodeFrame(sampleData []int16, frameLen uint32, bytes []byte) uint32 { channels := q.Channels - p := uint(0) + p := uint32(0) slices := (frameLen + QOASliceLen - 1) / QOASliceLen frameSize := qoaFrameSize(channels, slices) @@ -27,12 +27,13 @@ func (q *QOA) encodeFrame(sampleData []int16, frameLen uint32, bytes []byte) uin } // Write the frame header + header := uint64(q.Channels)<<56 | + uint64(q.SampleRate)<<32 | + uint64(frameLen)<<16 | + uint64(frameSize) binary.BigEndian.PutUint64( bytes, - uint64(q.Channels)<<56| - uint64(q.SampleRate)<<32| - uint64(frameLen)<<16| - uint64(frameSize), + header, ) p += 8 @@ -122,10 +123,15 @@ func (q *QOA) findBestScaleFactor(sampleIndex uint32, currentChannel uint32, sli predicted := lms.predict() residual := sample - predicted - scaled := div(residual, scaleFactor) + /* + div() implements a rounding division, but avoids rounding to zero for small numbers. E.g. 0.1 will be rounded to 1. Note that 0 itself still returns as 0, which is handled in the qoa_quant_tab[]. qoa_div() takes an index into the .16 fixed point qoa_reciprocal_tab as an argument, so it can do the division with a cheaper integer multiplication. + */ + scaled := (residual*qoaReciprocalTable[scaleFactor] + (1 << 15)) >> 16 + scaled += (residual >> 31) - (scaled >> 31) // Round away from 0 clamped := clamp(scaled, -8, 8) quantized := qoaQuantTable[clamped+8] dequantized := qoaDequantTable[scaleFactor][quantized] + reconstructed := clampS16(predicted + int(dequantized)) errDelta := int64(sample - int(reconstructed)) @@ -147,9 +153,7 @@ func (q *QOA) findBestScaleFactor(sampleIndex uint32, currentChannel uint32, sli bestLMS = lms bestScaleFactor = scaleFactor } - } - return bestScaleFactor, bestError, bestSlice, &bestLMS } diff --git a/pkg/qoa/qoa.go b/pkg/qoa/qoa.go index 85336ce..8073d46 100644 --- a/pkg/qoa/qoa.go +++ b/pkg/qoa/qoa.go @@ -98,6 +98,12 @@ dequantized residual forms the final output sample. */ package qoa +import ( + "fmt" + "io" + "os" +) + const ( // QOAMagic is the magic number identifying a QOA file QOAMagic = 0x716f6166 // 'qoaf' @@ -122,8 +128,8 @@ func qoaFrameSize(channels, slices uint32) uint32 { // qoaLMS represents the LMS state per channel. type qoaLMS struct { - History [QOALMSLen]int16 - Weights [QOALMSLen]int16 + History [4]int16 + Weights [4]int16 } // QOA stores the QOA audio file description. @@ -202,13 +208,10 @@ The adjustment of the weights is done with a "Sign-Sign-LMS" that adds or subtra This is all done with fixed point integers. Hence the right-shifts when updating the weights and calculating the prediction. */ func (lms *qoaLMS) predict() int { - prediction := 0 - // Loop unrolled for QOALMSLen - prediction += int(lms.Weights[0]) * int(lms.History[0]) - prediction += int(lms.Weights[1]) * int(lms.History[1]) - prediction += int(lms.Weights[2]) * int(lms.History[2]) - prediction += int(lms.Weights[3]) * int(lms.History[3]) - return prediction >> 13 + return (int(lms.Weights[0])*int(lms.History[0]) + + int(lms.Weights[1])*int(lms.History[1]) + + int(lms.Weights[2])*int(lms.History[2]) + + int(lms.Weights[3])*int(lms.History[3])) >> 13 } func (lms *qoaLMS) update(sample int16, residual int16) { @@ -225,23 +228,12 @@ func (lms *qoaLMS) update(sample int16, residual int16) { } } - // Loop unrolled for QOALMSLen lms.History[0] = lms.History[1] lms.History[1] = lms.History[2] lms.History[2] = lms.History[3] lms.History[3] = sample } -/* -div() implements a rounding division, but avoids rounding to zero for small numbers. E.g. 0.1 will be rounded to 1. Note that 0 itself still returns as 0, which is handled in the qoa_quant_tab[]. qoa_div() takes an index into the .16 fixed point qoa_reciprocal_tab as an argument, so it can do the division with a cheaper integer multiplication. -*/ -func div(v, scaleFactor int) int { - reciprocal := qoaReciprocalTable[scaleFactor] - n := (v*reciprocal + (1 << 15)) >> 16 - n += (v >> 31) - (n >> 31) // Round away from 0 - return n -} - // clamps a value between a minimum and maximum value. func clamp(v, min, max int) int { if v <= min { diff --git a/pkg/qoa/qoa_test.go b/pkg/qoa/qoa_test.go index a5863b9..57b6577 100644 --- a/pkg/qoa/qoa_test.go +++ b/pkg/qoa/qoa_test.go @@ -109,28 +109,6 @@ func TestLMSUpdate(t *testing.T) { } } -func TestDiv(t *testing.T) { - testCases := []struct { - v int - scaleFactor int - expected int - }{ - {100, 1, 14}, - {-100, 1, -14}, - {70, 2, 3}, - {-70, 2, -3}, - {0, 2, 0}, - {1, 0, 1}, - } - - for i, tc := range testCases { - t.Run(fmt.Sprintf("Test Case %d", i), func(t *testing.T) { - result := div(tc.v, tc.scaleFactor) - assert.Equal(t, tc.expected, result, "Incorrect result") - }) - } -} - func TestClamp(t *testing.T) { testCases := []struct { v, min, max int From 46f54a3501138025c050e78632065d53a2cb84c3 Mon Sep 17 00:00:00 2001 From: braheezy Date: Fri, 12 Apr 2024 23:46:52 -0700 Subject: [PATCH 08/11] Comments, style, and cleanup --- cmd/play.go | 3 +- cmd/style.go | 8 ++ cmd/tui.go | 229 ++++++++++++++++++++++++++------------------------- 3 files changed, 127 insertions(+), 113 deletions(-) create mode 100644 cmd/style.go diff --git a/cmd/play.go b/cmd/play.go index 4f969d2..1abd7ef 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -10,11 +10,12 @@ import ( ) var playCmd = &cobra.Command{ - Use: "play ", + Use: "play ", 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) { + // 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) diff --git a/cmd/style.go b/cmd/style.go new file mode 100644 index 0000000..8b60eed --- /dev/null +++ b/cmd/style.go @@ -0,0 +1,8 @@ +package cmd + +const ( + padding = 4 + maxWidth = 60 + qoaRed = "#7b2165" + qoaPink = "#dd81c7" +) diff --git a/cmd/tui.go b/cmd/tui.go index b135bba..ee2627c 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -12,75 +12,96 @@ import ( "github.com/ebitengine/oto/v3" ) +// ========================================== +// =============== Messages ================= +// ========================================== +// tickMsg is sent periodically to update the progress bar. type tickMsg time.Time -type playerMsg int +// tickCmd is a helper function to create a tickMsg. +func tickCmd() tea.Cmd { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// controlsMsg is sent to control various things about the music player. +type controlsMsg int -func sendPlayerMsg(msg playerMsg) tea.Cmd { +const ( + start controlsMsg = iota + stop +) + +// sendControlsMsg is a helper function to create a controlsMsg. +func sendControlsMsg(msg controlsMsg) tea.Cmd { return func() tea.Msg { return msg } } -type changeSong int +// changeSongMsg is sent to change the song. +type changeSongMsg int const ( - next changeSong = iota + next changeSongMsg = iota prev ) -func sendChangeSongMsg(msg changeSong) tea.Cmd { +// sendChangeSongMsg is a helper function to create a changeSongMsg. +func sendChangeSongMsg(msg changeSongMsg) tea.Cmd { return func() tea.Msg { return msg } } -const ( - start playerMsg = iota - stop -) +// ========================================== +// ================ Models ================== +// ========================================== +// model holds the main state of the application. type model struct { - filenames []string + // filenames is a list of filenames to play. + filenames []string + // currentIndex is the index of the current song playing currentIndex int - qoaPlayer *qoaPlayer - ctx *oto.Context + // qoaPlayer is the QOA player + qoaPlayer *qoaPlayer + // ctx is the Oto context. There can only be one per process. + ctx *oto.Context } +// qoaPlayer handles playing QOA audio files and showing progress. type qoaPlayer struct { - qoaData []int16 - player *oto.Player - qoaMetadata qoa.QOA - startTime time.Time - lastPauseTime time.Time // Tracks when the last pause started - totalPausedTime time.Duration // Accumulates total time spent paused - totalLength time.Duration - filename string - progress progress.Model - paused bool + // qoaData is the raw QOA encoded audio bytes. + qoaData []int16 + // player is the Oto player, which does the actually playing of sound. + player *oto.Player + // qoaMetadata is the QOA encoder struct. + qoaMetadata qoa.QOA + // startTime is the time when the song started playing. + startTime time.Time + // lastPauseTime is the time when the last pause started. + lastPauseTime time.Time + // totalPausedTime is the total time spent paused. + totalPausedTime time.Duration + // totalLength is the total length of the song. + totalLength time.Duration + // filename is the filename of the song being played. + filename string + // progress is the progress bubble model. + progress progress.Model + // paused is whether the song is paused. + paused bool } -func newModel(filenames []string) *model { - _, err := qoa.IsValidQOAFile(filenames[0]) - if err != nil { - logger.Fatalf("Error validating QOA file: %v", err) - } - - qoaBytes, err := os.ReadFile(filenames[0]) - if err != nil { - logger.Fatalf("Error reading QOA file: %v", err) - } - - // Decode the QOA audio data - qoaMetadata, _, err := qoa.Decode(qoaBytes) - if err != nil { - logger.Fatalf("Error decoding QOA data: %v", err) - } - +// initialModel creates a new model with the given filenames. +func initialModel(filenames []string) *model { // Prepare an Oto context (this will use the default audio device) ctx, ready, err := oto.NewContext( &oto.NewContextOptions{ - SampleRate: int(qoaMetadata.SampleRate), + // Typically 44100 or 48000, we could get it from a QOA file but we'd have to decode one. + SampleRate: 44100, // only 1 or 2 are supported by oto ChannelCount: 2, Format: oto.FormatSignedInt16LE, @@ -100,6 +121,7 @@ func newModel(filenames []string) *model { return m } +// newQOAPlayer creates a new QOA player for the given filename. func (m *model) newQOAPlayer(filename string) *qoaPlayer { _, err := qoa.IsValidQOAFile(filename) if err != nil { @@ -111,14 +133,16 @@ func (m *model) newQOAPlayer(filename string) *qoaPlayer { logger.Fatalf("Error reading QOA file: %v", err) } - // Decode the QOA audio data qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes) if err != nil { logger.Fatalf("Error decoding QOA data: %v", err) } - prog := progress.New(progress.WithDefaultGradient()) + totalLength := time.Duration(qoaMetadata.Samples/qoaMetadata.SampleRate) * time.Second + + prog := progress.New(progress.WithGradient(qoaRed, qoaPink)) prog.ShowPercentage = false + prog.Width = maxWidth player := m.ctx.NewPlayer(NewQOAAudioReader(qoaAudioData)) return &qoaPlayer{ @@ -127,130 +151,113 @@ func (m *model) newQOAPlayer(filename string) *qoaPlayer { qoaMetadata: *qoaMetadata, progress: prog, player: player, - totalLength: time.Duration(qoaMetadata.Samples/qoaMetadata.SampleRate) * time.Second, + totalLength: totalLength, } } +// ========================================== +// ================= Main =================== +// ========================================== +// startTUI is the main entry point for the TUI. func startTUI(inputFiles []string) { - // If inputFiles[0] is a directory, get the immediate contents. Only files ending in .qoa. - fileInfo, err := os.Stat(inputFiles[0]) - if err != nil { - logger.Fatalf("Error reading file: %v", err) - } - if fileInfo.IsDir() { - files, err := os.ReadDir(inputFiles[0]) - if err != nil { - logger.Fatalf("Error reading directory: %v", err) - } - for _, file := range files { - if !file.IsDir() && strings.HasSuffix(file.Name(), ".qoa") { - inputFiles = append(inputFiles, file.Name()) - } - } - if len(inputFiles) == 0 { - logger.Fatal("No .qoa files found in directory") - } - // Remove the first element, the directory name - inputFiles = inputFiles[1:] - } - p := tea.NewProgram(newModel(inputFiles)) + p := tea.NewProgram(initialModel(inputFiles)) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } } -func (m model) Init() tea.Cmd { - return tea.Batch(sendPlayerMsg(start)) -} - -// Start playback and initialize timing -func (qp *qoaPlayer) StartPlayback() { - qp.player.Play() - if qp.startTime.IsZero() { - qp.startTime = time.Now() - } else { - qp.totalPausedTime += time.Since(qp.lastPauseTime) - qp.lastPauseTime = time.Time{} // Reset last pause time - } -} -// Pause playback and track pause timing -func (qp *qoaPlayer) PausePlayback() { - qp.player.Pause() - qp.lastPauseTime = time.Now() +func (m model) Init() tea.Cmd { + return tea.Batch(sendControlsMsg(start)) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var teaCommands []tea.Cmd - switch msg := msg.(type) { + // Handle terminal resizing + case tea.WindowSizeMsg: + m.qoaPlayer.progress.Width = msg.Width - padding*2 - 4 + if m.qoaPlayer.progress.Width > maxWidth { + m.qoaPlayer.progress.Width = maxWidth + } + return m, nil + + // Handle key presses case tea.KeyMsg: switch msg.String() { - case "q", "ctrl+c": + case "q", "ctrl+c", "esc": if m.qoaPlayer.player.IsPlaying() { - m.qoaPlayer.player.Close() // Ensure player resources are freed + m.qoaPlayer.player.Close() } return m, tea.Quit - case "p": // Adding a pause/play toggle + case "p": + // pause/play toggle + var cmd tea.Cmd if m.qoaPlayer.player.IsPlaying() { - teaCommands = append(teaCommands, sendPlayerMsg(stop)) + cmd = sendControlsMsg(stop) } else if m.qoaPlayer.player != nil { - teaCommands = append(teaCommands, sendPlayerMsg(start)) + cmd = sendControlsMsg(start) } + return m, cmd } - case playerMsg: + // Handle requests to change controls (play, pause, etc.) + case controlsMsg: switch msg { case start: if !m.qoaPlayer.player.IsPlaying() { m.qoaPlayer.player.Play() m.qoaPlayer.paused = false + + // Account for time spent paused, if needed if m.qoaPlayer.startTime.IsZero() { m.qoaPlayer.startTime = time.Now() } else { m.qoaPlayer.totalPausedTime += time.Since(m.qoaPlayer.lastPauseTime) m.qoaPlayer.lastPauseTime = time.Time{} // Reset last pause time } - teaCommands = append(teaCommands, tickCmd()) + // Now that we are definitely playing, start the progress bubble + return m, tickCmd() } case stop: m.qoaPlayer.player.Pause() m.qoaPlayer.lastPauseTime = time.Now() m.qoaPlayer.paused = true - } - case changeSong: + // Handle requests to change song (prev, next, etc.) + case changeSongMsg: switch msg { case next: m = nextSong(m) - teaCommands = append(teaCommands, sendPlayerMsg(start)) + return m, sendControlsMsg(start) } + // Update the progress. This is called periodically, so also handle songs that are over. case tickMsg: + // Check if the song is over, ignoring progress bubble status in case the song ended before it go to 100%. if !m.qoaPlayer.player.IsPlaying() && !m.qoaPlayer.paused { - teaCommands = append(teaCommands, sendChangeSongMsg(next)) - return m, tea.Batch(teaCommands...) + // Just go to the next song. + return m, sendChangeSongMsg(next) } + // If we're still playing, update accordingly if m.qoaPlayer.player.IsPlaying() { elapsed := time.Since(m.qoaPlayer.startTime) - m.qoaPlayer.totalPausedTime newPercent := elapsed.Seconds() / m.qoaPlayer.totalLength.Seconds() cmd := m.qoaPlayer.progress.SetPercent(newPercent) - teaCommands = append(teaCommands, cmd, tickCmd()) - return m, tea.Batch(teaCommands...) - + // Set new progress bar percent and keep ticking + return m, tea.Batch(cmd, tickCmd()) } else if m.qoaPlayer.progress.Percent() >= 1.0 { - - teaCommands = append(teaCommands, sendChangeSongMsg(next)) - return m, tea.Batch(teaCommands...) + // Progress is at 100%, so song must be over. + return m, tea.Batch(sendChangeSongMsg(next)) } case progress.FrameMsg: progressModel, cmd := m.qoaPlayer.progress.Update(msg) m.qoaPlayer.progress = progressModel.(progress.Model) - teaCommands = append(teaCommands, cmd) + return m, cmd } - return m, tea.Batch(teaCommands...) + return m, nil } +// nextSong changes to the next song in the filenames list, wrapping around to 0 if needed. func nextSong(m model) model { m.qoaPlayer.player.Close() @@ -262,18 +269,16 @@ func nextSong(m model) model { m.qoaPlayer = m.newQOAPlayer(nextFile) m.currentIndex = nextIndex - // Return the new QOA player and a command to update the progress bar + // Return the new QOA player return m } +// ========================================== +// ================= View =================== +// ========================================== +// View renders the current state of the application. func (m model) View() string { pad := strings.Repeat(" ", 2) statusLine := "Press 'p' to pause/play, 'q' to quit." return fmt.Sprintf("\nPlaying: %s (index: %v)\n\n%s%s\n\n%s%s\n", m.qoaPlayer.filename, m.currentIndex, pad, m.qoaPlayer.progress.View(), pad, statusLine) } - -func tickCmd() tea.Cmd { - return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} From 23464b0d868af2f15a34e295247d808cbd6b2eb1 Mon Sep 17 00:00:00 2001 From: braheezy Date: Sat, 13 Apr 2024 10:08:06 -0700 Subject: [PATCH 09/11] Use help bubble --- cmd/keys.go | 29 +++++++++++++++++++++++++++++ cmd/style.go | 10 ++++++++++ cmd/tui.go | 41 ++++++++++++++++++++++++++++++++--------- 3 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 cmd/keys.go diff --git a/cmd/keys.go b/cmd/keys.go new file mode 100644 index 0000000..e00e67e --- /dev/null +++ b/cmd/keys.go @@ -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 + } +} diff --git a/cmd/style.go b/cmd/style.go index 8b60eed..7bc33b0 100644 --- a/cmd/style.go +++ b/cmd/style.go @@ -1,8 +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)) ) diff --git a/cmd/tui.go b/cmd/tui.go index ee2627c..b1bac6c 100644 --- a/cmd/tui.go +++ b/cmd/tui.go @@ -7,6 +7,8 @@ import ( "time" "github.com/braheezy/goqoa/pkg/qoa" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" "github.com/ebitengine/oto/v3" @@ -69,6 +71,10 @@ type model struct { qoaPlayer *qoaPlayer // ctx is the Oto context. There can only be one per process. ctx *oto.Context + // help is the help bubble model + help help.Model + // To support help + keys helpKeyMap } // qoaPlayer handles playing QOA audio files and showing progress. @@ -109,6 +115,9 @@ func initialModel(filenames []string) *model { if err != nil { panic("oto.NewContext failed: " + err.Error()) } + // Create the help bubble + help := help.New() + // Wait for the context to be ready <-ready @@ -116,6 +125,8 @@ func initialModel(filenames []string) *model { filenames: filenames, currentIndex: 0, ctx: ctx, + help: help, + keys: helpsKeys, } m.qoaPlayer = m.newQOAPlayer(filenames[0]) return m @@ -180,17 +191,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.qoaPlayer.progress.Width = maxWidth } return m, nil - // Handle key presses case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": + switch { + case key.Matches(msg, m.keys.quit): if m.qoaPlayer.player.IsPlaying() { m.qoaPlayer.player.Close() } return m, tea.Quit - case "p": - // pause/play toggle + case key.Matches(msg, m.keys.togglePlay): var cmd tea.Cmd if m.qoaPlayer.player.IsPlaying() { cmd = sendControlsMsg(stop) @@ -247,7 +256,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Progress is at 100%, so song must be over. return m, tea.Batch(sendChangeSongMsg(next)) } - + // Update the progress bubble case progress.FrameMsg: progressModel, cmd := m.qoaPlayer.progress.Update(msg) m.qoaPlayer.progress = progressModel.(progress.Model) @@ -278,7 +287,21 @@ func nextSong(m model) model { // ========================================== // View renders the current state of the application. func (m model) View() string { - pad := strings.Repeat(" ", 2) - statusLine := "Press 'p' to pause/play, 'q' to quit." - return fmt.Sprintf("\nPlaying: %s (index: %v)\n\n%s%s\n\n%s%s\n", m.qoaPlayer.filename, m.currentIndex, pad, m.qoaPlayer.progress.View(), pad, statusLine) + var view strings.Builder + // pad := strings.Repeat(" ", 2) + // Status line + statusLine := fmt.Sprintf("Playing: %s (index: %v)", m.qoaPlayer.filename, m.currentIndex) + view.WriteString(statusStyle.Render(statusLine)) + + // Song progress + view.WriteRune('\n') + view.WriteString(m.qoaPlayer.progress.View()) + view.WriteString("\n\n") + + view.WriteString(m.help.View(m.keys)) + view.WriteRune('\n') + + // statusLine := "Press 'p' to pause/play, 'q' to quit." + return view.String() + } From 43249180a67508314fa6ff5174723b284937936f Mon Sep 17 00:00:00 2001 From: braheezy Date: Sat, 13 Apr 2024 10:18:31 -0700 Subject: [PATCH 10/11] Put upx back, and whitespace --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583ee54..22eab2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,16 +13,26 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.21" + - name: Install dependencies run: go get ./... + - name: Build run: go build -ldflags="-s -w" -gcflags=all="-l -B" -trimpath -buildvcs=false -v . + + - name: Run UPX + uses: crazy-max/ghaction-upx@v3 + with: + files: goqoa.exe + - name: Go Test run: go test -v ./... + - uses: actions/upload-artifact@v4 with: name: windows @@ -35,16 +45,21 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.21" + - name: Install dependencies run: go get ./... + - name: Build run: go build -ldflags="-s -w" -gcflags=all="-l -B" -trimpath -buildvcs=false -o goqoa-mac -v . + - name: Go Test run: go test -v ./... + - uses: actions/upload-artifact@v4 with: name: mac @@ -57,32 +72,40 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Install Go uses: actions/setup-go@v4 with: go-version: "1.21" + - name: Install dependencies run: | go get ./... sudo apt-get update sudo apt-get install -y libasound2-dev + - name: Build run: | go build -ldflags="-s -w" -gcflags=all="-l -B" -trimpath -buildvcs=false -o goqoa-linux -v . upx --best goqoa-linux + - name: Go Test run: go test -v ./... + - name: Cache large spec pack uses: actions/cache@v3 with: key: qoa_test_samples_2023_02_18.zip path: qoa_test_samples_2023_02_18.zip + - name: Download large spec pack run: wget --timestamping https://qoaformat.org/samples/qoa_test_samples_2023_02_18.zip + - name: Spec Test run: | sudo cp goqoa-linux /usr/bin/goqoa bash check_spec.sh + - uses: actions/upload-artifact@v4 with: name: linux @@ -99,7 +122,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + - name: Release uses: softprops/action-gh-release@v1 with: From a47a1ce11fc7650a2a1abb0d821118c1ca306c7e Mon Sep 17 00:00:00 2001 From: braheezy Date: Sat, 13 Apr 2024 10:19:34 -0700 Subject: [PATCH 11/11] Fix syntax --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22eab2b..6823c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,8 @@ name: ci -"on": - push: null - workflow_dispatch: null +on: + push: + workflow_dispatch: + concurrency: group: ${{ github.ref }} cancel-in-progress: true