Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions cmd/fft.go
Original file line number Diff line number Diff line change
@@ -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
}
89 changes: 89 additions & 0 deletions cmd/record.go
Original file line number Diff line number Diff line change
@@ -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)
}
68 changes: 68 additions & 0 deletions cmd/record_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
133 changes: 133 additions & 0 deletions cmd/recorder.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading