init commit

This commit is contained in:
2026-01-25 17:13:15 -06:00
commit 1bbfc332d8
27 changed files with 2462 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
test/

61
IDEA.MD Normal file
View File

@@ -0,0 +1,61 @@
# Application Idea
`playback` is a fullscreen TUI for playing audio files in parallel with rendering their transcription from an SRT file.
## Features
**Waveform View**
A section that renders the "timeline" which show the waveform as well as a needle showing the current location
**Transcript View**
A scrollable view that shows the contents of the SRT. As the audio is playing (or paused) at any particular location, the
relevant text from the transcript should be highlighted showing the current location. Allow for an edit mode to fix
transcriptions. With vim style interactions. If vim can be incorperated itself then eve better.
**Keyboard Navigation**
- When the waveform view is the active view, move forward and back in the audio with `left`, `right`, `h`, and `l`.
- When the transcript view is active, use vim binding to move around. Including `i` to enter interactive/edit mode.
- Use `ctrl + j` and `ctrl + k` to move between wave form and transcript mode.
**Layout**
The two views will stack vertically on top of each other, with the waveform being on top. Not too much vertical room is
needed for the waveform. The trascript section will take up the rest of the vertical space and will be scrollable.
There will be a small section at the top with the absolute path to the audio file and to the SRT file.
## Workflow
Application launched with
```bash
playback ./audioFileName -t ./transcriptFileName.srt
```
`-t` stands for transcript and it is optional. If `-t` is not provided the system will first try to find a file in the same
directory as the audio file that is also named the same except with an `srt` extension. If that is not found them the application
will launch with no transcript.
**No Transcript Mode**
The app can function perfectly fine with no transcript. The user can play, pause, rewind, fast forward, no issue. In this mode,
the application will actually create a stand in, empty transcript with a formualted name of `sameNameOfFile.srt.tmp` in the system `/tmp`
folder. The user will be able to edit this file in the editor and save it (using the vim commands when not in edit mode). If the user
saves the file and it is one of these temp files, then it will make a new file in the dir of the audio file named `sameNameOfFile.srt`
The tmp transcript will be mostly empty, other than message about no transcript was found and a temproary file was made, which they can
edit here, and what it will be saved as. Also the file will inform them about the `transcribe` tool found at
`https://git.beitzah.net/ysandler/transcribe`. It will also inform them about how to launch with a srt file is the mistakenly didn't
include it
## Configuration
- config file is found at `~/.config/playback`
## Tech stack
I want to build as much as possible in Golang.
I want to have versioning with a file called `VERSION` that just contains the version value
I want to use `make` to build
I want an install.sh script that creates a desktop registry for app launchers (only worrying about linux right now)

36
Makefile Normal file
View File

@@ -0,0 +1,36 @@
.PHONY: build release run test install uninstall clean
BINARY_NAME := playback
BUILD_DIR := build
INSTALL_DIR := /usr/local/bin
VERSION := $(shell cat VERSION)
LDFLAGS := -X 'playback/pkg/version.Version=$(VERSION)'
build:
@mkdir -p $(BUILD_DIR)
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/playback
release:
@mkdir -p $(BUILD_DIR)
go build -ldflags="-s -w $(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/playback
run: build
./$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
test:
go test ./...
install: release
sudo cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
chmod +x $(INSTALL_DIR)/$(BINARY_NAME)
./install.sh
uninstall:
sudo rm -f $(INSTALL_DIR)/$(BINARY_NAME)
rm -f ~/.local/share/applications/playback.desktop
update-desktop-database ~/.local/share/applications 2>/dev/null || true
clean:
rm -rf $(BUILD_DIR)
go clean

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# Playback
A terminal-based audio player with synchronized transcript viewing and editing.
## Installation
```bash
make install
```
## Usage
```bash
playback audio.mp3 # Auto-finds transcript (audio.srt)
playback audio.mp3 -t transcript.srt # Specify transcript
```
## Keybindings
| Key | Action |
|-----|--------|
| `space` | Play/Pause |
| `ctrl+j` | Focus transcript |
| `ctrl+k` | Focus waveform |
| `q` | Quit |
| `?` | Toggle help |
**Waveform (focused):**
- `h/l` or arrows: Seek 5s
- `H/L`: Seek 30s
**Transcript (focused):**
- `j/k`: Navigate cues
- `ctrl+d/u`: Jump 5 cues
- `g/G`: First/last cue
- `enter`: Seek audio to cue
- `i`: Edit in external editor
## Configuration
Config file: `~/.config/playback/config.json`
```json
{
"seek_step_ms": 5000,
"big_seek_step_ms": 30000,
"volume": 1.0,
"editor": "nvim"
}
```
Default editor is `vim`.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

72
cmd/playback/main.go Normal file
View File

@@ -0,0 +1,72 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"playback/internal/app"
"playback/internal/audio"
"playback/pkg/version"
)
var (
transcriptPath string
)
func main() {
rootCmd := &cobra.Command{
Use: "playback <audio-file>",
Short: "Audio player with synchronized transcript display",
Long: `Playback is a fullscreen TUI application for playing audio files
with synchronized SRT transcript display and vim-style editing.
Features:
- Play MP3, WAV, FLAC, and OGG files
- Display and highlight transcripts in sync with audio
- Vim-style editing for transcript modification
- Waveform visualization with seek controls
- Configurable keybindings`,
Args: cobra.ExactArgs(1),
Version: version.Get(),
RunE: run,
}
rootCmd.Flags().StringVarP(&transcriptPath, "transcript", "t", "", "Path to SRT transcript file")
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func run(cmd *cobra.Command, args []string) error {
audioPath := args[0]
// Validate audio file exists
if _, err := os.Stat(audioPath); os.IsNotExist(err) {
return fmt.Errorf("audio file not found: %s", audioPath)
}
// Validate audio format
if err := audio.ValidateFormat(audioPath); err != nil {
return err
}
// Validate transcript file if specified
if transcriptPath != "" {
if _, err := os.Stat(transcriptPath); os.IsNotExist(err) {
return fmt.Errorf("transcript file not found: %s", transcriptPath)
}
}
// Create and run the app
m := app.New(audioPath, transcriptPath)
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running app: %w", err)
}
return nil
}

47
go.mod Normal file
View File

