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 +7 -0
- data/CLAUDE.md +158 -0
- data/Gemfile +12 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/bin/tucue +6 -0
- data/lib/tucue/cli.rb +72 -0
- data/lib/tucue/marker.rb +60 -0
- data/lib/tucue/player.rb +170 -0
- data/lib/tucue/ui.rb +183 -0
- data/lib/tucue/version.rb +5 -0
- data/lib/tucue.rb +12 -0
- data/tucue.gemspec +29 -0
- metadata +85 -0
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
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
|
+
[](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
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
|
data/lib/tucue/marker.rb
ADDED
|
@@ -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
|
data/lib/tucue/player.rb
ADDED
|
@@ -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
|
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: []
|