package tui
import (
"fmt"
"image"
"os"
"path/filepath"
"sort"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"skraak/tools"
"skraak/utils"
)
type playbackTickMsg struct{}
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("62")).
Padding(0, 1)
labelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
helpDarkStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86"))
commentBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(0, 1)
)
func wrapText(text string, maxWidth int) string {
if len(text) <= maxWidth {
return text
}
lines := strings.Split(text, "\n")
var result []string
for _, line := range lines {
if len(line) <= maxWidth {
result = append(result, line)
continue
}
words := strings.Fields(line)
var currentLine string
for _, word := range words {
if len(currentLine)+len(word)+1 <= maxWidth {
if currentLine == "" {
currentLine = word
} else {
currentLine += " " + word
}
} else {
if currentLine != "" {
result = append(result, currentLine)
}
if len(word) > maxWidth {
result = append(result, word[:maxWidth])
word = word[maxWidth:]
}
currentLine = word
}
}
if currentLine != "" {
result = append(result, currentLine)
}
}
return strings.Join(result, "\n")
}
type Model struct {
state *tools.ClassifyState
err string
quitting bool
bindingsHelp string
commentMode bool commentText string commentCursor int
clipMode bool clipInput string
awaitingSecondaryFor string
imageGen *uint64
}
func New(state *tools.ClassifyState) Model {
sorted := make([]tools.KeyBinding, len(state.Config.Bindings))
copy(sorted, state.Config.Bindings)
keyRank := func(k string) int {
if len(k) == 0 {
return 3
}
c := k[0]
switch {
case c >= 'a' && c <= 'z':
return 0
case c >= 'A' && c <= 'Z':
return 1
case c >= '0' && c <= '9':
return 2
default:
return 3
}
}
sort.SliceStable(sorted, func(i, j int) bool {
ri, rj := keyRank(sorted[i].Key), keyRank(sorted[j].Key)
if ri != rj {
return ri < rj
}
return sorted[i].Key < sorted[j].Key
})
var bindings []string
for _, b := range sorted {
if b.CallType != "" {
bindings = append(bindings, fmt.Sprintf("%s=%s/%s", b.Key, b.Species, b.CallType))
} else {
bindings = append(bindings, fmt.Sprintf("%s=%s", b.Key, b.Species))
}
}
bindingsHelp := strings.Join(bindings, " ")
gen := uint64(0)
return Model{
state: state,
bindingsHelp: bindingsHelp,
imageGen: &gen,
}
}
func (m Model) protocol() utils.ImageProtocol {
if m.state.Config.ITerm {
return utils.ProtocolITerm
}
if m.state.Config.Sixel {
return utils.ProtocolSixel
}
return utils.ProtocolKitty
}
func (m Model) Init() tea.Cmd {
return inlineImageCmd(m.state, m.protocol(), *m.imageGen, m.imageGen)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKey(msg)
case playbackTickMsg:
if m.state.Player == nil || !m.state.Player.IsPlaying() {
return m, nil }
return m, playbackTick()
}
return m, nil
}
func (m Model) segmentChangeCmd() tea.Cmd {
(*m.imageGen)++
gen := *m.imageGen
return tea.Sequence(tea.ClearScreen, inlineImageCmd(m.state, m.protocol(), gen, m.imageGen))
}
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.commentMode {
return m.handleCommentKey(msg)
}
if m.clipMode {
return m.handleClipKey(msg)
}
m.err = ""
if m.awaitingSecondaryFor != "" {
if handled, model, cmd := m.handleSecondaryWait(msg); handled {
return model, cmd
}
}
if handled, model, cmd := m.handleSpecialKey(msg); handled {
return model, cmd
}
return m.handleSwitchKey(msg)
}
func (m Model) handleSecondaryWait(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
primary := m.awaitingSecondaryFor
m.awaitingSecondaryFor = ""
if msg.Key().Code == tea.KeyEscape || msg.Key().Code == tea.KeyEsc {
return true, m, nil
}
s := msg.String()
if len(s) == 1 {
if callType, ok := m.state.Config.SecondaryBindings[primary][s]; ok {
m.stopPlayer()
m.state.ApplyCallTypeOnly(callType)
if err := m.state.Save(); err != nil {
m.err = err.Error()
}
model, cmd := m.advanceOrQuit()
return true, model, cmd
}
}
return false, m, nil
}
func (m Model) handleSpecialKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
key := msg.Key()
if key.Code == tea.KeyEnter || key.Code == tea.KeyKpEnter {
speed := 1.0
if key.Mod&tea.ModShift != 0 {
speed = 0.5
}
if errMsg := playCurrentSegmentAtSpeed(m.state, speed); errMsg != "" {
m.err = errMsg
}
return true, m, playbackTick()
}
if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
m.stopPlayer()
m.quitting = true
return true, m, tea.Quit
}
if key.Code == tea.KeySpace {
m.commentText = m.state.GetCurrentComment()
m.commentCursor = len(m.commentText)
m.commentMode = true
return true, m, nil
}
if msg.String() == "ctrl+s" {
m.clipInput = ""
m.clipMode = true
return true, m, nil
}
return false, m, nil
}
func (m Model) handleSwitchKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
m.stopPlayer()
m.quitting = true
return m, tea.Quit
case ",", "left":
m.stopPlayer()
m.state.PrevSegment()
return m, m.segmentChangeCmd()
case ".", "right":
m.stopPlayer()
if !m.state.NextSegment() {
m.quitting = true
return m, tea.Quit
}
return m, m.segmentChangeCmd()
case "ctrl+d":
m.state.ToggleBookmark()
if err := m.state.Save(); err != nil {
m.err = err.Error()
}
return m, nil
case "ctrl+,":
m.stopPlayer()
if m.state.PrevBookmark() {
return m, m.segmentChangeCmd()
}
m.err = "No bookmarks found"
return m, nil
case "ctrl+.":
m.stopPlayer()
if m.state.NextBookmark() {
return m, m.segmentChangeCmd()
}
m.err = "No bookmarks found"
return m, nil
case "0":
m.stopPlayer()
if m.state.ConfirmLabel() {
if err := m.state.Save(); err != nil {
m.err = err.Error()
return m, nil
}
}
return m.advanceOrQuit()
default:
return m.handleBindingKey(msg)
}
}
func (m Model) handleBindingKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
s := msg.String()
if len(s) != 1 {
return m, nil
}
k := s
key := msg.Key()
if key.Mod&tea.ModShift != 0 {
lower := strings.ToLower(s)
if lower != s {
if m.state.HasSecondary(lower) {
if result := m.state.ParseKeyBuffer(lower); result != nil {
m.stopPlayer()
m.state.ApplyBinding(&tools.BindingResult{Species: result.Species})
if err := m.state.Save(); err != nil {
m.err = err.Error()
}
m.awaitingSecondaryFor = lower
return m, nil
}
}
k = lower
}
}
if result := m.state.ParseKeyBuffer(k); result != nil {
m.stopPlayer()
m.state.ApplyBinding(result)
if err := m.state.Save(); err != nil {
m.err = err.Error()
}
return m.advanceOrQuit()
}
return m, nil
}
func (m Model) stopPlayer() {
if m.state.Player != nil {
m.state.Player.Stop()
}
}
func (m Model) advanceOrQuit() (tea.Model, tea.Cmd) {
if !m.state.NextSegment() {
m.quitting = true
return m, tea.Quit
}
return m, m.segmentChangeCmd()
}
func (m Model) handleCommentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
key := msg.Key()
if key.Code == tea.KeyEnter {
m.state.SetComment(m.commentText)
if err := m.state.Save(); err != nil {
m.err = err.Error()
}
m.commentMode = false
return m, nil
}
if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
m.commentMode = false
return m, nil
}
if handled := m.handleCommentKeyCode(key); handled {
return m, nil
}
if handled := m.handleCommentCtrl(msg.String()); handled {
return m, nil
}
s := msg.String()
if len(s) == 1 && s[0] >= 33 && s[0] <= 126 {
if len(m.commentText) < 140 {
m.commentText = m.commentText[:m.commentCursor] + s + m.commentText[m.commentCursor:]
m.commentCursor++
}
}
return m, nil
}
func (m *Model) handleCommentKeyCode(key tea.Key) bool {
switch key.Code {
case tea.KeyLeft:
if m.commentCursor > 0 {
m.commentCursor--
}
return true
case tea.KeyRight:
if m.commentCursor < len(m.commentText) {
m.commentCursor++
}
return true
case tea.KeySpace:
if len(m.commentText) < 140 {
m.commentText = m.commentText[:m.commentCursor] + " " + m.commentText[m.commentCursor:]
m.commentCursor++
}
return true
case tea.KeyBackspace:
if m.commentCursor > 0 {
m.commentText = m.commentText[:m.commentCursor-1] + m.commentText[m.commentCursor:]
m.commentCursor--
}
return true
case tea.KeyDelete:
if m.commentCursor < len(m.commentText) {
m.commentText = m.commentText[:m.commentCursor] + m.commentText[m.commentCursor+1:]
}
return true
}
return false
}
func (m *Model) handleCommentCtrl(s string) bool {
switch s {
case "ctrl+u":
m.commentText = ""
m.commentCursor = 0
return true
case "ctrl+a":
m.commentCursor = 0
return true
case "ctrl+e":
m.commentCursor = len(m.commentText)
return true
}
return false
}
func (m Model) handleClipKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
key := msg.Key()
if key.Code == tea.KeyEnter {
if m.clipInput == "" {
m.clipMode = false
return m, nil
}
err := saveClip(m.state, m.clipInput)
if err != nil {
m.err = err.Error()
} else {
m.err = "Clip saved: " + m.clipInput
}
m.clipMode = false
return m, nil
}
if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
m.clipMode = false
return m, nil
}
if key.Code == tea.KeyBackspace {
if len(m.clipInput) > 0 {
m.clipInput = m.clipInput[:len(m.clipInput)-1]
}
return m, nil
}
s := msg.String()
if len(s) == 1 && s[0] >= 32 && s[0] <= 126 { if len(m.clipInput) < 64 {
m.clipInput += s
}
return m, nil
}
return m, nil
}
func saveClip(state *tools.ClassifyState, prefix string) error {
df := state.CurrentFile()
seg := state.CurrentSegment()
if df == nil || seg == nil {
return fmt.Errorf("no segment selected")
}
pngPath, wavOutPath, err := buildClipPaths(df, seg, prefix)
if err != nil {
return err
}
wavPath := strings.TrimSuffix(df.FilePath, ".data")
samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
if err != nil {
return fmt.Errorf("failed to read WAV: %w", err)
}
segSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)
if len(segSamples) == 0 {
return fmt.Errorf("no samples in segment")
}
outputSampleRate := sampleRate
if sampleRate > utils.DefaultMaxSampleRate {
segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
outputSampleRate = utils.DefaultMaxSampleRate
}
resized, err := generateClipSpectrogram(segSamples, outputSampleRate)
if err != nil {
return err
}
if err := writeClipPNG(resized, pngPath); err != nil {
return err
}
if err := utils.WriteWAVFile(wavOutPath, segSamples, outputSampleRate); err != nil {
return fmt.Errorf("failed to write WAV: %w", err)
}
return nil
}
func writeClipPNG(img image.Image, path string) error {
pngFile, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create PNG: %w", err)
}
if err := utils.WritePNG(img, pngFile); err != nil {
_ = pngFile.Close()
return fmt.Errorf("failed to write PNG: %w", err)
}
if err := pngFile.Close(); err != nil {
return fmt.Errorf("failed to close PNG: %w", err)
}
return nil
}
func buildClipPaths(df *utils.DataFile, seg *utils.Segment, prefix string) (pngPath, wavOutPath string, err error) {
wavPath := strings.TrimSuffix(df.FilePath, ".data")
basename := wavPath[strings.LastIndex(wavPath, "/")+1:]
basename = strings.TrimSuffix(basename, ".wav")
startInt := int(seg.StartTime)
endInt := int(seg.EndTime)
if seg.EndTime > float64(endInt) {
endInt++
}
cwd, err := os.Getwd()
if err != nil {
return "", "", fmt.Errorf("failed to get working directory: %w", err)
}
baseName := fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)
pngPath = filepath.Join(cwd, baseName+".png")
wavOutPath = filepath.Join(cwd, baseName+".wav")
if _, err := os.Stat(pngPath); err == nil {
return "", "", fmt.Errorf("file already exists: %s", pngPath)
}
if _, err := os.Stat(wavOutPath); err == nil {
return "", "", fmt.Errorf("file already exists: %s", wavOutPath)
}
return pngPath, wavOutPath, nil
}
func generateClipSpectrogram(segSamples []float64, sampleRate int) (image.Image, error) {
config := utils.DefaultSpectrogramConfig(sampleRate)
spectrogram := utils.GenerateSpectrogram(segSamples, config)
if spectrogram == nil {
return nil, fmt.Errorf("failed to generate spectrogram")
}
colorData := utils.ApplyL4Colormap(spectrogram)
img := utils.CreateRGBImage(colorData)
if img == nil {
return nil, fmt.Errorf("failed to create image")
}
return utils.ResizeImage(img, 224, 224), nil
}
func playCurrentSegmentAtSpeed(state *tools.ClassifyState, speed float64) string {
df := state.CurrentFile()
seg := state.CurrentSegment()
if df == nil || seg == nil {
return ""
}
wavPath := strings.TrimSuffix(df.FilePath, ".data")
samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
if err != nil {
return fmt.Sprintf("audio: %v", err)
}
if state.Player == nil {
player, err := utils.NewAudioPlayer(sampleRate)
if err != nil {
return fmt.Sprintf("audio init: %v", err)
}
state.Player = player
}
segSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)
if len(segSamples) > 0 {
state.PlaybackSpeed = speed
state.Player.PlayAtSpeed(segSamples, sampleRate, speed)
}
return ""
}
func playbackTick() tea.Cmd {
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
return playbackTickMsg{}
})
}
func (m Model) View() tea.View {
if m.quitting {
var b strings.Builder
_ = utils.ClearImages(&b, m.protocol())
b.WriteString("\nDone!\n")
return tea.NewView(b.String())
}
var b strings.Builder
df := m.state.CurrentFile()
seg := m.state.CurrentSegment()
total := m.state.TotalSegments()
current := m.state.CurrentSegmentNumber()
if df == nil || seg == nil {
return tea.NewView("\nNo segments to review.\n")
}
const wrapWidth = 80
b.WriteString(helpStyle.Render(wrapText(m.bindingsHelp, wrapWidth)))
b.WriteString("\n")
b.WriteString(helpDarkStyle.Render(wrapText("[esc]quit [,]prev [.]next [0]confirm [space]comment [ctrl+s]clip [ctrl+d]bookmark [ctrl+,]prev-bk [ctrl+.]next-bk [enter]play [shift+enter]½speed", wrapWidth)))
b.WriteString("\n\n")
progress := float64(current) / float64(total)
barWidth := 30
filled := int(progress * float64(barWidth))
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
wavFile := strings.TrimSuffix(df.FilePath, ".data")
wavFile = wavFile[strings.LastIndex(wavFile, "/")+1:]
b.WriteString(titleStyle.Render(fmt.Sprintf(" %s [%s] %d/%d Segments ", wavFile, bar, current, total)))
b.WriteString("\n\n")
segInfo := fmt.Sprintf("Segment: %.1fs - %.1fs (%.1fs)", seg.StartTime, seg.EndTime, seg.EndTime-seg.StartTime)
if m.state.HasBookmark() {
segInfo += " [BOOKMARKED]"
}
if m.awaitingSecondaryFor != "" {
segInfo += " Waiting..."
}
if m.state.Player != nil && m.state.Player.IsPlaying() {
if m.state.PlaybackSpeed == 0.5 {
segInfo += " ▶ Playing 0.5x..."
} else {
segInfo += " ▶ Playing..."
}
}
b.WriteString(segInfo)
b.WriteString("\n\n")
filterLabels := seg.GetFilterLabels(m.state.Config.Filter)
if len(filterLabels) > 0 {
b.WriteString(labelStyle.Render("Labels:"))
b.WriteString("\n")
for _, l := range filterLabels {
fmt.Fprintf(&b, " • %s\n", tools.FormatLabels([]*utils.Label{l}, m.state.Config.Filter))
}
}
b.WriteString("\n")
if m.clipMode {
m.renderClipDialog(&b)
return tea.NewView(b.String())
}
if m.commentMode {
m.renderCommentDialog(&b)
return tea.NewView(b.String())
}
if m.err != "" {
b.WriteString(errorStyle.Render(m.err))
}
v := tea.NewView(b.String())
v.AltScreen = true
return v
}
func (m Model) renderCommentDialog(b *strings.Builder) {
before := m.commentText[:m.commentCursor]
after := m.commentText[m.commentCursor:]
inputLine := before + "█" + after
charCount := fmt.Sprintf("%d/140", len(m.commentText))
helpLine := "[enter]save [esc]cancel [←→]move [ctrl+u]clear [ctrl+a]start [ctrl+e]end"
content := fmt.Sprintf("Comment:\n%s\n%s\n%s", inputLine, charCount, helpLine)
b.WriteString(commentBoxStyle.Render(content))
}
func (m Model) renderClipDialog(b *strings.Builder) {
inputLine := m.clipInput + "█"
helpLine := "[enter]save [esc]cancel"
content := fmt.Sprintf("Clip prefix:\n%s\n%s", inputLine, helpLine)
b.WriteString(commentBoxStyle.Render(content))
}
func generateSpectrogramImage(state *tools.ClassifyState, dataPath string, seg *utils.Segment) image.Image {
imgSize := state.Config.ImageSize
if imgSize == 0 {
imgSize = utils.SpectrogramDisplaySize
}
img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize)
if err != nil {
return nil
}
return img
}
func inlineImageCmd(state *tools.ClassifyState, protocol utils.ImageProtocol, gen uint64, currentGen *uint64) tea.Cmd {
return func() tea.Msg {
df := state.CurrentFile()
seg := state.CurrentSegment()
if df == nil || seg == nil {
return nil
}
img := generateSpectrogramImage(state, df.FilePath, seg)
if img == nil {
return nil
}
if *currentGen != gen {
return nil
}
_ = utils.ClearImages(os.Stdout, protocol)
_, _ = fmt.Fprint(os.Stdout, "\r\n\r\n")
_ = utils.WriteImage(img, os.Stdout, protocol)
return nil
}
}