@@ -0,0 +1,47 @@
module playback
go 1.25.4
require (
github.com/asticode/go-astisub v0.38.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gopxl/beep/v2 v2.1.1
github.com/spf13/cobra v1.10.2
)
require (
github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ebitengine/oto/v3 v3.3.2 // indirect
github.com/ebitengine/purego v0.8.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jfreymuth/oggvorbis v1.0.5 // indirect
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.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mewkiz/flac v1.0.12 // indirect
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

144
go.sum Normal file
View File

@@ -0,0 +1,144 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astisub v0.38.0 h1:Qh3IO8Cotn0wwok5maid7xqsIJTwn2DtABT1UajKJaI=
github.com/asticode/go-astisub v0.38.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/oto/v3 v3.3.2 h1:VTWBsKX9eb+dXzaF4jEwQbs4yWIdXukJ0K40KgkpYlg=
github.com/ebitengine/oto/v3 v3.3.2/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU=
github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
github.com/jszwec/csvutil v1.5.1/go.mod h1:Rpu7Uu9giO9subDyMCIQfHVDuLrcaC36UA4YcJjGBkg=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mewkiz/flac v1.0.12 h1:5Y1BRlUebfiVXPmz7hDD7h3ceV2XNrGNMejNVjDpgPY=
github.com/mewkiz/flac v1.0.12/go.mod h1:1UeXlFRJp4ft2mfZnPLRpQTd7cSjb/s17o7JQzzyrCA=
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-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20210809222454-d867a43fc93e/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-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

23
install.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Create desktop entry directory if it doesn't exist
mkdir -p ~/.local/share/applications
# Create desktop entry
cat > ~/.local/share/applications/playback.desktop << 'EOF'
[Desktop Entry]
Name=Playback
Comment=Audio player with synchronized transcript display
Exec=/usr/local/bin/playback %f
Icon=audio-x-generic
Terminal=true
Type=Application
Categories=AudioVideo;Audio;Player;
MimeType=audio/mpeg;audio/mp3;audio/wav;audio/x-wav;audio/flac;audio/ogg;audio/x-vorbis+ogg;
EOF
# Update desktop database
update-desktop-database ~/.local/share/applications 2>/dev/null || true
echo "Desktop entry installed to ~/.local/share/applications/playback.desktop"
echo "Playback is now available in your application menu"

374
internal/app/app.go Normal file
View File

@@ -0,0 +1,374 @@
package app
import (
"fmt"
"os/exec"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"playback/internal/audio"
"playback/internal/config"
"playback/internal/srt"
"playback/internal/ui"
"playback/internal/ui/header"
"playback/internal/ui/transcript"
"playback/internal/ui/waveform"
)
// FocusedView represents which view has focus
type FocusedView int
const (
FocusWaveform FocusedView = iota
FocusTranscript
)
// Model is the main application model
type Model struct {
// Configuration
config config.Config
keys KeyMap
// Audio
player *audio.Player
audioPath string
// Transcript
transcriptPath string
// UI Components
header header.Model
waveform waveform.Model
transcript transcript.Model
// State
focused FocusedView
showHelp bool
width int
height int
err error
statusMsg string
quitting bool
}
// New creates a new application model
func New(audioPath, transcriptPath string) Model {
cfg := config.Load()
m := Model{
config: cfg,
keys: DefaultKeyMap(),
player: audio.NewPlayer(),
audioPath: audioPath,
transcriptPath: transcriptPath,
header: header.New(),
waveform: waveform.New(),
transcript: transcript.New(),
focused: FocusWaveform,
}
return m
}
// Init initializes the application
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.loadAudio(),
m.loadTranscript(),
m.tickCmd(),
)
}
func (m Model) loadAudio() tea.Cmd {
return func() tea.Msg {
if err := m.player.Load(m.audioPath); err != nil {
return ErrorMsg{Err: fmt.Errorf("failed to load audio: %w", err)}
}
// Load waveform data
samples, err := m.player.GetSamples(200)
if err != nil {
return WaveformLoadedMsg{Err: err}
}
return WaveformLoadedMsg{Samples: samples}
}
}
func (m Model) loadTranscript() tea.Cmd {
return func() tea.Msg {
path := m.transcriptPath
// Try to find transcript if not specified
if path == "" {
path = srt.FindTranscript(m.audioPath)
}
// Create temp file if no transcript found
if path == "" {
var err error
path, err = srt.CreateTempTranscript(m.audioPath)
if err != nil {
return ErrorMsg{Err: fmt.Errorf("failed to create temp transcript: %w", err)}
}
}
t, err := srt.Load(path)
if err != nil {
return ErrorMsg{Err: fmt.Errorf("failed to load transcript: %w", err)}
}
return transcriptLoadedMsg{transcript: t}
}
}
type transcriptLoadedMsg struct {
transcript *srt.Transcript
}
func (m Model) tickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// Update handles messages
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateLayout()
case tea.KeyMsg:
// Global keys
switch {
case key.Matches(msg, m.keys.Quit):
m.quitting = true
m.player.Close()
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.showHelp = !m.showHelp
case key.Matches(msg, m.keys.PlayPause):
m.player.Toggle()
case key.Matches(msg, m.keys.FocusWaveform):
m.focused = FocusWaveform
m.waveform.SetFocused(true)
m.transcript.SetFocused(false)
case key.Matches(msg, m.keys.FocusTranscript):
m.focused = FocusTranscript
m.waveform.SetFocused(false)
m.transcript.SetFocused(true)
case key.Matches(msg, m.keys.EnterEdit):
if m.focused == FocusTranscript {
m.player.Pause()
return m, m.launchEditor()
}
}
// Context-specific keys
if m.focused == FocusWaveform {
switch {
case key.Matches(msg, m.keys.SeekForward):
m.player.SeekRelative(m.config.SeekStep)
case key.Matches(msg, m.keys.SeekBackward):
m.player.SeekRelative(-m.config.SeekStep)
case key.Matches(msg, m.keys.SeekForwardBig):
m.player.SeekRelative(m.config.BigSeekStep)
case key.Matches(msg, m.keys.SeekBackwardBig):
m.player.SeekRelative(-m.config.BigSeekStep)
}
}
if m.focused == FocusTranscript {
cmd := m.transcript.Update(msg)
cmds = append(cmds, cmd)
}
case TickMsg:
m.updatePosition()
cmds = append(cmds, m.tickCmd())
case WaveformLoadedMsg:
if msg.Err != nil {
m.statusMsg = fmt.Sprintf("Waveform: %v", msg.Err)
} else {
data := audio.NewWaveformData(msg.Samples)
m.waveform.SetSamples(data)
m.waveform.SetDuration(m.player.Duration())
}
case transcriptLoadedMsg:
m.transcript.SetTranscript(msg.transcript)
m.transcriptPath = msg.transcript.FilePath
m.header.SetPaths(m.audioPath, m.transcriptPath, msg.transcript.IsTemp)
case ErrorMsg:
m.err = msg.Err
case SavedMsg:
if msg.Err != nil {
m.statusMsg = fmt.Sprintf("Save failed: %v", msg.Err)
} else {
m.statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
}
case VimExitedMsg:
if msg.Err != nil {
m.statusMsg = fmt.Sprintf("Editor error: %v", msg.Err)
} else {
return m, m.reloadTranscript(msg.Path)
}
case transcript.SeekToCueMsg:
m.player.Seek(msg.Position)
}
return m, tea.Batch(cmds...)
}
func (m *Model) updatePosition() {
pos := m.player.Position()
dur := m.player.Duration()
if dur > 0 {
m.waveform.SetPosition(float64(pos) / float64(dur))
}
m.transcript.SetPosition(pos)
}
func (m *Model) updateLayout() {
// Header: 2 lines
headerHeight := 2
// Waveform: 5 lines
waveformHeight := 5
// Status bar: 1 line
statusHeight := 1
// Transcript: remaining space
transcriptHeight := m.height - headerHeight - waveformHeight - statusHeight - 2
m.header.SetWidth(m.width)
m.waveform.SetSize(m.width, waveformHeight)
m.transcript.SetSize(m.width, transcriptHeight)
m.waveform.SetFocused(m.focused == FocusWaveform)
m.transcript.SetFocused(m.focused == FocusTranscript)
}
func (m Model) launchEditor() tea.Cmd {
t := m.transcript.Transcript()
if t == nil {
return nil
}
lineNum := m.transcript.SelectedCueLineNumber()
c := exec.Command(m.config.Editor, fmt.Sprintf("+%d", lineNum), t.FilePath)
return tea.ExecProcess(c, func(err error) tea.Msg {
return VimExitedMsg{Path: t.FilePath, Err: err}
})
}
func (m Model) reloadTranscript(path string) tea.Cmd {
return func() tea.Msg {
t, err := srt.Load(path)
if err != nil {
return ErrorMsg{Err: err}
}
return transcriptLoadedMsg{transcript: t}
}
}
// View renders the application
func (m Model) View() string {
if m.quitting {
return ""
}
if m.err != nil {
return ui.ErrorStyle.Render(fmt.Sprintf("Error: %v\n\nPress 'q' to quit.", m.err))
}
if m.showHelp {
return m.renderHelp()
}
// Header
headerView := m.header.View(m.player.Position(), m.player.Duration(), m.player.IsPlaying())
// Waveform
waveformView := m.waveform.View()
// Transcript
transcriptView := m.transcript.View()
// Status bar
statusView := m.renderStatus()
return lipgloss.JoinVertical(
lipgloss.Left,
headerView,
"",
waveformView,
transcriptView,
statusView,
)
}
func (m Model) renderStatus() string {
// Mode indicator
modeStyle := ui.ModeStyle
mode := modeStyle.Render(m.transcript.ModeString())
// Focus indicator
focusStr := "Waveform"
if m.focused == FocusTranscript {
focusStr = "Transcript"
}
focus := ui.BaseStyle.Render(fmt.Sprintf("[%s]", focusStr))
// Status message
statusMsg := ui.StatusBarStyle.Render(m.statusMsg)
// Help hint
helpHint := ui.HelpDescStyle.Render("Press ? for help")
return lipgloss.JoinHorizontal(
lipgloss.Center,
mode,
" ",
focus,
" ",
statusMsg,
lipgloss.NewStyle().Width(m.width-lipgloss.Width(mode)-lipgloss.Width(focus)-lipgloss.Width(statusMsg)-lipgloss.Width(helpHint)-8).Render(""),
helpHint,
)
}
func (m Model) renderHelp() string {
help := m.keys.HelpView()
helpStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ui.ColorPrimary).
Padding(1, 2).
Width(60)
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
helpStyle.Render(help),
)
}

130
internal/app/keys.go Normal file
View File

@@ -0,0 +1,130 @@
package app
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines all keybindings
type KeyMap struct {
// Global
Quit key.Binding
Help key.Binding
PlayPause key.Binding
// Focus
FocusWaveform key.Binding
FocusTranscript key.Binding
// Waveform navigation
SeekForward key.Binding
SeekBackward key.Binding
SeekForwardBig key.Binding
SeekBackwardBig key.Binding
// Transcript navigation
ScrollUp key.Binding
ScrollDown key.Binding
PageUp key.Binding
PageDown key.Binding
GoTop key.Binding
GoBottom key.Binding
// Editing
EnterEdit key.Binding
}
// DefaultKeyMap returns the default keybindings
func DefaultKeyMap() KeyMap {
return KeyMap{
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "help"),
),
PlayPause: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "play/pause"),
),
FocusWaveform: key.NewBinding(
key.WithKeys("ctrl+k"),
key.WithHelp("ctrl+k", "focus waveform"),
),
FocusTranscript: key.NewBinding(
key.WithKeys("ctrl+j"),
key.WithHelp("ctrl+j", "focus transcript"),
),
SeekForward: key.NewBinding(
key.WithKeys("l", "right"),
key.WithHelp("l/→", "seek forward"),
),
SeekBackward: key.NewBinding(
key.WithKeys("h", "left"),
key.WithHelp("h/←", "seek backward"),
),
SeekForwardBig: key.NewBinding(
key.WithKeys("L", "shift+right"),
key.WithHelp("L", "seek forward (big)"),
),
SeekBackwardBig: key.NewBinding(
key.WithKeys("H", "shift+left"),
key.WithHelp("H", "seek backward (big)"),
),
ScrollUp: key.NewBinding(
key.WithKeys("k", "up"),
key.WithHelp("k/↑", "scroll up"),
),
ScrollDown: key.NewBinding(
key.WithKeys("j", "down"),
key.WithHelp("j/↓", "scroll down"),
),
PageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("ctrl+d"),
key.WithHelp("ctrl+d", "page down"),
),
GoTop: key.NewBinding(
key.WithKeys("g"),
key.WithHelp("gg", "go to top"),
),
GoBottom: key.NewBinding(
key.WithKeys("G"),
key.WithHelp("G", "go to bottom"),
),
EnterEdit: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "edit transcript"),
),
}
}
// HelpView returns a formatted help string
func (k KeyMap) HelpView() string {
return `Keybindings:
Global:
space Play/Pause
ctrl+j Focus transcript
ctrl+k Focus waveform
q Quit
? Toggle help
Waveform (when focused):
h / ← Seek backward (5s)
l / → Seek forward (5s)
H Seek backward (30s)
L Seek forward (30s)
Transcript (when focused):
j / ↓ Next cue
k / ↑ Previous cue
ctrl+d Jump 5 cues down
ctrl+u Jump 5 cues up
g Go to first cue
G Go to last cue
enter Seek audio to cue
i Edit in $EDITOR at cue`
}

29
internal/app/messages.go Normal file
View File

@@ -0,0 +1,29 @@
package app
import "time"
// TickMsg is sent periodically to update playback position
type TickMsg time.Time
// WaveformLoadedMsg is sent when waveform data is ready
type WaveformLoadedMsg struct {
Samples []float64
Err error
}
// ErrorMsg represents an error
type ErrorMsg struct {
Err error
}
// SavedMsg is sent when transcript is saved
type SavedMsg struct {
Path string
Err error
}
// VimExitedMsg is sent when vim finishes editing
type VimExitedMsg struct {
Path string
Err error
}

60
internal/audio/formats.go Normal file
View File

@@ -0,0 +1,60 @@
package audio
import (
"fmt"
"path/filepath"
"strings"
)
// AudioFormat represents a supported audio format
type AudioFormat int
const (
FormatUnknown AudioFormat = iota
FormatMP3
FormatWAV
FormatFLAC
FormatOGG
)
// DetectFormat returns the audio format based on file extension
func DetectFormat(path string) AudioFormat {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".mp3":
return FormatMP3
case ".wav":
return FormatWAV
case ".flac":
return FormatFLAC
case ".ogg":
return FormatOGG
default:
return FormatUnknown
}
}
// String returns the format name
func (f AudioFormat) String() string {
switch f {
case FormatMP3:
return "MP3"
case FormatWAV:
return "WAV"
case FormatFLAC:
return "FLAC"
case FormatOGG:
return "OGG"
default:
return "Unknown"
}
}
// ValidateFormat checks if the file format is supported
func ValidateFormat(path string) error {
format := DetectFormat(path)
if format == FormatUnknown {
return fmt.Errorf("unsupported audio format: %s", filepath.Ext(path))
}
return nil
}

313
internal/audio/player.go Normal file
View File

@@ -0,0 +1,313 @@
package audio
import (
"fmt"
"io"
"os"
"sync"
"time"
"github.com/gopxl/beep/v2"
"github.com/gopxl/beep/v2/flac"
"github.com/gopxl/beep/v2/mp3"
"github.com/gopxl/beep/v2/speaker"
"github.com/gopxl/beep/v2/vorbis"
"github.com/gopxl/beep/v2/wav"
)
// Player handles audio playback
type Player struct {
mu sync.Mutex
filePath string
file *os.File
streamer beep.StreamSeekCloser
format beep.Format
ctrl *beep.Ctrl
resampler *beep.Resampler
playing bool
initialized bool
}
// NewPlayer creates a new audio player
func NewPlayer() *Player {
return &Player{}
}
// Load opens an audio file for playback
func (p *Player) Load(path string) error {
p.mu.Lock()
defer p.mu.Unlock()
// Close any existing file
if p.file != nil {
p.streamer.Close()
p.file.Close()
}
format := DetectFormat(path)
if format == FormatUnknown {
return fmt.Errorf("unsupported audio format")
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open audio file: %w", err)
}
var streamer beep.StreamSeekCloser
var audioFormat beep.Format
switch format {
case FormatMP3:
streamer, audioFormat, err = mp3.Decode(file)
case FormatWAV:
streamer, audioFormat, err = wav.Decode(file)
case FormatFLAC:
streamer, audioFormat, err = flac.Decode(file)
case FormatOGG:
streamer, audioFormat, err = vorbis.Decode(file)
}
if err != nil {
file.Close()
return fmt.Errorf("failed to decode audio: %w", err)
}
p.filePath = path
p.file = file
p.streamer = streamer
p.format = audioFormat
p.ctrl = &beep.Ctrl{Streamer: streamer, Paused: true}
// Initialize speaker if not already done
if !p.initialized {
sampleRate := beep.SampleRate(44100)
if err := speaker.Init(sampleRate, sampleRate.N(time.Second/10)); err != nil {
return fmt.Errorf("failed to initialize speaker: %w", err)
}
p.initialized = true
}
// Resample if needed
targetRate := beep.SampleRate(44100)
if audioFormat.SampleRate != targetRate {
p.resampler = beep.Resample(4, audioFormat.SampleRate, targetRate, p.ctrl)
speaker.Play(p.resampler)
} else {
p.resampler = nil
speaker.Play(p.ctrl)
}
return nil
}
// Play starts or resumes playback
func (p *Player) Play() {
p.mu.Lock()
defer p.mu.Unlock()
if p.ctrl == nil {
return
}
speaker.Lock()
p.ctrl.Paused = false
speaker.Unlock()
p.playing = true
}
// Pause pauses playback
func (p *Player) Pause() {
p.mu.Lock()
defer p.mu.Unlock()
if p.ctrl == nil {
return
}
speaker.Lock()
p.ctrl.Paused = true
speaker.Unlock()
p.playing = false
}
// Toggle toggles between play and pause
func (p *Player) Toggle() {
p.mu.Lock()
if p.ctrl == nil {
p.mu.Unlock()
return
}
playing := p.playing
p.mu.Unlock()
if playing {
p.Pause()
} else {
p.Play()
}
}
// IsPlaying returns true if audio is currently playing
func (p *Player) IsPlaying() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.playing
}
// Position returns the current playback position
func (p *Player) Position() time.Duration {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return 0
}
speaker.Lock()
pos := p.format.SampleRate.D(p.streamer.Position())
speaker.Unlock()
return pos
}
// Duration returns the total duration
func (p *Player) Duration() time.Duration {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return 0
}
return p.format.SampleRate.D(p.streamer.Len())
}
// Seek moves to the specified position
func (p *Player) Seek(pos time.Duration) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return nil
}
sample := p.format.SampleRate.N(pos)
if sample < 0 {
sample = 0
}
if sample > p.streamer.Len() {
sample = p.streamer.Len()
}
speaker.Lock()
err := p.streamer.Seek(sample)
speaker.Unlock()
return err
}
// SeekRelative seeks relative to current position
func (p *Player) SeekRelative(delta time.Duration) error {
pos := p.Position()
return p.Seek(pos + delta)
}
// Close releases resources
func (p *Player) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer != nil {
p.streamer.Close()
}
if p.file != nil {
p.file.Close()
}
}
// Format returns the audio format info
func (p *Player) Format() beep.Format {
p.mu.Lock()
defer p.mu.Unlock()
return p.format
}
// Streamer returns the underlying streamer (for waveform extraction)
func (p *Player) Streamer() beep.StreamSeekCloser {
p.mu.Lock()
defer p.mu.Unlock()
return p.streamer
}
// GetSamples extracts sample data for waveform visualization
func (p *Player) GetSamples(numSamples int) ([]float64, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return nil, fmt.Errorf("no audio loaded")
}
totalSamples := p.streamer.Len()
if totalSamples == 0 {
return nil, fmt.Errorf("empty audio")
}
samples := make([]float64, numSamples)
samplesPerBucket := totalSamples / numSamples
if samplesPerBucket < 1 {
samplesPerBucket = 1
}
// Save current position
speaker.Lock()
currentPos := p.streamer.Position()
speaker.Unlock()
buf := make([][2]float64, samplesPerBucket)
for i := 0; i < numSamples; i++ {
targetPos := i * samplesPerBucket
if targetPos >= totalSamples {
break
}
speaker.Lock()
p.streamer.Seek(targetPos)
speaker.Unlock()
speaker.Lock()
n, ok := p.streamer.Stream(buf)
speaker.Unlock()
if !ok || n == 0 {
if err, isErr := p.streamer.(interface{ Err() error }); isErr && err.Err() != nil {
if err.Err() != io.EOF {
continue
}
}
continue
}
// Calculate average absolute amplitude
var sum float64
for j := 0; j < n; j++ {
val := (buf[j][0] + buf[j][1]) / 2
if val < 0 {
val = -val
}
sum += val
}
samples[i] = sum / float64(n)
}
// Restore position
speaker.Lock()
p.streamer.Seek(currentPos)
speaker.Unlock()
return samples, nil
}

