diff --git a/cmd/fft.go b/cmd/fft.go new file mode 100644 index 0000000..4513e39 --- /dev/null +++ b/cmd/fft.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "math" + "math/cmplx" +) + +// fft performs an in-place radix-2 Cooley-Tukey FFT. +// Input length must be a power of 2. +func fft(x []complex128) []complex128 { + n := len(x) + if n <= 1 { + return x + } + + even := make([]complex128, n/2) + odd := make([]complex128, n/2) + for i := 0; i < n/2; i++ { + even[i] = x[2*i] + odd[i] = x[2*i+1] + } + even = fft(even) + odd = fft(odd) + + result := make([]complex128, n) + for k := 0; k < n/2; k++ { + w := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * odd[k] + result[k] = even[k] + w + result[k+n/2] = even[k] - w + } + return result +} + +// magnitudeSpectrum returns magnitude of each frequency bin from real samples. +// Pads to next power of 2 before FFT. +func magnitudeSpectrum(samples []float64) []float64 { + n := nextPow2(len(samples)) + input := make([]complex128, n) + for i, s := range samples { + input[i] = complex(s, 0) + } + + spectrum := fft(input) + mags := make([]float64, n/2) + for i := range mags { + mags[i] = cmplx.Abs(spectrum[i]) + } + return mags +} + +// nextPow2 returns the smallest power of 2 >= n. +func nextPow2(n int) int { + p := 1 + for p < n { + p *= 2 + } + return p +} diff --git a/cmd/record.go b/cmd/record.go new file mode 100644 index 0000000..ee9bb6b --- /dev/null +++ b/cmd/record.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "os" + "regexp" + "strconv" + + "github.com/spf13/cobra" + + "github.com/j178/leetgo/lang" + "github.com/j178/leetgo/leetcode" +) + +var recordForce bool + +var recordCmd = &cobra.Command{ + Use: "record qid", + Short: "Record a voice note for a problem attempt", + Args: cobra.ExactArgs(1), + RunE: runRecord, +} + +func init() { + recordCmd.Flags().BoolVarP(&recordForce, "force", "f", false, "restart numbering from attempt-1") +} + +// attemptRe matches filenames like "attempt-1.mp3", "attempt-12.mp3", etc. +var attemptRe = regexp.MustCompile(`^attempt-(\d+)\.mp3$`) + +// nextAttemptNumber scans dir for existing attempt-N.mp3 files and returns +// the next number. If force is true, returns 1. +func nextAttemptNumber(dir string, force bool) int { + if force { + return 1 + } + entries, err := os.ReadDir(dir) + if err != nil { + return 1 + } + maxN := 0 + for _, e := range entries { + m := attemptRe.FindStringSubmatch(e.Name()) + if m != nil { + n, _ := strconv.Atoi(m[1]) + if n > maxN { + maxN = n + } + } + } + return maxN + 1 +} + +func runRecord(cmd *cobra.Command, args []string) error { + // Check ffmpeg first — fail fast with install instructions. + if err := checkFFmpeg(); err != nil { + return err + } + + // Parse the question ID (supports "219", "contains-duplicate-ii", "today", etc.) + c := leetcode.NewClient(leetcode.ReadCredentials()) + qs, err := leetcode.ParseQID(args[0], c) + if err != nil { + return err + } + if len(qs) > 1 { + return fmt.Errorf("multiple questions found") + } + + // Resolve the problem output directory. + result, err := lang.GeneratePathsOnly(qs[0]) + if err != nil { + return err + } + outDir := result.TargetDir() + + // Ensure the output directory exists. + if _, err := os.Stat(outDir); os.IsNotExist(err) { + return fmt.Errorf("problem directory %q does not exist — run `leetgo pick` first", outDir) + } + + // Determine the next attempt number. + attempt := nextAttemptNumber(outDir, recordForce) + outputPath := fmt.Sprintf("%s/attempt-%d.mp3", outDir, attempt) + filename := fmt.Sprintf("attempt-%d.mp3", attempt) + + // Launch the recording TUI. + return runRecorderTUI(outDir, filename, outputPath) +} diff --git a/cmd/record_test.go b/cmd/record_test.go new file mode 100644 index 0000000..7e27085 --- /dev/null +++ b/cmd/record_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNextAttemptNumber(t *testing.T) { + tests := []struct { + name string + existing []string // filenames to create in the temp dir + force bool + want int + }{ + { + name: "empty directory", + existing: nil, + force: false, + want: 1, + }, + { + name: "one existing attempt", + existing: []string{"attempt-1.mp3"}, + force: false, + want: 2, + }, + { + name: "three existing attempts", + existing: []string{"attempt-1.mp3", "attempt-2.mp3", "attempt-3.mp3"}, + force: false, + want: 4, + }, + { + name: "gap in numbering", + existing: []string{"attempt-1.mp3", "attempt-3.mp3"}, + force: false, + want: 4, + }, + { + name: "force flag resets to 1", + existing: []string{"attempt-1.mp3", "attempt-2.mp3"}, + force: true, + want: 1, + }, + { + name: "non-matching files ignored", + existing: []string{"question.md", "solution.cpp", "notes.mp3"}, + force: false, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for _, f := range tt.existing { + if err := os.WriteFile(filepath.Join(dir, f), []byte{}, 0o644); err != nil { + t.Fatal(err) + } + } + got := nextAttemptNumber(dir, tt.force) + if got != tt.want { + t.Errorf("nextAttemptNumber() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/cmd/recorder.go b/cmd/recorder.go new file mode 100644 index 0000000..b647464 --- /dev/null +++ b/cmd/recorder.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "runtime" + "syscall" +) + +// platformConfig holds platform-specific ffmpeg input flags. +type platformConfig struct { + inputFormat string + inputDevice string + canPause bool +} + +// detectPlatform returns the ffmpeg input config for the current OS. +func detectPlatform() platformConfig { + return detectPlatformFor(runtime.GOOS) +} + +// detectPlatformFor returns the ffmpeg input config for a given GOOS. +func detectPlatformFor(goos string) platformConfig { + switch goos { + case "darwin": + return platformConfig{inputFormat: "avfoundation", inputDevice: ":0", canPause: true} + case "windows": + return platformConfig{inputFormat: "dshow", inputDevice: "", canPause: false} + default: // linux, freebsd, etc. + return platformConfig{inputFormat: "pulse", inputDevice: "default", canPause: true} + } +} + +// buildArgs constructs the ffmpeg argument list for recording to outputPath. +func (pc platformConfig) buildArgs(outputPath string) []string { + args := []string{"-f", pc.inputFormat} + if pc.inputDevice != "" { + args = append(args, "-i", pc.inputDevice) + } else { + // Windows dshow: let ffmpeg auto-detect the default audio device + args = append(args, "-i", "audio") + } + args = append(args, + "-c:a", "libmp3lame", + "-q:a", "2", + "-y", + outputPath, + ) + return args +} + +// buildVizArgs constructs ffmpeg args that write MP3 to file AND pipe raw PCM to stdout. +func (pc platformConfig) buildVizArgs(outputPath string) []string { + args := []string{"-f", pc.inputFormat} + if pc.inputDevice != "" { + args = append(args, "-i", pc.inputDevice) + } else { + args = append(args, "-i", "audio") + } + // Split audio: stream [a] → MP3 file, stream [b] → raw PCM to stdout + args = append(args, + "-filter_complex", "[0:a]asplit=2[a][b]", + "-map", "[a]", "-c:a", "libmp3lame", "-q:a", "2", "-y", outputPath, + "-map", "[b]", "-f", "s16le", "-ac", "1", "-ar", "44100", "pipe:1", + ) + return args +} + +// checkFFmpeg verifies that ffmpeg is available on PATH. +func checkFFmpeg() error { + _, err := exec.LookPath("ffmpeg") + if err != nil { + return fmt.Errorf("ffmpeg is not installed or not on PATH.\n\nInstall it with:\n macOS: brew install ffmpeg\n Linux: sudo apt install ffmpeg\n Windows: winget install ffmpeg\n See: https://ffmpeg.org/download.html") + } + return nil +} + +// startRecording spawns ffmpeg that writes MP3 to file and pipes raw PCM to stdout. +func startRecording(outputPath string) (*exec.Cmd, io.Reader, error) { + pc := detectPlatform() + args := pc.buildVizArgs(outputPath) + + cmd := exec.Command("ffmpeg", args...) + pipe, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + return nil, nil, err + } + return cmd, pipe, nil +} + +// stopRecording sends SIGINT to ffmpeg so it flushes and exits cleanly. +func stopRecording(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + if runtime.GOOS == "windows" { + return cmd.Process.Kill() + } + _ = cmd.Process.Signal(syscall.SIGINT) + return cmd.Wait() +} + +// pauseRecording suspends the ffmpeg process (Unix only). +func pauseRecording(cmd *exec.Cmd) error { + if runtime.GOOS == "windows" { + return fmt.Errorf("pause is not supported on Windows") + } + return cmd.Process.Signal(syscall.SIGSTOP) +} + +// resumeRecording resumes a paused ffmpeg process (Unix only). +func resumeRecording(cmd *exec.Cmd) error { + if runtime.GOOS == "windows" { + return fmt.Errorf("resume is not supported on Windows") + } + return cmd.Process.Signal(syscall.SIGCONT) +} + +// cancelRecording kills ffmpeg and removes the partial output file. +func cancelRecording(cmd *exec.Cmd, outputPath string) { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + _ = os.Remove(outputPath) +} diff --git a/cmd/recorder_test.go b/cmd/recorder_test.go new file mode 100644 index 0000000..bddcbd6 --- /dev/null +++ b/cmd/recorder_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "runtime" + "testing" +) + +func TestDetectPlatform(t *testing.T) { + pc := detectPlatform() + + // Every platform must set these + if pc.inputFormat == "" { + t.Error("inputFormat must not be empty") + } + if pc.inputDevice == "" { + t.Error("inputDevice must not be empty") + } + if pc.canPause && runtime.GOOS == "windows" { + t.Error("windows should not report canPause=true") + } +} + +func TestDetectPlatformKnownOS(t *testing.T) { + tests := []struct { + goos string + wantFormat string + wantDevice string + wantCanPause bool + }{ + {"darwin", "avfoundation", ":0", true}, + {"linux", "pulse", "default", true}, + {"windows", "dshow", "", false}, + } + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + pc := detectPlatformFor(tt.goos) + if pc.inputFormat != tt.wantFormat { + t.Errorf("inputFormat = %q, want %q", pc.inputFormat, tt.wantFormat) + } + if tt.goos != "windows" && pc.inputDevice != tt.wantDevice { + t.Errorf("inputDevice = %q, want %q", pc.inputDevice, tt.wantDevice) + } + if pc.canPause != tt.wantCanPause { + t.Errorf("canPause = %v, want %v", pc.canPause, tt.wantCanPause) + } + }) + } +} + +func TestBuildFFmpegArgs(t *testing.T) { + pc := platformConfig{inputFormat: "avfoundation", inputDevice: ":0"} + args := pc.buildArgs("/tmp/attempt-1.mp3") + + want := []string{ + "-f", "avfoundation", + "-i", ":0", + "-c:a", "libmp3lame", + "-q:a", "2", + "-y", + "/tmp/attempt-1.mp3", + } + if len(args) != len(want) { + t.Fatalf("args length = %d, want %d\ngot: %v\nwant: %v", len(args), len(want), args, want) + } + for i := range args { + if args[i] != want[i] { + t.Errorf("args[%d] = %q, want %q", i, args[i], want[i]) + } + } +} + +func TestBuildFFmpegArgsWindows(t *testing.T) { + pc := platformConfig{inputFormat: "dshow", inputDevice: "", canPause: false} + args := pc.buildArgs("/tmp/attempt-1.mp3") + + found := false + for _, a := range args { + if a == "-f" { + found = true + } + } + if !found { + t.Error("args should contain -f flag") + } +} + +func TestBuildVizArgs(t *testing.T) { + pc := platformConfig{inputFormat: "pulse", inputDevice: "default"} + args := pc.buildVizArgs("/tmp/attempt-1.mp3") + + // Must contain asplit filter and pipe:1 output + hasAsplit := false + hasPipe := false + for _, a := range args { + if a == "[0:a]asplit=2[a][b]" { + hasAsplit = true + } + if a == "pipe:1" { + hasPipe = true + } + } + if !hasAsplit { + t.Error("viz args should contain asplit filter") + } + if !hasPipe { + t.Error("viz args should contain pipe:1 output") + } +} diff --git a/cmd/recorder_tui.go b/cmd/recorder_tui.go new file mode 100644 index 0000000..dc3178d --- /dev/null +++ b/cmd/recorder_tui.go @@ -0,0 +1,297 @@ +package cmd + +import ( + "encoding/binary" + "fmt" + "io" + "math" + "os/exec" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + sampleRate = 44100 + pcmChunkSize = 2048 // samples per read (matches FFT input size) +) + +type recorderStatus int + +const ( + statusRecording recorderStatus = iota + statusPaused + statusStopping + statusDone + statusCancelled + statusError +) + +// Unicode block characters for EQ bars, from lowest to highest. +var blockChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +// recorderModel is the bubbletea model for the audio recording TUI. +type recorderModel struct { + outDir string + filename string + outputPath string + cmd *exec.Cmd + pipe io.Reader + status recorderStatus + startTime time.Time + elapsed time.Duration + err error + canPause bool + bands []float64 // frequency band levels, 0.0–1.0 + width int // terminal width in columns +} + +// tickMsg is sent every 100ms to update the timer display. +type tickMsg time.Time + +// errMsg wraps an error from ffmpeg. +type errMsg error + +// bandsMsg carries frequency band levels from the PCM reader. +type bandsMsg []float64 + +// numBandsForWidth returns how many EQ bands fit the given terminal width. +// Each band takes 2 chars (block + space), minus 1 for the last band. +func numBandsForWidth(w int) int { + if w <= 0 { + return 16 + } + n := (w + 1) / 2 + if n < 4 { + n = 4 + } + return n +} + +// runRecorderTUI launches the bubbletea recording interface. +func runRecorderTUI(outDir, filename, outputPath string) error { + m := &recorderModel{ + outDir: outDir, + filename: filename, + outputPath: outputPath, + canPause: detectPlatform().canPause, + width: 80, // default before first WindowSizeMsg + } + m.bands = make([]float64, numBandsForWidth(m.width)) + p := tea.NewProgram(m) + _, err := p.Run() + return err +} + +func (m *recorderModel) Init() tea.Cmd { + // Start the ffmpeg subprocess (writes MP3 + pipes raw PCM). + cmd, pipe, err := startRecording(m.outputPath) + if err != nil { + return func() tea.Msg { return errMsg(err) } + } + m.cmd = cmd + m.pipe = pipe + m.startTime = time.Now() + m.status = statusRecording + return tea.Batch(tickCmd(), readPCMCmd(m.pipe, numBandsForWidth(m.width))) +} + +func (m *recorderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + n := numBandsForWidth(m.width) + if len(m.bands) != n { + m.bands = make([]float64, n) + } + return m, nil + + case tickMsg: + if m.status == statusRecording { + m.elapsed = time.Since(m.startTime) + } + if m.status == statusRecording || m.status == statusPaused { + return m, tickCmd() + } + return m, nil + + case bandsMsg: + m.bands = msg + // Keep reading while recording or paused (pipe stays open). + if m.status == statusRecording || m.status == statusPaused { + return m, readPCMCmd(m.pipe, numBandsForWidth(m.width)) + } + return m, nil + + case errMsg: + m.status = statusError + m.err = msg + return m, tea.Quit + + case tea.KeyMsg: + switch msg.String() { + case "q", "enter": + // Stop recording and save. + m.status = statusStopping + go func() { _ = stopRecording(m.cmd) }() + m.status = statusDone + return m, tea.Quit + + case " ": + if !m.canPause { + return m, nil + } + if m.status == statusRecording { + _ = pauseRecording(m.cmd) + m.status = statusPaused + } else if m.status == statusPaused { + _ = resumeRecording(m.cmd) + m.status = statusRecording + m.startTime = time.Now().Add(-m.elapsed) + } + + case "ctrl+c": + m.status = statusCancelled + cancelRecording(m.cmd, m.outputPath) + return m, tea.Quit + } + } + + return m, nil +} + +func (m *recorderModel) View() string { + switch m.status { + case statusDone: + return lipgloss.NewStyle().Foreground(lipgloss.Color("#5fff5f")).Render( + fmt.Sprintf("✓ Saved %s (%s)", m.filename, formatDuration(m.elapsed))) + case statusCancelled: + return lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaf00")).Render("Cancelled — partial file discarded.") + case statusError: + return lipgloss.NewStyle().Foreground(lipgloss.Color("#ff5f5f")).Render( + fmt.Sprintf("Error: %v", m.err)) + } + + // Status indicator + var indicator string + var controls string + switch m.status { + case statusRecording: + indicator = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff5f5f")).Bold(true).Render("⏺") + controls = "[space] pause [q] stop [ctrl+c] cancel" + case statusPaused: + indicator = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaf00")).Bold(true).Render("⏸") + controls = "[space] resume [q] stop [ctrl+c] cancel" + } + + // EQ bars — black & white, full terminal width + eqLine := renderEQ(m.bands) + + return fmt.Sprintf("%s %s Recording %s\n%s\n%s", + indicator, formatDuration(m.elapsed), m.filename, + eqLine, + lipgloss.NewStyle().Faint(true).Render(controls)) +} + +// renderEQ renders frequency bands as plain block characters, full width. +func renderEQ(bands []float64) string { + result := make([]byte, 0, len(bands)*2) + for i, level := range bands { + idx := int(level * float64(len(blockChars)-1)) + if idx < 0 { + idx = 0 + } + if idx >= len(blockChars) { + idx = len(blockChars) - 1 + } + result = append(result, string(blockChars[idx])...) + if i < len(bands)-1 { + result = append(result, ' ') + } + } + return string(result) +} + +// readPCMCmd reads a chunk of raw PCM from the pipe, runs FFT, +// and returns frequency band levels as a bandsMsg. +func readPCMCmd(pipe io.Reader, numBands int) tea.Cmd { + return func() tea.Msg { + buf := make([]byte, pcmChunkSize*2) // s16le = 2 bytes per sample + _, err := io.ReadFull(pipe, buf) + if err != nil { + return nil // pipe closed, stop reading + } + + // Convert s16le bytes to float64 samples + samples := make([]float64, pcmChunkSize) + for i := range samples { + sample := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2])) + samples[i] = float64(sample) / 32768.0 + } + + return bandsMsg(analyzeBands(samples, numBands)) + } +} + +// analyzeBands runs FFT on samples and groups frequency bins into n bands. +func analyzeBands(samples []float64, n int) []float64 { + mags := magnitudeSpectrum(samples) + + bands := make([]float64, n) + binHz := float64(sampleRate) / float64(len(samples)) + + // Logarithmic band grouping over voice range (80 Hz – 8000 Hz) + minFreq := 80.0 + maxFreq := 8000.0 + + for i := 0; i < n; i++ { + lo := minFreq * math.Pow(maxFreq/minFreq, float64(i)/float64(n)) + hi := minFreq * math.Pow(maxFreq/minFreq, float64(i+1)/float64(n)) + loBin := int(lo / binHz) + hiBin := int(hi / binHz) + if loBin < 0 { + loBin = 0 + } + if hiBin >= len(mags) { + hiBin = len(mags) - 1 + } + + var sum float64 + count := 0 + for b := loBin; b <= hiBin; b++ { + sum += mags[b] + count++ + } + if count > 0 { + bands[i] = sum / float64(count) + } + } + + // Normalize relative to current frame max + maxBand := 0.001 + for _, b := range bands { + if b > maxBand { + maxBand = b + } + } + for i := range bands { + bands[i] = math.Min(bands[i]/maxBand, 1.0) + } + + return bands +} + +// tickCmd returns a command that sends a tickMsg after 100ms. +func tickCmd() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// formatDuration formats a duration as MM:SS. +func formatDuration(d time.Duration) string { + m := int(d.Minutes()) + s := int(d.Seconds()) % 60 + return fmt.Sprintf("%02d:%02d", m, s) +} diff --git a/cmd/root.go b/cmd/root.go index 5b96df5..ef77dd7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -120,38 +120,39 @@ func initCommands() { _ = viper.BindPFlag("yes", rootCmd.PersistentFlags().Lookup("yes")) _ = rootCmd.RegisterFlagCompletionFunc( - "lang", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - langs := make([]string, 0, len(lang.SupportedLangs)) - for _, l := range lang.SupportedLangs { - langs = append(langs, l.Slug()) - } - return langs, cobra.ShellCompDirectiveNoFileComp - }, - ) + "lang", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + langs := make([]string, 0, len(lang.SupportedLangs)) + for _, l := range lang.SupportedLangs { + langs = append(langs, l.Slug()) + } + return langs, cobra.ShellCompDirectiveNoFileComp + }, + ) _ = rootCmd.RegisterFlagCompletionFunc( - "site", - func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"cn", "us"}, cobra.ShellCompDirectiveNoFileComp - }, - ) + "site", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"cn", "us"}, cobra.ShellCompDirectiveNoFileComp + }, + ) commands := []*cobra.Command{ - initCmd, - pickCmd, - infoCmd, - testCmd, - submitCmd, - fixCmd, - editCmd, - extractCmd, - contestCmd, - cacheCmd, - debugCmd, - gitCmd, - inspectCmd, - whoamiCmd, - openCmd, - } + initCmd, + pickCmd, + infoCmd, + testCmd, + submitCmd, + fixCmd, + editCmd, + recordCmd, + extractCmd, + contestCmd, + cacheCmd, + debugCmd, + gitCmd, + inspectCmd, + whoamiCmd, + openCmd, + } for _, cmd := range commands { cmd.Flags().SortFlags = false cmd.PersistentPreRunE = preRun