diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a1f827..6823c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: ci - on: push: workflow_dispatch: @@ -7,7 +6,6 @@ on: concurrency: group: ${{ github.ref }} cancel-in-progress: true - jobs: build-windows: runs-on: windows-latest @@ -40,7 +38,6 @@ jobs: with: name: windows path: goqoa.exe - build-mac: runs-on: macos-latest env: @@ -68,7 +65,6 @@ jobs: with: name: mac path: goqoa-mac - build-linux: runs-on: ubuntu-latest env: @@ -115,7 +111,6 @@ jobs: with: name: linux path: goqoa-linux - release: if: startsWith(github.ref, 'refs/tags/') needs: @@ -124,7 +119,6 @@ jobs: - build-linux permissions: contents: write - runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index 612f8c9..b79e045 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,9 @@ dist/ /fuzz/*.qoa /assets/*.gif *.prof -/output* -__debug_bin* +/output_* /*.wav +__debug_bin* /*.qoa TODO /raw_output.txt 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/play.go b/cmd/play.go index 7bd6d2d..1abd7ef 100644 --- a/cmd/play.go +++ b/cmd/play.go @@ -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 ", + 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) { - 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) } 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 +} diff --git a/cmd/style.go b/cmd/style.go new file mode 100644 index 0000000..7bc33b0 --- /dev/null +++ b/cmd/style.go @@ -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)) +) diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..b1bac6c --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,307 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "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" +) + +// ========================================== +// =============== Messages ================= +// ========================================== +// tickMsg is sent periodically to update the progress bar. +type tickMsg time.Time + +// 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 + +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 + } +} + +// changeSongMsg is sent to change the song. +type changeSongMsg int + +const ( + next changeSongMsg = iota + prev +) + +// sendChangeSongMsg is a helper function to create a changeSongMsg. +func sendChangeSongMsg(msg changeSongMsg) tea.Cmd { + return func() tea.Msg { + return msg + } +} + +// ========================================== +// ================ Models ================== +// ========================================== + +// model holds the main state of the application. +type model struct { + // filenames is a list of filenames to play. + filenames []string + // currentIndex is the index of the current song playing + currentIndex int + // qoaPlayer is the QOA player + 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. +type qoaPlayer struct { + // 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 +} + +// 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{ + // 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, + }) + if err != nil { + panic("oto.NewContext failed: " + err.Error()) + } + // Create the help bubble + help := help.New() + + // Wait for the context to be ready + <-ready + + m := &model{ + filenames: filenames, + currentIndex: 0, + ctx: ctx, + help: help, + keys: helpsKeys, + } + m.qoaPlayer = m.newQOAPlayer(filenames[0]) + 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 { + logger.Fatalf("Error validating QOA file: %v", err) + } + + qoaBytes, err := os.ReadFile(filename) + if err != nil { + logger.Fatalf("Error reading QOA file: %v", err) + } + + qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes) + if err != nil { + logger.Fatalf("Error decoding QOA data: %v", err) + } + + 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{ + filename: filename, + qoaData: qoaAudioData, + qoaMetadata: *qoaMetadata, + progress: prog, + player: player, + totalLength: totalLength, + } +} + +// ========================================== +// ================= Main =================== +// ========================================== +// startTUI is the main entry point for the TUI. +func startTUI(inputFiles []string) { + 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(sendControlsMsg(start)) +} + +func (m model) Update(msg tea.Msg) (tea.Model, 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 { + case key.Matches(msg, m.keys.quit): + if m.qoaPlayer.player.IsPlaying() { + m.qoaPlayer.player.Close() + } + return m, tea.Quit + case key.Matches(msg, m.keys.togglePlay): + var cmd tea.Cmd + if m.qoaPlayer.player.IsPlaying() { + cmd = sendControlsMsg(stop) + } else if m.qoaPlayer.player != nil { + cmd = sendControlsMsg(start) + } + return m, cmd + } + // 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 + } + // 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 + } + // Handle requests to change song (prev, next, etc.) + case changeSongMsg: + switch msg { + case next: + m = nextSong(m) + 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 { + // 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) + // Set new progress bar percent and keep ticking + return m, tea.Batch(cmd, tickCmd()) + } else if m.qoaPlayer.progress.Percent() >= 1.0 { + // 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) + return m, cmd + + } + 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() + + // 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 + return m +} + +// ========================================== +// ================= View =================== +// ========================================== +// View renders the current state of the application. +func (m model) View() string { + 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() + +} diff --git a/go.mod b/go.mod index 2b5ce74..86d3cff 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ 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/lipgloss v0.10.0 github.com/charmbracelet/log v0.4.0 github.com/ebitengine/oto/v3 v3.2.0 github.com/go-audio/audio v1.0.0 @@ -17,7 +20,8 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/lipgloss v0.10.0 // indirect + github.com/charmbracelet/harmonica v0.2.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.7.0 // 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.18.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 5f32cb9..89ef06a 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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= @@ -90,9 +107,12 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= 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..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 @@ -41,8 +42,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 +61,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 +92,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 +112,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) @@ -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 } @@ -173,15 +177,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..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,17 +128,17 @@ 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. 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 } @@ -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 { @@ -267,3 +259,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 +} diff --git a/pkg/qoa/qoa_test.go b/pkg/qoa/qoa_test.go index 3010fba..57b6577 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{ @@ -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 @@ -245,7 +223,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) {