View File

@@ -0,0 +1,36 @@
package audio
// WaveformData holds pre-computed waveform samples
type WaveformData struct {
Samples []float64
MaxValue float64
}
// NewWaveformData creates waveform data from raw samples
func NewWaveformData(samples []float64) *WaveformData {
wd := &WaveformData{
Samples: samples,
}
// Find max value for normalization
for _, s := range samples {
if s > wd.MaxValue {
wd.MaxValue = s
}
}
if wd.MaxValue == 0 {
wd.MaxValue = 1 // Avoid division by zero
}
return wd
}
// Normalized returns samples normalized to 0-1 range
func (w *WaveformData) Normalized() []float64 {
normalized := make([]float64, len(w.Samples))
for i, s := range w.Samples {
normalized[i] = s / w.MaxValue
}
return normalized
}

92
internal/config/config.go Normal file
View File

@@ -0,0 +1,92 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// Config holds application configuration
type Config struct {
SeekStep time.Duration `json:"seek_step"`
BigSeekStep time.Duration `json:"big_seek_step"`
Volume float64 `json:"volume"`
Theme string `json:"theme"`
Editor string `json:"editor"`
}
// configFile is the JSON serialization format
type configFile struct {
SeekStepMs int64 `json:"seek_step_ms"`
BigSeekStepMs int64 `json:"big_seek_step_ms"`
Volume float64 `json:"volume"`
Theme string `json:"theme"`
Editor string `json:"editor"`
}
// configPath returns the path to the config file
func configPath() string {
configDir, err := os.UserConfigDir()
if err != nil {
configDir = os.Getenv("HOME")
}
return filepath.Join(configDir, "playback", "config.json")
}
// Load loads configuration from disk, returning defaults if not found
func Load() Config {
cfg := DefaultConfig()
data, err := os.ReadFile(configPath())
if err != nil {
return cfg
}
var cf configFile
if err := json.Unmarshal(data, &cf); err != nil {
return cfg
}
if cf.SeekStepMs > 0 {
cfg.SeekStep = time.Duration(cf.SeekStepMs) * time.Millisecond
}
if cf.BigSeekStepMs > 0 {
cfg.BigSeekStep = time.Duration(cf.BigSeekStepMs) * time.Millisecond
}
if cf.Volume > 0 {
cfg.Volume = cf.Volume
}
if cf.Theme != "" {
cfg.Theme = cf.Theme
}
if cf.Editor != "" {
cfg.Editor = cf.Editor
}
return cfg
}
// Save writes configuration to disk
func (c Config) Save() error {
cf := configFile{
SeekStepMs: c.SeekStep.Milliseconds(),
BigSeekStepMs: c.BigSeekStep.Milliseconds(),
Volume: c.Volume,
Theme: c.Theme,
Editor: c.Editor,
}
data, err := json.MarshalIndent(cf, "", " ")
if err != nil {
return err
}
// Ensure directory exists
dir := filepath.Dir(configPath())
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(configPath(), data, 0644)
}

View File

@@ -0,0 +1,22 @@
package config
import "time"
// Default configuration values
const (
DefaultSeekStep = 5 * time.Second
DefaultBigSeekStep = 30 * time.Second
DefaultVolume = 1.0
DefaultEditor = "vim"
)
// DefaultConfig returns the default configuration
func DefaultConfig() Config {
return Config{
SeekStep: DefaultSeekStep,
BigSeekStep: DefaultBigSeekStep,
Volume: DefaultVolume,
Theme: "default",
Editor: DefaultEditor,
}
}

112
internal/srt/parser.go Normal file
View File

@@ -0,0 +1,112 @@
package srt
import (
"fmt"
"os"
"path/filepath"
"strings"
astisub "github.com/asticode/go-astisub"
)
// Load loads an SRT file from the given path
func Load(path string) (*Transcript, error) {
subs, err := astisub.OpenFile(path)
if err != nil {
return nil, fmt.Errorf("failed to parse SRT file: %w", err)
}
transcript := &Transcript{
FilePath: path,
IsTemp: strings.HasSuffix(path, ".tmp"),
Cues: make([]Cue, len(subs.Items)),
}
lineNum := 1 // SRT files are 1-indexed
for i, item := range subs.Items {
var textParts []string
for _, line := range item.Lines {
var lineParts []string
for _, lineItem := range line.Items {
lineParts = append(lineParts, lineItem.Text)
}
textParts = append(textParts, strings.Join(lineParts, ""))
}
text := strings.Join(textParts, "\n")
transcript.Cues[i] = Cue{
Index: i + 1,
Start: item.StartAt,
End: item.EndAt,
Text: text,
LineNumber: lineNum,
}
// Calculate lines used by this cue:
// 1 (index) + 1 (timestamp) + text lines + 1 (blank line)
textLines := 1
if text != "" {
textLines = strings.Count(text, "\n") + 1
}
lineNum += 2 + textLines + 1 // index + timestamp + text + blank
}
return transcript, nil
}
// FindTranscript looks for an SRT file next to the audio file
func FindTranscript(audioPath string) string {
ext := filepath.Ext(audioPath)
basePath := strings.TrimSuffix(audioPath, ext)
// Try common SRT naming patterns
patterns := []string{
basePath + ".srt",
basePath + ".en.srt",
audioPath + ".srt",
}
for _, pattern := range patterns {
if _, err := os.Stat(pattern); err == nil {
return pattern
}
}
return ""
}
// CreateTempTranscript creates a temporary SRT file with placeholder content
func CreateTempTranscript(audioPath string) (string, error) {
basename := filepath.Base(audioPath)
ext := filepath.Ext(basename)
nameOnly := strings.TrimSuffix(basename, ext)
tempPath := filepath.Join(os.TempDir(), nameOnly+".srt.tmp")
content := fmt.Sprintf(`1
00:00:00,000 --> 00:00:05,000
[No transcript found for: %s]
2
00:00:05,000 --> 00:00:15,000
This is a temporary transcript file.
You can edit it using vim-style commands.
Press 'i' to enter edit mode, 'esc' to exit.
3
00:00:15,000 --> 00:00:25,000
To generate a transcript automatically, try:
https://git.beitzah.net/ysandler/transcribe
4
00:00:25,000 --> 00:00:35,000
Or launch with an existing transcript:
playback %s -t /path/to/transcript.srt
`, basename, basename)
if err := os.WriteFile(tempPath, []byte(content), 0644); err != nil {
return "", fmt.Errorf("failed to create temp transcript: %w", err)
}
return tempPath, nil
}

