tucue 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 97054b40943e2128666fdce09762dcc347ed314e8f61cb3af56fb4150379fca5
4
+ data.tar.gz: d713fe563c046f18a36123afe12d1f1ce5fdcfb33cc56bc7d374ce83ffb7417f
5
+ SHA512:
6
+ metadata.gz: 4f810dcd87e5ede57616c2672572da421cb98d242af2b22e0e5355f9bf3f7989159d552e0939d02a8c4ce62d0d95fe185d42727371fa9428ce226f8097801174
7
+ data.tar.gz: 0cf0d7c900293762b8d3a924a12be24679e94e0dc42d26ba382a1666813adfc380c84e857d4f52cf2f8894a6fa70efee68f321ccc0c9c6542866b88f0317df36
data/CLAUDE.md ADDED
@@ -0,0 +1,158 @@
1
+ # tucue
2
+
3
+ ## Overview
4
+
5
+ A Ruby TUI application for playing local audio files, marking specific
6
+ moments, and exporting them.
7
+
8
+ - **gem name**: `tucue` (a blend of TUI + Cue, pronounced "too-cue")
9
+ - **RubyGems**: publishing is planned (check name availability on rubygems.org)
10
+
11
+ ---
12
+
13
+ ## Conventions
14
+
15
+ - **Code comments**: English.
16
+ - **README and other distributed docs**: English.
17
+ - **Commit messages**: English.
18
+ - **UI strings**: English.
19
+
20
+ ---
21
+
22
+ ## Features
23
+
24
+ - [x] Play mp3 / wav files
25
+ - [x] Rewind / fast-forward in 5- and 15-second steps
26
+ - [x] Mark the current position (with an optional label)
27
+ - [x] Export the mark list to a file (CSV / JSON)
28
+
29
+ ---
30
+
31
+ ## Technical approach
32
+
33
+ ### Playback engine
34
+ - Delegate to **mpv** (`brew install mpv` is a prerequisite).
35
+ - Open a Unix socket with `--input-ipc-server` and control it by sending
36
+ JSON commands from Ruby.
37
+
38
+ ```bash
39
+ mpv --input-ipc-server=/tmp/tucue.sock target.mp3
40
+ ```
41
+
42
+ ```ruby
43
+ # Seeking
44
+ socket.puts({ command: ["seek", 15, "relative"] }.to_json)
45
+ socket.puts({ command: ["seek", -5, "relative"] }.to_json)
46
+
47
+ # Get the current position
48
+ socket.puts({ command: ["get_property", "time-pos"] }.to_json)
49
+ ```
50
+
51
+ IPC notes (see `lib/tucue/player.rb`):
52
+ - Pair requests and responses by `request_id`; skip `event` messages that
53
+ arrive in between.
54
+ - Guard socket sends with a `Mutex` so callers can share the socket safely.
55
+ - Treat a dropped connection (mpv reaching EOF or exiting) as a clean
56
+ shutdown rather than an error.
57
+
58
+ ### TUI
59
+ - Built on **curses** (bundled with Ruby).
60
+ - Optionally combine with the **tty-\* family** (`tty-cursor`, `tty-screen`,
61
+ `tty-box`).
62
+ - The UI uses a `getch` timeout to refresh the playback position
63
+ periodically instead of a separate poll thread, because curses is not
64
+ thread-safe. (This differs from the original "sub-thread polling" idea.)
65
+
66
+ ### Export formats
67
+ - CSV (default)
68
+ - JSON (option)
69
+
70
+ ---
71
+
72
+ ## UI sketch
73
+
74
+ ```
75
+ ┌─────────────────────────────────┐
76
+ │ File: interview.mp3 │
77
+ │ 00:01:23 / 00:45:10 ####---- │
78
+ ├─────────────────────────────────┤
79
+ │ [Space] play/pause │
80
+ │ [<-] -5s [->] +5s │
81
+ │ [[] -15s []] +15s │
82
+ │ [m] mark [e] export │
83
+ │ [q] quit │
84
+ ├─────────────────────────────────┤
85
+ │ Marks (2) │
86
+ │ * 00:01:23 - key point │
87
+ │ * 00:03:45 │
88
+ └─────────────────────────────────┘
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Key bindings
94
+
95
+ | Key | Action |
96
+ |---|---|
97
+ | `Space` / `p` | Play / pause |
98
+ | `→` | +5 seconds |
99
+ | `←` | -5 seconds |
100
+ | `]` | +15 seconds |
101
+ | `[` | -15 seconds |
102
+ | `m` | Mark the current position |
103
+ | `e` | Export the marks |
104
+ | `q` | Quit |
105
+
106
+ ---
107
+
108
+ ## Gem layout
109
+
110
+ ```
111
+ tucue/
112
+ ├── CLAUDE.md
113
+ ├── README.md
114
+ ├── LICENSE
115
+ ├── tucue.gemspec
116
+ ├── Gemfile
117
+ ├── bin/
118
+ │ └── tucue # entry point (CLI command)
119
+ └── lib/
120
+ ├── tucue.rb # requires and Tucue::Error
121
+ └── tucue/
122
+ ├── version.rb
123
+ ├── cli.rb # argument parsing / entry point
124
+ ├── player.rb # mpv control
125
+ ├── ui.rb # curses TUI
126
+ └── marker.rb # mark management / export
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Usage
132
+
133
+ ```bash
134
+ bundle exec tucue interview.mp3
135
+ bundle exec tucue --start 01:02:03 interview.mp3
136
+ ```
137
+
138
+ CLI options (parsed in `lib/tucue/cli.rb`):
139
+ - `-s`, `--start TIME` — start playback at `TIME` (`SS`, `MM:SS`, or
140
+ `HH:MM:SS`). Implemented via mpv's `--start=` and `Player#start_at`.
141
+ - `-v`, `--version`; `-h`, `--help`.
142
+
143
+ ---
144
+
145
+ ## Environment / prerequisites
146
+
147
+ - macOS (developer environment)
148
+ - Ruby 3.x or later
149
+ - mpv (`brew install mpv`)
150
+
151
+ ---
152
+
153
+ ## Licensing
154
+
155
+ - tucue's own code is released under the **MIT License**.
156
+ - mpv is GPL/LGPL, but it runs as a **separate process** and is **not
157
+ bundled** with tucue, so its copyleft does not extend to tucue's source.
158
+ Do not redistribute mpv binaries inside the gem.
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in tucue.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "minitest", "~> 5.16"
11
+
12
+ gem "standard", "~> 1.3"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 takkanm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # tucue
2
+
3
+ [![CI](https://github.com/takkanm/tucue/actions/workflows/ci.yml/badge.svg)](https://github.com/takkanm/tucue/actions/workflows/ci.yml)
4
+
5
+ A Ruby TUI application for playing local audio files, marking specific
6
+ moments, and exporting them. Handy for managing cue points in interview
7
+ recordings and similar audio.
8
+
9
+ The name is a blend of **TUI** and **Cue** (pronounced "too-cue").
10
+
11
+ ```
12
+ ┌─────────────────────────────────┐
13
+ │ File: interview.mp3 │
14
+ │ 00:01:23 / 00:45:10 ####---- │
15
+ ├─────────────────────────────────┤
16
+ │ [Space] play/pause │
17
+ │ [<-] -5s [->] +5s │
18
+ │ [[] -15s []] +15s │
19
+ │ [m] mark [e] export │
20
+ │ [q] quit │
21
+ ├─────────────────────────────────┤
22
+ │ Marks (2) │
23
+ │ * 00:01:23 - key point │
24
+ │ * 00:03:45 │
25
+ └─────────────────────────────────┘
26
+ ```
27
+
28
+ ## Features
29
+
30
+ - Play audio files such as mp3 / wav
31
+ - Rewind and fast-forward in 5- and 15-second steps
32
+ - Mark the current position with an optional label
33
+ - Export the mark list as CSV or JSON
34
+
35
+ ## Requirements
36
+
37
+ - macOS (developer environment)
38
+ - Ruby 3.x or later
39
+ - [mpv](https://mpv.io/) (used as the playback engine)
40
+
41
+ ```bash
42
+ brew install mpv
43
+ ```
44
+
45
+ ## Installation
46
+
47
+ Clone the repository and run bundle install.
48
+
49
+ ```bash
50
+ git clone https://github.com/takkanm/tucue.git
51
+ cd tucue
52
+ bundle install
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ bundle exec tucue interview.mp3
59
+ ```
60
+
61
+ This launches the TUI and starts playback.
62
+
63
+ ### Options
64
+
65
+ | Option | Description |
66
+ |---|---|
67
+ | `-s`, `--start TIME` | Start playback at `TIME`. Accepts `SS`, `MM:SS`, or `HH:MM:SS` (e.g. `90`, `1:30`, `01:02:03`). |
68
+ | `-v`, `--version` | Show the version. |
69
+ | `-h`, `--help` | Show help. |
70
+
71
+ ```bash
72
+ bundle exec tucue --start 01:02:03 interview.mp3
73
+ ```
74
+
75
+ ## Key bindings
76
+
77
+ | Key | Action |
78
+ |---|---|
79
+ | `Space` / `p` | Play / pause |
80
+ | `→` | +5 seconds |
81
+ | `←` | -5 seconds |
82
+ | `]` | +15 seconds |
83
+ | `[` | -15 seconds |
84
+ | `m` | Mark the current position (prompts for an optional label) |
85
+ | `e` | Export the marks |
86
+ | `q` | Quit |
87
+
88
+ ## Export
89
+
90
+ Pressing `e` writes the current marks to a `.csv` file named after the
91
+ audio file.
92
+
93
+ ### CSV
94
+
95
+ ```csv
96
+ timestamp,seconds,label
97
+ 00:01:23,83.0,key point
98
+ 00:03:45,225.5,
99
+ ```
100
+
101
+ ### JSON
102
+
103
+ JSON export is also supported (`Tucue::Marker#export_json`).
104
+
105
+ ```json
106
+ [
107
+ {
108
+ "timestamp": "00:01:23",
109
+ "seconds": 83.0,
110
+ "label": "key point"
111
+ },
112
+ {
113
+ "timestamp": "00:03:45",
114
+ "seconds": 225.5,
115
+ "label": null
116
+ }
117
+ ]
118
+ ```
119
+
120
+ ## Architecture
121
+
122
+ | File | Responsibility |
123
+ |---|---|
124
+ | `bin/tucue` | CLI entry point |
125
+ | `lib/tucue/player.rb` | Playback engine controlling mpv over JSON IPC (Unix socket) |
126
+ | `lib/tucue/ui.rb` | curses-based TUI and key-input loop |
127
+ | `lib/tucue/marker.rb` | Mark management and CSV / JSON export |
128
+
129
+ mpv is launched with `--input-ipc-server` and controlled by sending JSON
130
+ commands over a Unix domain socket to seek and read the playback position.
131
+
132
+ ## License
133
+
134
+ tucue is released under the [MIT License](LICENSE).
135
+
136
+ tucue requires [mpv](https://mpv.io/), which is licensed under
137
+ GPL-2.0-or-later and LGPL-2.1-or-later. mpv runs as a separate process and
138
+ is not distributed with tucue; you install it yourself (e.g. via Homebrew),
139
+ so its copyleft terms do not apply to tucue's own source.
data/bin/tucue ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/tucue"
5
+
6
+ exit Tucue::CLI.start(ARGV)
data/lib/tucue/cli.rb ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Tucue
6
+ # Command-line entry point: parses arguments and launches the TUI.
7
+ class CLI
8
+ # Run with the given argv. Returns a process exit status (0 = success).
9
+ def self.start(argv)
10
+ new.start(argv)
11
+ end
12
+
13
+ def start(argv)
14
+ options = parse(argv)
15
+ file = argv.shift
16
+
17
+ unless file
18
+ warn "usage: tucue [options] FILE"
19
+ return 1
20
+ end
21
+
22
+ unless File.exist?(file)
23
+ warn "tucue: file not found: #{file}"
24
+ return 1
25
+ end
26
+
27
+ player = Player.new(file, start_at: options[:start_at])
28
+ marker = Marker.new
29
+ UI.new(player, marker).run
30
+ 0
31
+ rescue OptionParser::ParseError, ArgumentError => e
32
+ warn "tucue: #{e.message}"
33
+ 1
34
+ end
35
+
36
+ # Parse "SS", "MM:SS", or "HH:MM:SS" (seconds may be fractional) into a
37
+ # number of seconds. Returns nil for nil input; raises ArgumentError on a
38
+ # malformed value.
39
+ def self.parse_time(value)
40
+ return nil if value.nil?
41
+
42
+ parts = value.to_s.split(":", -1)
43
+ unless (1..3).cover?(parts.size) && parts.all? { |p| p.match?(/\A\d+(\.\d+)?\z/) }
44
+ raise ArgumentError, "invalid time: #{value.inspect} (use SS, MM:SS, or HH:MM:SS)"
45
+ end
46
+
47
+ parts.map(&:to_f).reduce(0.0) { |acc, part| acc * 60 + part }
48
+ end
49
+
50
+ private
51
+
52
+ def parse(argv)
53
+ options = {}
54
+ parser = OptionParser.new do |o|
55
+ o.banner = "usage: tucue [options] FILE"
56
+ o.on("-s", "--start TIME", "Start playback at TIME (e.g. 90, 1:30, 01:02:03)") do |v|
57
+ options[:start_at] = self.class.parse_time(v)
58
+ end
59
+ o.on("-v", "--version", "Show version") do
60
+ puts Tucue::VERSION
61
+ exit 0
62
+ end
63
+ o.on("-h", "--help", "Show this help") do
64
+ puts o
65
+ exit 0
66
+ end
67
+ end
68
+ parser.parse!(argv)
69
+ options
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+
6
+ module Tucue
7
+ # Records marks and exports them to CSV / JSON.
8
+ class Marker
9
+ # A single mark: +time+ is the playback position in seconds and
10
+ # +label+ is an optional user-supplied note.
11
+ Mark = Struct.new(:time, :label) do
12
+ # Format the time as HH:MM:SS.
13
+ def timestamp
14
+ total = time.to_i
15
+ format("%02d:%02d:%02d", total / 3600, (total % 3600) / 60, total % 60)
16
+ end
17
+
18
+ def to_h
19
+ {timestamp: timestamp, seconds: time.to_f.round(3), label: label}
20
+ end
21
+ end
22
+
23
+ CSV_HEADERS = %w[timestamp seconds label].freeze
24
+
25
+ def initialize
26
+ @marks = []
27
+ end
28
+
29
+ attr_reader :marks
30
+
31
+ # Append a mark at +time+ seconds with an optional +label+.
32
+ def add(time, label = nil)
33
+ label = nil if label.is_a?(String) && label.strip.empty?
34
+ mark = Mark.new(time, label)
35
+ @marks << mark
36
+ mark
37
+ end
38
+
39
+ def empty?
40
+ @marks.empty?
41
+ end
42
+
43
+ # Write the marks to +path+ as CSV and return the path.
44
+ def export_csv(path)
45
+ CSV.open(path, "w") do |csv|
46
+ csv << CSV_HEADERS
47
+ @marks.each do |mark|
48
+ csv << [mark.timestamp, mark.time.to_f.round(3), mark.label]
49
+ end
50
+ end
51
+ path
52
+ end
53
+
54
+ # Write the marks to +path+ as JSON and return the path.
55
+ def export_json(path)
56
+ File.write(path, JSON.pretty_generate(@marks.map(&:to_h)))
57
+ path
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require "timeout"
6
+
7
+ module Tucue
8
+ # Playback engine that controls mpv over --input-ipc-server.
9
+ #
10
+ # Spawns mpv as a child process and talks to it through a Unix domain
11
+ # socket, sending JSON IPC commands to seek and read the playback position.
12
+ # https://mpv.io/manual/stable/#json-ipc
13
+ class Player
14
+ class Error < Tucue::Error; end
15
+
16
+ # Max seconds to wait for the IPC socket to appear.
17
+ SOCKET_TIMEOUT = 5
18
+
19
+ def initialize(file, socket_path: "/tmp/tucue.sock", start_at: nil, extra_args: [])
20
+ @file = file
21
+ @socket_path = socket_path
22
+ @start_at = start_at
23
+ @extra_args = extra_args
24
+ @pid = nil
25
+ @socket = nil
26
+ @request_id = 0
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ attr_reader :file
31
+
32
+ # Launch mpv and connect to its IPC socket.
33
+ def start
34
+ raise Error, "mpv not found in PATH" unless mpv_available?
35
+
36
+ File.unlink(@socket_path) if File.exist?(@socket_path)
37
+
38
+ @pid = spawn(
39
+ "mpv",
40
+ "--no-video",
41
+ "--no-terminal",
42
+ "--input-ipc-server=#{@socket_path}",
43
+ *(@start_at ? ["--start=#{@start_at}"] : []),
44
+ *@extra_args,
45
+ @file
46
+ )
47
+
48
+ connect
49
+ self
50
+ end
51
+
52
+ # Toggle between playing and paused.
53
+ def toggle_pause
54
+ command("cycle", "pause")
55
+ end
56
+
57
+ def play
58
+ set_property("pause", false)
59
+ end
60
+
61
+ def pause
62
+ set_property("pause", true)
63
+ end
64
+
65
+ def paused?
66
+ get_property("pause") == true
67
+ end
68
+
69
+ # Seek relative to the current position (seconds; negative rewinds).
70
+ def seek(seconds)
71
+ command("seek", seconds, "relative")
72
+ end
73
+
74
+ # Current playback position in seconds, or nil if unavailable.
75
+ def time_pos
76
+ get_property("time-pos")
77
+ end
78
+
79
+ # Total duration in seconds, or nil if unavailable.
80
+ def duration
81
+ get_property("duration")
82
+ end
83
+
84
+ # Quit mpv and close the socket.
85
+ def stop
86
+ command("quit") if @socket
87
+ rescue Error
88
+ # Already gone; ignore.
89
+ ensure
90
+ close
91
+ end
92
+
93
+ private
94
+
95
+ def mpv_available?
96
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
97
+ File.executable?(File.join(dir, "mpv"))
98
+ end
99
+ end
100
+
101
+ def connect
102
+ Timeout.timeout(SOCKET_TIMEOUT) do
103
+ loop do
104
+ break if File.socket?(@socket_path)
105
+
106
+ sleep 0.05
107
+ end
108
+
109
+ loop do
110
+ @socket = UNIXSocket.new(@socket_path)
111
+ break
112
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
113
+ sleep 0.05
114
+ end
115
+ end
116
+ rescue Timeout::Error
117
+ raise Error, "timed out waiting for mpv IPC socket: #{@socket_path}"
118
+ end
119
+
120
+ # Send a command whose return value we don't care about.
121
+ def command(*args)
122
+ send_command(args)["error"] == "success"
123
+ end
124
+
125
+ def get_property(name)
126
+ response = send_command(["get_property", name])
127
+ (response["error"] == "success") ? response["data"] : nil
128
+ end
129
+
130
+ def set_property(name, value)
131
+ command("set_property", name, value)
132
+ end
133
+
134
+ # Send a JSON IPC command and return the matching response.
135
+ # Requests and responses are paired by request_id; event messages
136
+ # arriving in between are skipped.
137
+ def send_command(args)
138
+ raise Error, "player not started" unless @socket
139
+
140
+ @mutex.synchronize do
141
+ id = (@request_id += 1)
142
+ @socket.puts(JSON.generate(command: args, request_id: id))
143
+
144
+ loop do
145
+ line = @socket.gets
146
+ raise Error, "mpv connection closed" if line.nil?
147
+
148
+ message = JSON.parse(line)
149
+ return message if message["request_id"] == id
150
+ # Anything else (events, etc.) is ignored.
151
+ end
152
+ end
153
+ rescue Errno::EPIPE, IOError => e
154
+ raise Error, "mpv IPC error: #{e.message}"
155
+ end
156
+
157
+ def close
158
+ @socket&.close
159
+ @socket = nil
160
+ if @pid
161
+ Process.wait(@pid)
162
+ end
163
+ rescue Errno::ECHILD, Errno::ESRCH
164
+ # Already reaped.
165
+ ensure
166
+ @pid = nil
167
+ File.unlink(@socket_path) if File.exist?(@socket_path)
168
+ end
169
+ end
170
+ end
data/lib/tucue/ui.rb ADDED
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "curses"
4
+
5
+ module Tucue
6
+ # curses-based TUI handling the key-input loop and screen drawing.
7
+ #
8
+ # Rather than spawning a separate poll thread (curses is not thread-safe),
9
+ # the main loop uses a getch timeout so the screen refreshes periodically
10
+ # while still reacting to key presses promptly.
11
+ class UI
12
+ SEEK_SMALL = 5
13
+ SEEK_LARGE = 15
14
+
15
+ # getch timeout in ms; also the screen refresh interval.
16
+ REFRESH_MS = 200
17
+
18
+ PROGRESS_WIDTH = 24
19
+
20
+ def initialize(player, marker)
21
+ @player = player
22
+ @marker = marker
23
+ @status = ""
24
+ @running = true
25
+ end
26
+
27
+ def run
28
+ @player.start
29
+ @status = "Playing #{File.basename(@player.file)}"
30
+ with_curses do
31
+ loop do
32
+ begin
33
+ draw
34
+ handle_key(@window.getch)
35
+ rescue Tucue::Error => e
36
+ # The player connection dropped (e.g. mpv reached end of file
37
+ # or exited); report it and leave the loop cleanly.
38
+ @status = "Playback ended: #{e.message}"
39
+ @running = false
40
+ end
41
+ break unless @running
42
+ end
43
+ end
44
+ ensure
45
+ @player.stop
46
+ end
47
+
48
+ private
49
+
50
+ def with_curses
51
+ Curses.init_screen
52
+ Curses.curs_set(0)
53
+ Curses.noecho
54
+ Curses.stdscr.keypad(true)
55
+ @window = Curses.stdscr
56
+ @window.timeout = REFRESH_MS
57
+ yield
58
+ ensure
59
+ Curses.close_screen
60
+ end
61
+
62
+ def handle_key(key)
63
+ case key
64
+ when " ", "p"
65
+ @player.toggle_pause
66
+ @status = @player.paused? ? "Paused" : "Playing"
67
+ when Curses::Key::RIGHT
68
+ @player.seek(SEEK_SMALL)
69
+ when Curses::Key::LEFT
70
+ @player.seek(-SEEK_SMALL)
71
+ when "]"
72
+ @player.seek(SEEK_LARGE)
73
+ when "["
74
+ @player.seek(-SEEK_LARGE)
75
+ when "m"
76
+ add_mark
77
+ when "e"
78
+ export
79
+ when "q"
80
+ @running = false
81
+ end
82
+ rescue Tucue::Error => e
83
+ @status = "Error: #{e.message}"
84
+ end
85
+
86
+ def add_mark
87
+ pos = @player.time_pos || 0
88
+ label = prompt("Label (optional): ")
89
+ label = nil if label.empty?
90
+ @marker.add(pos, label)
91
+ @status = "Marked #{format_time(pos)}#{" - #{label}" if label}"
92
+ end
93
+
94
+ def export
95
+ path = "#{File.basename(@player.file, ".*")}.csv"
96
+ @marker.export_csv(path)
97
+ @status = "Exported #{@marker.marks.size} mark(s) to #{path}"
98
+ rescue NotImplementedError
99
+ @status = "Export not implemented yet"
100
+ end
101
+
102
+ # Read a line of input at the bottom of the screen.
103
+ def prompt(message)
104
+ Curses.curs_set(1)
105
+ Curses.echo
106
+ @window.setpos(@window.maxy - 1, 0)
107
+ @window.clrtoeol
108
+ @window.addstr(message)
109
+ @window.timeout = -1 # block until the user finishes typing
110
+ input = @window.getstr.to_s.strip
111
+ input
112
+ ensure
113
+ @window.timeout = REFRESH_MS
114
+ Curses.noecho
115
+ Curses.curs_set(0)
116
+ end
117
+
118
+ def draw
119
+ @window.erase
120
+ row = 0
121
+ @window.setpos(row, 0)
122
+ @window.addstr(" File: #{File.basename(@player.file)}")
123
+
124
+ row += 1
125
+ pos = @player.time_pos
126
+ dur = @player.duration
127
+ @window.setpos(row, 0)
128
+ @window.addstr(" #{format_time(pos)} / #{format_time(dur)} #{progress_bar(pos, dur)}")
129
+
130
+ row += 1
131
+ @window.setpos(row, 0)
132
+ @window.addstr(" " + ("-" * 40))
133
+
134
+ [
135
+ "[Space] play/pause",
136
+ "[<-] -#{SEEK_SMALL}s [->] +#{SEEK_SMALL}s",
137
+ "[[] -#{SEEK_LARGE}s []] +#{SEEK_LARGE}s",
138
+ "[m] mark [e] export",
139
+ "[q] quit"
140
+ ].each do |line|
141
+ row += 1
142
+ @window.setpos(row, 0)
143
+ @window.addstr(" #{line}")
144
+ end
145
+
146
+ row += 1
147
+ @window.setpos(row, 0)
148
+ @window.addstr(" " + ("-" * 40))
149
+
150
+ row += 1
151
+ @window.setpos(row, 0)
152
+ @window.addstr(" Marks (#{@marker.marks.size})")
153
+
154
+ @marker.marks.each do |mark|
155
+ row += 1
156
+ break if row >= @window.maxy - 1
157
+
158
+ label = mark.label ? " - #{mark.label}" : ""
159
+ @window.setpos(row, 0)
160
+ @window.addstr(" * #{format_time(mark.time)}#{label}")
161
+ end
162
+
163
+ @window.setpos(@window.maxy - 1, 0)
164
+ @window.addstr(" #{@status}")
165
+ @window.refresh
166
+ end
167
+
168
+ def progress_bar(pos, dur)
169
+ return "-" * PROGRESS_WIDTH unless pos && dur&.positive?
170
+
171
+ filled = [(pos.to_f / dur * PROGRESS_WIDTH).round, PROGRESS_WIDTH].min
172
+ ("#" * filled) + ("-" * (PROGRESS_WIDTH - filled))
173
+ end
174
+
175
+ # Format seconds as HH:MM:SS.
176
+ def format_time(seconds)
177
+ return "--:--:--" if seconds.nil?
178
+
179
+ total = seconds.to_i
180
+ format("%02d:%02d:%02d", total / 3600, (total % 3600) / 60, total % 60)
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tucue
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tucue.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tucue/version"
4
+
5
+ module Tucue
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative "tucue/player"
10
+ require_relative "tucue/marker"
11
+ require_relative "tucue/ui"
12
+ require_relative "tucue/cli"
data/tucue.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/tucue/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "tucue"
7
+ spec.version = Tucue::VERSION
8
+ spec.authors = ["takkanm"]
9
+ spec.email = ["takkanm@gmail.com"]
10
+
11
+ spec.summary = "TUI audio player for marking and exporting cue points."
12
+ spec.description = "tucue plays local audio files (mp3/wav) in a terminal UI, " \
13
+ "lets you mark timestamps with optional labels, and export " \
14
+ "them as CSV or JSON. Playback is delegated to mpv."
15
+ spec.homepage = "https://github.com/takkanm/tucue"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+
22
+ spec.files = Dir["lib/**/*.rb", "bin/*", "*.gemspec", "Gemfile", "README.md", "LICENSE", "CLAUDE.md"]
23
+ spec.bindir = "bin"
24
+ spec.executables = ["tucue"]
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "csv", "~> 3.0"
28
+ spec.add_dependency "curses", "~> 1.4"
29
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tucue
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - takkanm
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: curses
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.4'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.4'
40
+ description: tucue plays local audio files (mp3/wav) in a terminal UI, lets you mark
41
+ timestamps with optional labels, and export them as CSV or JSON. Playback is delegated
42
+ to mpv.
43
+ email:
44
+ - takkanm@gmail.com
45
+ executables:
46
+ - tucue
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CLAUDE.md
51
+ - Gemfile
52
+ - LICENSE
53
+ - README.md
54
+ - bin/tucue
55
+ - lib/tucue.rb
56
+ - lib/tucue/cli.rb
57
+ - lib/tucue/marker.rb
58
+ - lib/tucue/player.rb
59
+ - lib/tucue/ui.rb
60
+ - lib/tucue/version.rb
61
+ - tucue.gemspec
62
+ homepage: https://github.com/takkanm/tucue
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ homepage_uri: https://github.com/takkanm/tucue
67
+ source_code_uri: https://github.com/takkanm/tucue
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.3
83
+ specification_version: 4
84
+ summary: TUI audio player for marking and exporting cue points.
85
+ test_files: []