package tui
import (
"fmt"
"image"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"skraak/tools"
"skraak/utils"
)
type playbackFinishedMsg 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"))
commentBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(0, 1)
commentInputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("15"))
commentHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
)
type Model struct {
state *tools.ClassifyState
err string
quitting bool
bindingsHelp string
commentMode bool commentText string }
func New(state *tools.ClassifyState) Model {
var bindings []string
for _, b := range state.Config.Bindings {
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, " ")
return Model{
state: state,
bindingsHelp: bindingsHelp,
}
}
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 {
if m.protocol() != utils.ProtocolKitty {
return inlineImageCmd(m.state, m.protocol())
}
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKey(msg)
case playbackFinishedMsg:
return m, nil
}
return m, nil
}
func (m Model) segmentChangeCmd() tea.Cmd {
if m.protocol() != utils.ProtocolKitty {
return tea.Sequence(tea.ClearScreen, inlineImageCmd(m.state, m.protocol()))
}
return tea.ClearScreen
}
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.commentMode {
return m.handleCommentKey(msg)
}
m.err = ""
key := msg.Key()
if key.Code == tea.KeyEnter {
speed := 1.0
if key.Mod&tea.ModShift != 0 {
speed = 0.5
}
if errMsg := playCurrentSegmentAtSpeed(m.state, speed); errMsg != "" {
m.err = errMsg
}
return m, waitForPlaybackFinished(m.state)
}
if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
if m.state.Player != nil {
m.state.Player.Stop()
}
m.quitting = true
return m, tea.Quit
}
if key.Code == tea.KeySpace {
m.commentText = m.state.GetCurrentComment()
m.commentMode = true
return m, nil
}
switch msg.String() {
case "ctrl+c":
if m.state.Player != nil {
m.state.Player.Stop()
}
m.quitting = true
return m, tea.Quit
case ",":
if m.state.Player != nil {
m.state.Player.Stop()
}
m.state.PrevSegment()
return m, m.segmentChangeCmd()
case ".":
if m.state.Player != nil {
m.state.Player.Stop()
}
if moveToNext(m.state) {
m.quitting = true
return m, tea.Quit
}
return m, m.segmentChangeCmd()
default:
if len(msg.String()) == 1 {
k := msg.String()
if result := m.state.ParseKeyBuffer(k); result != nil {
if m.state.Player != nil {
m.state.Player.Stop()
}
m.state.ApplyBinding(result)
if err := m.state.Save(); err != nil {
m.err = err.Error()
}
if moveToNext(m.state) {
m.quitting = true
return m, tea.Quit
}
return m, m.segmentChangeCmd()
}
}
return m, nil
}
}
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
}
switch msg.String() {
case "backspace":
if len(m.commentText) > 0 {
m.commentText = m.commentText[:len(m.commentText)-1]
}
return m, nil
case "ctrl+u":
m.commentText = ""
return m, nil
}
s := msg.String()
if len(s) == 1 && s[0] >= 32 && s[0] <= 126 {
if len(m.commentText) < 140 {
m.commentText += s
}
return m, nil
}
return m, 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 waitForPlaybackFinished(state *tools.ClassifyState) tea.Cmd {
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
if state.Player == nil || !state.Player.IsPlaying() {
return playbackFinishedMsg{}
}
return waitForPlaybackFinished(state)()
})
}
func moveToNext(state *tools.ClassifyState) bool {
return !state.NextSegment()
}
func (m Model) View() tea.View {
if m.quitting {
var b strings.Builder
if m.protocol() == utils.ProtocolKitty {
utils.ClearKittyImages(&b)
}
b.WriteString("\nDone!\n")
return tea.NewView(b.String())
}
var b strings.Builder
if m.protocol() == utils.ProtocolKitty {
utils.ClearKittyImages(&b)
}
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")
}
b.WriteString(helpStyle.Render(m.bindingsHelp))
b.WriteString("\n")
b.WriteString(helpStyle.Render("[esc]quit [,]prev [.]next [space]comment [enter]play [shift+enter]½speed"))
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 ", 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.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 {
b.WriteString(fmt.Sprintf(" • %s\n", tools.FormatLabels([]*utils.Label{l}, m.state.Config.Filter)))
}
}
b.WriteString("\n")
if m.commentMode {
m.renderCommentDialog(&b)
return tea.NewView(b.String())
}
if m.protocol() == utils.ProtocolKitty {
m.renderSpectrogram(&b, df.FilePath, seg)
}
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) {
inputLine := m.commentText + "█" charCount := fmt.Sprintf("%d/140", len(m.commentText))
helpLine := "[enter]save [esc]cancel [ctrl+u]clear"
content := fmt.Sprintf("Comment:\n%s\n%s\n%s", inputLine, charCount, helpLine)
b.WriteString(commentBoxStyle.Render(content))
}
func (m Model) renderSpectrogram(b *strings.Builder, dataPath string, seg *utils.Segment) {
img := generateSpectrogramImage(m.state, dataPath, seg)
if img == nil {
b.WriteString("[error generating spectrogram]")
return
}
utils.WriteKittyImage(img, b)
}
func generateSpectrogramImage(state *tools.ClassifyState, dataPath string, seg *utils.Segment) image.Image {
wavPath := strings.TrimSuffix(dataPath, ".data")
samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
if err != nil {
return nil
}
segSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)
if len(segSamples) == 0 {
return nil
}
config := utils.DefaultSpectrogramConfig(sampleRate)
spectrogram := utils.GenerateSpectrogram(segSamples, config)
if spectrogram == nil {
return nil
}
var img image.Image
if state.Config.Color {
colorData := utils.ApplyL4Colormap(spectrogram)
img = utils.CreateRGBImage(colorData)
} else {
img = utils.CreateGrayscaleImage(spectrogram)
}
if img == nil {
return nil
}
imgSize := state.Config.ImageSize
if imgSize == 0 {
imgSize = utils.SpectrogramDisplaySize
}
imgSize = utils.ClampImageSize(imgSize)
return utils.ResizeImage(img, imgSize, imgSize)
}
func inlineImageCmd(state *tools.ClassifyState, protocol utils.ImageProtocol) 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
}
fmt.Fprint(os.Stdout, "\r\n\r\n")
utils.WriteImage(img, os.Stdout, protocol)
return nil
}
}