39
internal/srt/types.go Normal file
View File

@@ -0,0 +1,39 @@
package srt
import "time"
// Cue represents a single subtitle entry
type Cue struct {
Index int
Start time.Duration
End time.Duration
Text string
LineNumber int // Line number in the SRT file (1-indexed)
}
// Transcript represents a complete subtitle file
type Transcript struct {
Cues []Cue
FilePath string
IsTemp bool
}
// CueAt returns the cue that contains the given time position
func (t *Transcript) CueAt(pos time.Duration) *Cue {
for i := range t.Cues {
if pos >= t.Cues[i].Start && pos < t.Cues[i].End {
return &t.Cues[i]
}
}
return nil
}
// CueIndexAt returns the index of the cue at the given position, or -1
func (t *Transcript) CueIndexAt(pos time.Duration) int {
for i := range t.Cues {
if pos >= t.Cues[i].Start && pos < t.Cues[i].End {
return i
}
}
return -1
}

79
internal/srt/writer.go Normal file
View File

@@ -0,0 +1,79 @@
package srt
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// formatDuration formats a duration as SRT timestamp (HH:MM:SS,mmm)
func formatDuration(d int64) string {
ms := d % 1000
d /= 1000
s := d % 60
d /= 60
m := d % 60
h := d / 60
return fmt.Sprintf("%02d:%02d:%02d,%03d", h, m, s, ms)
}
// Save writes the transcript to an SRT file
func (t *Transcript) Save() error {
return t.SaveTo(t.FilePath)
}
// SaveTo writes the transcript to the specified path
func (t *Transcript) SaveTo(path string) error {
var sb strings.Builder
for i, cue := range t.Cues {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d\n", cue.Index))
sb.WriteString(fmt.Sprintf("%s --> %s\n",
formatDuration(cue.Start.Milliseconds()),
formatDuration(cue.End.Milliseconds())))
sb.WriteString(cue.Text)
sb.WriteString("\n")
}
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// PromoteTempFile saves the transcript to a permanent location
func (t *Transcript) PromoteTempFile(audioPath string) (string, error) {
if !t.IsTemp {
return t.FilePath, nil
}
// Create permanent path next to audio file
ext := filepath.Ext(audioPath)
permanentPath := strings.TrimSuffix(audioPath, ext) + ".srt"
if err := t.SaveTo(permanentPath); err != nil {
return "", err
}
// Update transcript state
t.FilePath = permanentPath
t.IsTemp = false
// Remove temp file
tempPath := filepath.Join(os.TempDir(), filepath.Base(audioPath))
os.Remove(strings.TrimSuffix(tempPath, filepath.Ext(tempPath)) + ".srt.tmp")
return permanentPath, nil
}

View File

@@ -0,0 +1,97 @@
package header
import (
"fmt"
"path/filepath"
"time"
"github.com/charmbracelet/lipgloss"
"playback/internal/ui"
)
// Model represents the header component
type Model struct {
AudioPath string
TranscriptPath string
IsTemp bool
Width int
}
// New creates a new header model
func New() Model {
return Model{}
}
// SetPaths sets the file paths
func (m *Model) SetPaths(audioPath, transcriptPath string, isTemp bool) {
m.AudioPath = audioPath
m.TranscriptPath = transcriptPath
m.IsTemp = isTemp
}
// SetWidth sets the header width
func (m *Model) SetWidth(width int) {
m.Width = width
}
// formatDuration formats a duration as MM:SS
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
m := d / time.Minute
s := (d % time.Minute) / time.Second
return fmt.Sprintf("%02d:%02d", m, s)
}
// View renders the header
func (m Model) View(position, duration time.Duration, playing bool) string {
// Title
title := ui.HeaderStyle.Render("♪ Playback")
// File info
audioName := filepath.Base(m.AudioPath)
transcriptName := filepath.Base(m.TranscriptPath)
if m.IsTemp {
transcriptName += " (temp)"
}
fileInfo := ui.FilePathStyle.Render(
fmt.Sprintf("Audio: %s | Transcript: %s", audioName, transcriptName),
)
// Playback status
status := "⏸ Paused"
if playing {
status = "▶ Playing"
}
timeInfo := fmt.Sprintf("%s / %s", formatDuration(position), formatDuration(duration))
statusStyle := lipgloss.NewStyle().Foreground(ui.ColorSecondary)
if !playing {
statusStyle = lipgloss.NewStyle().Foreground(ui.ColorMuted)
}
rightSide := lipgloss.JoinHorizontal(
lipgloss.Center,
statusStyle.Render(status),
" ",
ui.BaseStyle.Render(timeInfo),
)
// Layout
leftWidth := lipgloss.Width(title) + lipgloss.Width(fileInfo) + 2
rightWidth := lipgloss.Width(rightSide)
spacerWidth := m.Width - leftWidth - rightWidth - 4
if spacerWidth < 1 {
spacerWidth = 1
}
return lipgloss.JoinHorizontal(
lipgloss.Center,
title,
" ",
fileInfo,
lipgloss.NewStyle().Width(spacerWidth).Render(""),
rightSide,
)
}

106
internal/ui/styles.go Normal file
View File

@@ -0,0 +1,106 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Colors
var (
ColorPrimary = lipgloss.Color("#7C3AED") // Purple
ColorSecondary = lipgloss.Color("#10B981") // Green
ColorAccent = lipgloss.Color("#F59E0B") // Amber
ColorMuted = lipgloss.Color("#6B7280") // Gray
ColorBackground = lipgloss.Color("#1F2937") // Dark gray
ColorForeground = lipgloss.Color("#F9FAFB") // Light gray
ColorHighlight = lipgloss.Color("#374151") // Medium gray
ColorError = lipgloss.Color("#EF4444") // Red
)
// Styles
var (
// Base styles
BaseStyle = lipgloss.NewStyle().
Foreground(ColorForeground)
// Header styles
HeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorPrimary).
Padding(0, 1)
FilePathStyle = lipgloss.NewStyle().
Foreground(ColorMuted).
Italic(true)
// Waveform styles
WaveformStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorMuted).
Padding(0, 1)
WaveformFocusedStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorPrimary).
Padding(0, 1)
NeedleStyle = lipgloss.NewStyle().
Foreground(ColorAccent).
Bold(true)
// Transcript styles
TranscriptStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorMuted).
Padding(0, 1)
TranscriptFocusedStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorPrimary).
Padding(0, 1)
CurrentCueStyle = lipgloss.NewStyle().
Background(ColorHighlight).
Foreground(ColorSecondary).
Bold(true)
SelectedCueStyle = lipgloss.NewStyle().
Foreground(ColorAccent).
Bold(true)
TimestampStyle = lipgloss.NewStyle().
Foreground(ColorMuted)
SelectedTimestampStyle = lipgloss.NewStyle().
Foreground(ColorAccent)
// Status bar styles
StatusBarStyle = lipgloss.NewStyle().
Foreground(ColorMuted).
Padding(0, 1)
ModeStyle = lipgloss.NewStyle().
Background(ColorPrimary).
Foreground(ColorForeground).
Padding(0, 1).
Bold(true)
InsertModeStyle = lipgloss.NewStyle().
Background(ColorSecondary).
Foreground(ColorForeground).
Padding(0, 1).
Bold(true)
CommandStyle = lipgloss.NewStyle().
Foreground(ColorAccent)
// Help styles
HelpKeyStyle = lipgloss.NewStyle().
Foreground(ColorSecondary).
Bold(true)
HelpDescStyle = lipgloss.NewStyle().
Foreground(ColorMuted)
// Error styles
ErrorStyle = lipgloss.NewStyle().
Foreground(ColorError).
Bold(true)
)

View File

@@ -0,0 +1,63 @@
package transcript
import (
"fmt"
"time"
"github.com/charmbracelet/lipgloss"
"playback/internal/srt"
"playback/internal/ui"
)
// RenderCue renders a single cue with optional highlighting
func RenderCue(cue *srt.Cue, isCurrent, isSelected bool, width int) string {
// Format timestamp
timestamp := formatTimestamp(cue.Start, cue.End)
// Apply styles based on state
var textStyle, timestampStyle lipgloss.Style
if isSelected {
// Selected cue (navigation cursor) - use accent color
textStyle = ui.SelectedCueStyle
timestampStyle = ui.SelectedTimestampStyle
} else if isCurrent {
// Current cue (playback position)
textStyle = ui.CurrentCueStyle
timestampStyle = ui.TimestampStyle
} else {
textStyle = ui.BaseStyle
timestampStyle = ui.TimestampStyle
}
timestampStr := timestampStyle.Render(timestamp)
textStr := textStyle.Render(cue.Text)
// Add selection indicator
prefix := " "
if isSelected {
prefix = "> "
}
return fmt.Sprintf("%s%s\n%s%s\n", prefix, timestampStr, prefix, textStr)
}
// formatTimestamp formats start/end times as SRT timestamp
func formatTimestamp(start, end time.Duration) string {
return fmt.Sprintf("%s --> %s",
formatTime(start),
formatTime(end),
)
}
// formatTime formats a duration as HH:MM:SS,mmm
func formatTime(d time.Duration) string {
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
d -= s * time.Second
ms := d / time.Millisecond
return fmt.Sprintf("%02d:%02d:%02d,%03d", h, m, s, ms)
}

View File

@@ -0,0 +1,233 @@
package transcript
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"playback/internal/srt"
"playback/internal/ui"
)
// SeekToCueMsg is sent when user wants to seek to a specific cue
type SeekToCueMsg struct {
Position time.Duration
}
// Model represents the transcript view component
type Model struct {
viewport viewport.Model
transcript *srt.Transcript
currentCue int // Cue currently playing (from playback position)
selectedCue int // Cue selected by user navigation
cueLines []int // Starting line number (in rendered view) for each cue
Width int
Height int
Focused bool
}
// New creates a new transcript model
func New() Model {
vp := viewport.New(80, 20)
vp.Style = lipgloss.NewStyle()
return Model{
viewport: vp,
currentCue: -1,
selectedCue: 0,
}
}
// SetTranscript sets the transcript to display
func (m *Model) SetTranscript(t *srt.Transcript) {
m.transcript = t
m.selectedCue = 0
m.updateContent()
m.scrollToCue(0)
}
// Transcript returns the current transcript
func (m *Model) Transcript() *srt.Transcript {
return m.transcript
}
// SelectedCueLineNumber returns the line number of the selected cue for vim
func (m *Model) SelectedCueLineNumber() int {
if m.transcript == nil || m.selectedCue < 0 || m.selectedCue >= len(m.transcript.Cues) {
return 1
}
return m.transcript.Cues[m.selectedCue].LineNumber
}
// SetPosition updates which cue is highlighted based on playback position
func (m *Model) SetPosition(pos time.Duration) {
if m.transcript == nil {
return
}
newCue := m.transcript.CueIndexAt(pos)
if newCue != m.currentCue {
m.currentCue = newCue
m.updateContent()
// Only auto-scroll if not focused (let user navigate freely when focused)
if !m.Focused && newCue >= 0 {
m.scrollToCue(newCue)
}
}
}
// SetSize sets the component dimensions
func (m *Model) SetSize(width, height int) {
m.Width = width
m.Height = height
m.viewport.Width = width - 4 // Account for border
m.viewport.Height = height - 2
m.updateContent()
}
// SetFocused sets the focus state
func (m *Model) SetFocused(focused bool) {
m.Focused = focused
// When focusing, sync selected cue to current playback position if valid
if focused && m.currentCue >= 0 {
m.selectedCue = m.currentCue
m.updateContent()
m.scrollToCue(m.selectedCue)
}
}
// ModeString returns the mode as a string
func (m *Model) ModeString() string {
return "VIEW"
}
// Update handles messages
func (m *Model) Update(msg tea.Msg) tea.Cmd {
if m.transcript == nil {
return nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "j", "down":
// Move to next cue
if m.selectedCue < len(m.transcript.Cues)-1 {
m.selectedCue++
m.refreshAndScroll()
}
return nil
case "k", "up":
// Move to previous cue
if m.selectedCue > 0 {
m.selectedCue--
m.refreshAndScroll()
}
return nil
case "ctrl+d":
// Jump 5 cues down
m.selectedCue += 5
if m.selectedCue >= len(m.transcript.Cues) {
m.selectedCue = len(m.transcript.Cues) - 1
}
m.refreshAndScroll()
return nil
case "ctrl+u":
// Jump 5 cues up
m.selectedCue -= 5
if m.selectedCue < 0 {
m.selectedCue = 0
}
m.refreshAndScroll()
return nil
case "g":
// Go to first cue
m.selectedCue = 0
m.refreshAndScroll()
return nil
case "G":
// Go to last cue
m.selectedCue = len(m.transcript.Cues) - 1
m.refreshAndScroll()
return nil
case "enter":
// Seek to selected cue
if m.selectedCue >= 0 && m.selectedCue < len(m.transcript.Cues) {
return func() tea.Msg {
return SeekToCueMsg{Position: m.transcript.Cues[m.selectedCue].Start}
}
}
return nil
}
}
return nil
}
// refreshAndScroll updates content and scrolls to selected cue
func (m *Model) refreshAndScroll() {
m.updateContent()
m.scrollToCue(m.selectedCue)
}
func (m *Model) updateContent() {
if m.transcript == nil {
m.viewport.SetContent("No transcript loaded")
return
}
// Track line positions for each cue
m.cueLines = make([]int, len(m.transcript.Cues))
currentLine := 0
var sb strings.Builder
for i, cue := range m.transcript.Cues {
m.cueLines[i] = currentLine
isCurrent := i == m.currentCue
isSelected := i == m.selectedCue
rendered := RenderCue(&cue, isCurrent, isSelected, m.Width-4)
sb.WriteString(rendered)
// Count lines in this cue's rendering
currentLine += strings.Count(rendered, "\n")
if i < len(m.transcript.Cues)-1 {
sb.WriteString("\n")
currentLine++ // blank line between cues
}
}
m.viewport.SetContent(sb.String())
}
func (m *Model) scrollToCue(cueIndex int) {
if cueIndex < 0 || m.transcript == nil || cueIndex >= len(m.cueLines) {
return
}
targetLine := m.cueLines[cueIndex]
// Center the cue in the viewport
viewportHeight := m.viewport.Height
offset := targetLine - viewportHeight/2
if offset < 0 {
offset = 0
}
m.viewport.SetYOffset(offset)
}
// View renders the transcript
func (m Model) View() string {
content := m.viewport.View()
style := ui.TranscriptStyle
if m.Focused {
style = ui.TranscriptFocusedStyle
}
return style.Width(m.Width - 2).Height(m.Height - 2).Render(content)
}

View File

@@ -0,0 +1,77 @@
package waveform
// Block characters for waveform rendering (bottom to top)
var blocks = []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
// RenderWaveform converts normalized samples (0-1) to block characters
func RenderWaveform(samples []float64, width int) string {
if len(samples) == 0 {
return ""
}
result := make([]rune, width)
for i := 0; i < width; i++ {
// Map position to sample index
sampleIdx := i * len(samples) / width
if sampleIdx >= len(samples) {
sampleIdx = len(samples) - 1
}
// Get sample value and map to block character
value := samples[sampleIdx]
if value < 0 {
value = 0
}
if value > 1 {
value = 1
}
blockIdx := int(value * float64(len(blocks)-1))
result[i] = blocks[blockIdx]
}
return string(result)
}
// RenderWaveformWithNeedle renders the waveform with a position indicator
func RenderWaveformWithNeedle(samples []float64, width int, position float64) (string, int) {
waveform := RenderWaveform(samples, width)
// Calculate needle position
needlePos := int(position * float64(width))
if needlePos < 0 {
needlePos = 0
}
if needlePos >= width {
needlePos = width - 1
}
return waveform, needlePos
}
// RenderWithColors returns the waveform with the needle position marked
// Returns: left part, needle char, right part
func RenderWithColors(samples []float64, width int, position float64) (string, string, string) {
waveform := []rune(RenderWaveform(samples, width))
if len(waveform) == 0 {
return "", "|", ""
}
needlePos := int(position * float64(len(waveform)))
if needlePos < 0 {
needlePos = 0
}
if needlePos >= len(waveform) {
needlePos = len(waveform) - 1
}
left := string(waveform[:needlePos])
needle := "|"
right := ""
if needlePos+1 < len(waveform) {
right = string(waveform[needlePos+1:])
}
return left, needle, right
}

View File

@@ -0,0 +1,133 @@
package waveform
import (
"time"
"github.com/charmbracelet/lipgloss"
"playback/internal/audio"
"playback/internal/ui"
)
// Model represents the waveform visualization component
type Model struct {
Width int
Height int
Focused bool
Samples []float64
Position float64 // 0.0 to 1.0
Duration time.Duration
}
// New creates a new waveform model
func New() Model {
return Model{
Height: 3,
}
}
// SetSamples sets the waveform samples
func (m *Model) SetSamples(data *audio.WaveformData) {
if data != nil {
m.Samples = data.Normalized()
}
}
// SetPosition sets the playback position (0.0 to 1.0)
func (m *Model) SetPosition(pos float64) {
m.Position = pos
}
// SetDuration sets the total duration
func (m *Model) SetDuration(d time.Duration) {
m.Duration = d
}
// SetSize sets the component dimensions
func (m *Model) SetSize(width, height int) {
m.Width = width
m.Height = height
}
// SetFocused sets the focus state
func (m *Model) SetFocused(focused bool) {
m.Focused = focused
}
// View renders the waveform
func (m Model) View() string {
contentWidth := m.Width - 4 // Account for border and padding
if contentWidth < 10 {
return ""
}
// Render waveform with needle position
left, needle, right := RenderWithColors(m.Samples, contentWidth, m.Position)
// Apply styles
waveformLine := lipgloss.JoinHorizontal(
lipgloss.Left,
ui.BaseStyle.Render(left),
ui.NeedleStyle.Render(needle),
ui.BaseStyle.Render(right),
)
// Time markers
startTime := "00:00"
endTime := formatDuration(m.Duration)
currentTime := formatDuration(time.Duration(m.Position * float64(m.Duration)))
timeMarkerWidth := contentWidth - len(startTime) - len(endTime)
if timeMarkerWidth < 0 {
timeMarkerWidth = 0
}
// Calculate current time position
currentTimePos := int(m.Position * float64(contentWidth))
currentTimeWidth := len(currentTime)
// Build time marker line
timeLine := ui.TimestampStyle.Render(startTime)
// Position current time
spaceBefore := currentTimePos - len(startTime) - currentTimeWidth/2
if spaceBefore < 0 {
spaceBefore = 0
}
spaceAfter := contentWidth - len(startTime) - spaceBefore - currentTimeWidth - len(endTime)
if spaceAfter < 0 {
spaceAfter = 0
}
timeLine = lipgloss.JoinHorizontal(
lipgloss.Left,
ui.TimestampStyle.Render(startTime),
lipgloss.NewStyle().Width(spaceBefore).Render(""),
ui.NeedleStyle.Render(currentTime),
lipgloss.NewStyle().Width(spaceAfter).Render(""),
ui.TimestampStyle.Render(endTime),
)
content := lipgloss.JoinVertical(
lipgloss.Left,
waveformLine,
timeLine,
)
// Apply border style based on focus
style := ui.WaveformStyle
if m.Focused {
style = ui.WaveformFocusedStyle
}
return style.Width(m.Width - 2).Render(content)
}
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
m := int(d / time.Minute)
s := int((d % time.Minute) / time.Second)
return string(rune('0'+m/10)) + string(rune('0'+m%10)) + ":" +
string(rune('0'+s/10)) + string(rune('0'+s%10))
}

30
pkg/version/version.go Normal file
View File

@@ -0,0 +1,30 @@
package version
import (
"os"
"path/filepath"
"runtime"
"strings"
)
// Version is set at build time or read from file
var Version = "dev"
// Get returns the application version
func Get() string {
if Version != "dev" {
return Version
}
// Try to read from VERSION file at runtime (for development)
_, filename, _, ok := runtime.Caller(0)
if ok {
root := filepath.Join(filepath.Dir(filename), "..", "..")
data, err := os.ReadFile(filepath.Join(root, "VERSION"))
if err == nil {
return strings.TrimSpace(string(data))
}
}
return Version
}