playr 0.8.1

A minimal TUI music player that plays local files and contacts nothing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# playr

A minimal music player which plays a directory, a saved playlist, or the results of a search. Keeps a SQLite index of your library.

When you download playr, you get three implementations: `playr`, a terminal app, `playr-gui`, a desktop gui app, and `playr-server`, a server for a machine without a screen, controlled from a web page or Open Sound Control (OSC).

None of the three contact external services or download any metadata and images. Indeed, `playr` and `playr-gui` have no network code at all. `playr-server` runs on your local network and communicates only with the browsers and OSC apps you connect to it.

![Varispeed on a Boards of Canada record is a good use of an afternoon.](https://raw.githubusercontent.com/shakfu/playr/main/docs/media/playr.png)


## Features

**Playback**

- Plays a file, a directory recursively, a saved playlist, or a search result

- Gapless within a run of tracks that share a sample rate

- Play, pause, next, previous, stop

- Four playback modes on one key: normal, shuffle, repeat, repeat one

- Seek by 5 seconds in either direction, or 30 with shift, resuming on the exact sample

- Marks: `b` marks a moment in a track, `B` undoes the last mark, `,` and `.` seek between marks, and marks are kept in the library

- Samples: `:slice` writes regions between marks, equal parts or onset slices as lossless WAV files that rtrack loads as a sample bank

- Sampler view: zoom to single frames, nudge the playhead and snap it to zero crossings, and set a range to slice or loop, with its ends moved while it loops

- Varispeed in semitone steps, 0.5x to 2.0x, pitch moving with tempo

- Volume as a float gain applied before quantisation

- A file it cannot decode is reported and skipped, never fatal

**Audio quality**

- Opens the output at the file's own sample rate whenever the device allows it, so nothing is resampled in the common case

- Sinc resampling when a rate is refused, not linear interpolation

- f32 throughout: decode, mix and gain, quantised once at the device

- Plays to float, 32-bit, 24-bit and 16-bit integer devices

- Lock-free ring between the decoder and the realtime callback

- Underruns emit silence rather than repeating stale samples

**Library**

- Recursive scan with tag and stream-property reading

- Rescan skips files whose size and modification time are unchanged

- Tracks whose files are gone are kept until `playr prune` removes them, so an unplugged drive does not empty playlists

- Scans commit every 500 files, so an interrupted scan keeps its progress

- Full-text search over title, artist, album, album artist and file name, or within one of them with `artist:evans`

- Search results play directly, in library order

- Playlists saved to and loaded from the database

**Interface**

- Four views: library, selection, playlists, and a sampler showing the playing track's waveform; the web page has the first three

- Dark and light themes; the terminal takes its colours from its own theme, and honours `NO_COLOR`

- Search filters as you type

- A selection to collect tracks into, edit and save as a playlist; it does not change what plays, and its tracks are marked `+` in the library

- Now playing shows title, artist, source rate and channels

- Progress bar with elapsed and total time

- Skipped files and audio device errors shown in the status line

- Vim and arrow key navigation; the window and the web page take the mouse as well, and the page takes touch

- Vim-style `:` commands for every action, with Tab completion and a history; they also take arguments such as `:seek 1:23` or `:playlist late night`

- `?` lists the keys for the view you are in; the bottom line shows messages, speed, volume, and a level meter

- The level meter reads momentary loudness in LUFS (ITU-R BS.1770, 400 ms) and holds the sample peak for 1.5 s. It measures the recording before the volume setting

- The meter bar runs from -40 dB to full scale and fills green below -18 dB, yellow to -6 dB, and red above; the peak marker `|` takes the colour of where it sits. Red on the bar means near the top, which is normal for loud masters. The peak number turns red only at full scale, where the recording clips

## Interfaces

playr is three programs over one core. All read the same library and the same `settings.toml`, and all take the same keys and `:` commands.

- **`playr`**, the terminal interface, drawn with ratatui. It needs no display server, so it runs over SSH.

- **`playr-gui`**, a desktop window, drawn with egui. Tables with right-click menus, menus for every action, file dialogs, and files dropped on the window.

- **`playr-server`**, for a machine without a screen, such as a Raspberry Pi with a DAC. It plays on that machine, and a web page or OSC controls it. The only program with network code.

Only one runs at a time: while one is running, the others, `playr scan` and `playr prune` refuse to start. `playr playlists`, `playr search --json`, `playr roots` and `playr formats` only read, and run alongside any of them.

| | `playr` | `playr-gui` | `playr-server` |
|-|-|-|-|
| library, selection and playlists views | yes | yes | yes |
| keys, `:` commands, `?` and `:help` | yes | yes | yes |
| search, marks, playlists, themes | yes | yes | yes |
| playback modes, varispeed, volume, level meter | yes | yes | yes |
| sampler view and `:slice` | yes | yes | no |
| mouse | no | yes | yes, and touch |
| media keys and the now-playing panel | yes* | yes | no |
| opens, scans or prunes a path it is given | yes | yes | no |
| re-scans the directories already recorded | yes | yes | yes |
| `:map` and `:unmap` | yes | yes | no |
| quits from the interface | yes | yes | no |
| network code | none | none | HTTP, and OSC with `--osc` |

\* Not on Windows, where the panel attaches to a window and the terminal has none.

The page reaches playr over a network, so it refuses the sampler, any command naming a path, `:map` and quitting. [docs/dev/server.md](docs/dev/server.md) holds the allow list.

## Install

### Release archives

Each [GitHub release](https://github.com/shakfu/playr/releases) has prebuilt archives, with Opus, for:

- Linux: x86_64 and arm64, glibc 2.35 or later

- macOS: arm64 and x86_64, 11.0 or later

- Windows: x86_64

Each archive holds the three programs, with `SHA256SUMS` in the release for checking them, and the release has a TouchOSC layout for `playr-server`. Put `playr` and `playr-server` on your `PATH`; the window needs one more step on macOS and Linux:

- **macOS:** the window is `playr.app`; move it to `Applications`. It is not signed or notarized, so macOS refuses to open it once downloaded with a browser; allow it under System Settings, Privacy & Security, or run `xattr -dr com.apple.quarantine playr.app`.

- **Linux:** copy `playr-gui` onto your `PATH`, `playr.desktop` to `~/.local/share/applications`, and `playr.png` to `~/.local/share/icons/hicolor/256x256/apps`, and playr is listed among your applications.

- **Windows:** run `playr-gui.exe`; it opens without a console window.

### From a clone

```sh
make install    # the three programs, and the window as an application
make gui        # run the window without installing
make app        # macOS: build target/release/playr.app
```

`make install` copies `playr`, `playr-gui` and `playr-server` to `~/.local/bin`. On macOS it also puts `playr.app` in `~/Applications`; on Linux it adds `playr.desktop` and its icon under `~/.local/share`.

### With cargo

```sh
cargo install playr                                             # the terminal, from crates.io
cargo install --git https://github.com/shakfu/playr playr-gui    # the window, from GitHub
cargo install --git https://github.com/shakfu/playr playr-server # the server, from GitHub
```

`playr-gui` and `playr-server` are not on crates.io yet. `cargo install` builds only the program, without the macOS bundle or the Linux desktop entry. Add `--features opus` to either for Opus.

### Building

Building needs Rust 1.89+ and a C compiler. SQLite is vendored and compiled from source, which is what the C compiler is for; no SQLite package has to be installed. On Linux it also needs the ALSA headers, and the window needs the X11 and Wayland development headers. On Debian and Ubuntu:

```sh
sudo apt install libasound2-dev                       # both programs
sudo apt install libxkbcommon-dev libwayland-dev \
  libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev   # the window
```

### Opus

Opus is off by default. It needs libopus, which is vendored and built with **cmake** -- the only part of playr that needs it. To include it:

```sh
cargo build --release --features opus                                        # the terminal
cargo build --release -p playr -p playr-gui --features playr/opus,playr-gui/opus   # both
```

Without it, Opus files are reported as undecodable and skipped, the same as WMA or DSD. `playr formats` says which build you have. `make install` builds without Opus; to install with it, copy the binaries after the build above, or use `cargo install` with `--features opus`.

## Use

```sh
playr                                # the terminal
playr-gui                            # the desktop window
playr-server                         # the web page; prints the address to open
```

Each takes the same options, reads the same library and the same settings, and answers the same keys. [Interfaces](#interfaces) compares what each one has.

### The terminal

```sh
playr scan ~/music          # index a directory
playr prune ~/music         # remove tracks and marks of files gone from it
playr                       # browse the library
playr ~/music/some/album    # play a directory, recursively, without indexing
playr search bill evans     # play everything that matches
playr search album:blue     # match the album only
playr search --json evans   # print the matches as JSON instead of playing them
playr playlist "late night" # play a saved playlist
playr playlists             # list saved playlists
playr formats               # show what this build can decode
```

`playr <command> --help` describes each command. A search that starts with `-` goes after `--`, as in `playr search -- -ology`. `--json` prints an array with one object per track, holding every library column, `null` for a missing tag, and `duration_ms` in milliseconds; no match prints `[]` and exits with status 1. Bad arguments exit with status 2.

Without a command it opens the four views on the library. It draws in any terminal, needs no display server, and runs over SSH. [Keys](#keys) lists the bindings, and `?` lists them for the view you are in.

### The desktop window

```sh
playr-gui                        # open the window on the library
playr-gui ~/music/some/album     # play a directory, as playr does
playr-gui --db other.db          # use a different library file
```

It takes the terminal's options and reads the same settings file, so its keys and `:` commands are the terminal's. It has the library, selection and playlists as tables with right-click menus, search, menus for every action, file dialogs, the transport, the level meter, and the sampler view, where a click on the waveform seeks, a shift-click marks, and the mouse wheel zooms. It is dark unless View, Theme or the `theme` setting chooses otherwise. [docs/dev/gui.md](docs/dev/gui.md) records its design and what is still open.

File, Add folder to library scans a directory, as `playr scan` does; File, Rescan library re-scans those folders; File, Library directories lists them, each with a Forget button; File, Remove missing files prunes every recorded folder; and File, Open plays files without adding them; files dropped on the window play too. When the window cannot start, for bad settings or no audio device, it opens a window that says why.

### The server

```sh
playr-server                         # serve on 127.0.0.1:8080, printing the address
playr-server --listen 0.0.0.0:8080   # reach it from other devices
playr-server --db other.db           # use a different library file
```

`playr-server` plays on the machine it runs on, such as a Raspberry Pi with a DAC, and serves a web page that controls it. It takes the terminal's options and reads the same settings file, so the page's keys and `:` commands are the terminal's. The page has the library, selection and playlists views, search, row menus, dialogs, marks, themes and the level meter, without the sampler. It adapts to a phone, a tablet or a desktop browser: click or tap a row to move the cursor, again to play, right-click or `...` for its menu, and shift-click the progress bar to add a mark.

Every request needs a token, printed in the startup address and kept in `server.token` beside the library. Opening that address sets a cookie for a year, so each browser needs it once. `--open` serves the page without a token, for a network where every device is trusted; the `Host` and `Origin` checks still refuse a website whose domain resolves to the machine.

The page cannot name a path, so it cannot open, scan or prune one, and it cannot quit the server. Its Rescan library re-scans the directories `playr scan` recorded, and nothing else.

`--osc ADDR:PORT` receives OSC for playback, volume, speed, mode and playlists by index, and `--osc-reply ADDR:PORT` sends the title, position and level back. `playr-server osc-schema` prints every address as JSON, and each release has a TouchOSC layout built from it.

[docs/server-guide.md](docs/server-guide.md) covers access from other devices, a QR code for a phone, running behind a proxy, and running it as a systemd service on a Raspberry Pi.

### The library

One library file serves all three interfaces and the `playr` subcommands.

It lives at `$XDG_DATA_HOME/playr/library.db`, or `~/.local/share/playr/library.db`. Override it with `--db <path>`. Only `playr scan`, or `:scan` inside playr, creates it. Until then the other commands run without a library, and `s` cannot save a playlist. Paths are stored in full, so a scan run from any directory finds the same rows. A path that is not valid UTF-8 is skipped and counted as unreadable.

Rescanning only re-reads files whose size or modification time changed. Each scanned directory is remembered as a root, so `:rescan` (or `:sync`) inside playr, and a bare `playr scan`, cover them again without naming them. A directory holding no audio file does not become a root, and neither does one inside a root: the root above it already covers its files.

`:roots` lists the directories the library covers, and `playr roots` prints them. `:roots rm DIR`, or `playr roots rm DIR`, forgets one: the directory stops being part of the library, and the tracks under it go with it, along with their places in playlists and their marks. Unlike a prune this does not ask the filesystem anything, so it works on a directory that is already gone. `:roots add DIR` is another spelling of `:scan DIR`.

A scan never removes anything itself. It counts the tracks under the scanned directory whose files are gone; inside playr it then asks whether to prune them, and `playr scan` prints the count and the command. `playr prune`, or `:prune`, covers every root; `playr prune DIR` and `:prune DIR` cover one. They remove those tracks, and with them their places in playlists, and the marks of every file under the directory that is gone, whether it was in the library or not. Nothing outside the named directories is touched, so pruning `~/music` leaves an unplugged drive mounted elsewhere alone. Prune after a file is moved or deleted for good, not while a drive under that directory is unplugged.

Playlist entries and marks are the only things in the library that are not read back from the files, so pruning is the one operation that loses work. With `auto_prune = true` a scan inside playr prunes without asking, except when a directory read as empty although the library holds tracks under it: that is what an unmounted drive looks like, so playr asks instead.

### Media keys and the now-playing panel

The keyboard's play, pause, next and previous keys work while playr runs, and the system's now-playing panel shows the track and takes its buttons: MPRIS on Linux, where playr appears as `org.mpris.MediaPlayer2.playr`, the macOS now-playing panel, and the Windows one. The terminal and the window have them alike. `playr-server` has neither. A machine without a screen has no panel to show and no keyboard to press; its controls are the web page and OSC.

None of it is required. A machine with no session bus or no panel runs playr as before. They belong to the machine playr runs on, so a `playr` reached over SSH has none: the media keys in front of you are routed by your own desktop, to its own players. On Windows the panel attaches to a window, which the terminal has not got, so there it works in `playr-gui` only. MPRIS is a local bus, not a network: playr still contacts nothing.

### Taking up again

playr remembers the track playing and how far into it when it closes, and offers it at the next start: "take up amen.flac again at 1:35?". Only `y` takes it up; anything else starts as playr always did. Nothing is offered when the command line named tracks to play, or when the file has gone. The position is stored in the library when playr closes and again at each track change, so a playr that is killed still leaves the track behind, if not the second.

## Keys

These keys are the same in the terminal, the window and the web page, and any of them can be rebound in [`settings.toml`](#configuration). The window and the page take the mouse as well, and the page takes touch.

| keys                     | action                                      |
|--------------------------|---------------------------------------------|
| `tab`, `1` `2` `3` `4`   | switch view; `4` is the sampler             |
| `j` `k`, up/down         | move                                        |
| `g` `G`, home/end        | jump to first or last                       |
| page up/down             | move by ten                                 |
| `enter`                  | play from here; in playlists, play it       |
| `a`                      | select or unselect, then move down          |
| `/`                      | search; `esc` clears                        |
| `r`                      | rename the selected playlist                |
| `s`                      | save the selection; asks before overwriting |
| `d`                      | remove from selection; delete a playlist    |
| `J` `K`, shift up/down   | move a track within the selection           |
| `c`                      | clear the selection; asks y/n               |
| `space`                  | play or pause                               |
| `n` `p`                  | next or previous track                      |
| `x`                      | stop                                        |
| `m` `M`                  | next or previous playback mode              |
| left/right               | seek back or forward 5 seconds              |
| shift left/right         | seek back or forward 30 seconds             |
| `b`                      | mark the playing position                   |
| `,` `.`                  | seek to the previous or next mark           |
| `B`                      | undo the last mark                          |
| `C`                      | clear all marks in this track; asks y/n     |
| `(` `)`                  | varispeed down or up, one semitone a press  |
| `\`                      | back to normal speed                        |
| `+` `-`                  | volume                                      |
| `:`                      | type a command; see [Commands](#commands)   |
| `?`                      | list the keys for this view                 |
| `q`                      | quit                                        |

Searching filters as you type, across title, artist, album, album artist and the file name without its folder or extension. The file name is what finds an untagged file, which the list shows by that name. Pressing enter on the results plays them.

Every word must match, as the start of a word. Prefix a word with a field to match it in that field alone: `title:`, `artist:`, `album:`, `albumartist:`, or `file:`. Quote words to match them together in order, as in `artist:"bill evans"`; unquoted, a field applies only to the word it is attached to. A prefix that is not one of these fields is searched as text, so `op:1` still finds a title with a colon in it.

Enter plays the list you are looking at, from the selected track: the library, search results, the selection, or a playlist. The selection is separate from what plays. It starts empty. In the library, `a` selects the track under the cursor, or unselects it if it is marked `+`, without interrupting playback. On a playlist, `a` adds its tracks, skipping any already selected but keeping the playlist's own repeats. `s` saves the selection as a playlist. To edit a playlist, add it to the selection with `a`, change it, and save it under the same name.

### Playback modes

`m` steps through four modes and `M` steps back. The bottom line names the mode unless it is normal.

| mode       | order                                              | after the last track |
|------------|----------------------------------------------------|----------------------|
| normal     | list order                                         | stop                 |
| shuffle    | every track once per pass, in random order         | reshuffle, go on     |
| repeat     | list order                                         | start again          |
| repeat one | the current track only                             | play it again        |

A mode applies to whatever list is playing: the library, search results, the selection, or a playlist. To shuffle across several playlists, add them to the selection with `a` and play the selection. Shuffle keeps the list on screen in its own order, and `p` goes back through the tracks it has played. Under repeat one, `n` moves on to the next track, which then repeats. Changing mode takes effect from the next track, even if it has already started loading.

### Marks

`b` marks the playing position in the current track. Marks show as `^` under the progress bar. `.` seeks to the next mark and `,` to the previous one; within a second after a mark, `,` goes to the one before it, so pressing it twice steps back twice. A mark within half a second of an existing one is not added again, except in the sampler view, where marks may be a frame apart.

Marks form a chain: `B` removes the mark added most recently, then the one before, whatever their positions in the track. `C` clears all of the track's marks; it asks first, and only `y` confirms.

Marks are stored in the library by file path and source frame, so they survive a rescan and stay exact at any playback speed. Without a library file they last until playr exits. A mark lands slightly after the moment you meant, by your reaction time; in the [sampler view](#sampler-view) it can be picked up with the cursor and moved, dragged in the window, or snapped to the nearest rise in the sound.

### Samples

`:slice` writes parts of the playing track as WAV files, for rtrack or any sampler. Marks set the regions. The region is the span between the marks either side of the playhead, from the start of the track or to its end where there is no mark on that side. In the [sampler view](#sampler-view) a range can replace it.

![Slicing a Boards of Canada record is a good use of an afternoon.](https://raw.githubusercontent.com/shakfu/playr/main/docs/media/waveform.png)


| command             | writes                                                               |
|---------------------|----------------------------------------------------------------------|
| `:slice region`     | the region                                                           |
| `:slice marks`      | the whole track, cut at every mark                                   |
| `:slice N`          | the region in N equal parts, 2 to 256                                |
| `:slice onsets [S]` | the region, cut where hits start; `S` from 0 to 1, higher finds more |

Each export writes a new directory, named after the track, under `samples` in [`settings.toml`](#configuration), by default `~/Music/playr/samples`. A second export of `amen.flac` goes to `amen-2`. The directory holds:

- `000-amen_S00.wav`, `001-amen_S01.wav`, and so on, one file per slice, in the layout rtrack loads as a sample bank.

- `samples.json`, with the source file and each slice's start and end frame.

Slices are read from the source file, so volume and speed do not apply. They are 24-bit WAV at the source's sample rate and channel count; 16- and 24-bit sources are copied bit for bit. Without `S`, `:slice onsets` uses `onset_sensitivity` from `settings.toml`, 0.5 by default. Onset detection is rtrack's: a hit within 50 ms of the region's start stays in the first slice, and each slice starts up to 10 ms before its hit. It reads the region into memory, up to about 23 minutes at 48 kHz. Export runs in the background, and the bottom line reports when it is done.

For MP3 and AAC, frame positions follow playr's decoder. Another decoder can count the codec's encoder delay differently and place the same slice up to a few thousand frames away.

[docs/sampler.md](docs/sampler.md) shows how regions and cuts fit together, what `samples.json` holds, and how precise a mark is.

### Sampler view

`4` opens a view of the playing track's waveform, read from the file the first time the view opens for that track. Marks show as `|` under it, the playhead as `^`, the cursor as `#`, and the region between the marks either side of the playhead in the accent colour. The detail line gives the region's times to the millisecond.

| key     | command                 | does                                              |
|---------|-------------------------|---------------------------------------------------|
| `z` `Z` | `:zoom +`, `:zoom -`    | zoom in or out, centred on the playhead           |
| `0`     | `:zoom all`             | show the whole track                              |
| `w`     | `:display`              | switch display: Braille, envelope, dB             |
| left, right | `:nudge -1`, `:nudge +1` | move the playhead a column                   |
| shift-left, shift-right | `:nudge -10%`, `:nudge +10%` | move it a tenth of the view      |
| `S`     | `:snap`                 | snap to zero crossings, on or off                 |
| `<` `>` | `:in`, `:out`           | start or end the range at the playhead            |
| backspace | `:range`              | clear the range; `:range 1:02 1:04.5` sets one    |
| `l`     | `:loop`                 | play the range over and over, or stop             |
| `[` `]` | `:edge start`, `:edge end` | choose the range end to move, shown reversed   |
| `{` `}` | `:edge -1`, `:edge +1`  | move that end a column earlier or later           |
| `a`     | `:audition`             | play the range, slice or region once, then pause  |
| `;` `'` | `:cursor -1`, `:cursor +1` | move the cursor a column                       |
| `h`     | `:cursor off`           | return the cursor to the playhead                 |
| `u` `i` | `:pick prev`, `:pick next` | move the cursor to a mark                      |
| `y` `o` | `:nudge-mark -1`, `:nudge-mark +1` | move the mark under the cursor         |
| `#`     | `:snap-mark`            | move it to the nearest rise in the sound          |
| delete  | `:del-mark`             | remove it                                         |
| `enter` | `:write`                | write the slices planned                          |
| `esc`   | `:discard`              | discard them, or with none planned, clear the range |

- **Displays.** The envelope draws each column as two bars in eighth blocks: its RMS level in the bright colour, inside its peak level in a darker one. The waveform is folded, with negative samples counted by their size, so the bars use the full height. RMS shows loudness, such as a verse against a chorus, where a mastered track's peaks are near full scale everywhere; peak shows where each hit starts. The Braille display, which the view starts with, draws the waveform around a centre line, two dots across and four down a cell, which shows its shape. Both scale to the loudest sample in the track.

- **dB.** The dB display draws the same bars on a scale from -48 dBFS to full scale, not scaled to the track. A linear scale puts RMS 12 dB below full scale a quarter of the way up; this puts it three quarters of the way, which spreads out quiet passages and the level changes between sections. Levels below -48 dB draw nothing.

- **Zoom.** Each step halves the time a column shows, down to one frame a cell; the window goes on to 16 points a frame. Down to 64 frames, 1.5 ms at 44.1 kHz, columns start on the 32-frame buckets the peaks are kept in, so a column never shows a neighbour's hit. Closer than that, the view reads the frames it shows, and 2 s either side, in the background; until they arrive, each column shows its bucket's peaks. At a frame a column the window's line display draws each frame's channels' mean around a zero line, with a dot per frame once frames are 4 points apart, so a crossing can be picked out by eye.

- **The cursor.** The cursor is a second position, apart from the playhead, and it is what the mark keys act on. It starts on the playhead and follows it until moved; `h` returns it. `u` and `i` put it on the mark before or after it, which is how a mark is picked up: every mark key acts on the mark the cursor is on, within a column of the view, and says so when there is none. In the window, a mark is dragged along the waveform instead.

- **Editing a mark.** `y` and `o` move the picked mark a column at a time, `:move-mark TIME` puts it at a time, and delete removes it, wherever it sits in the chain `B` undoes. `#` moves it to the nearest rise in the sound, looked for in the two seconds either side: a mark placed by reaction time lands late, and this puts it on the hit. The window around it is read in the background, so it costs the same on a long track as a short one. A move onto another mark is refused rather than merging the two.

- **Audition.** `a` plays the range, or the planned slice the playhead is in, or the region around it, once, and pauses at its end rather than returning to its start as `l` does. Playing on afterwards continues the track from there.

- **Planning.** In this view, `:slice` plans slices instead of writing them, and draws their edges as `+`. Enter writes exactly those slices; esc discards them, and so does a change of track. Outside the view, `:slice` writes at once.

- **Nudging.** The arrows move the playhead a column, and with shift a tenth of the view, so zooming in makes each step finer, down to one frame. Outside this view they seek 5 and 30 s. Pause first to place a point without hearing each step.

- **Snap.** With `:snap on`, shown as `snap` in the title, nudges, marks, seeks and range ends made in this view move to the nearest zero crossing within 10 ms: a frame where the channels' mean changes sign. A nudge snaps only past where it started, so repeated nudges walk from crossing to crossing. Where no crossing is within reach, as in silence, the point stays.

- **Range.** `<` and `>` set a range's start and end at the playhead, drawn as `[` and `]`; the window sets one by dragging across the waveform. With both ends set, every cut uses the range in place of the region: `:slice region` cuts it whole, `:slice 8` in equal parts, `:slice onsets` at its onsets, and `:slice marks` at the marks inside it. The range lasts until cleared or the track changes, and is not saved. In the window, a drag that starts on a range's edge moves that edge.

- **Loop.** `l` plays the range over and over, starting a paused track, and returns from its end to its start without a gap. Moving either end, with `<` or `>`, with `{` or `}` after `[` or `]` picks it, with `:range` or a drag, moves the loop at once; clearing the range, a new track or `l` again ends it. When the decoder has already read past a new end, the change discards what it read, which can leave a short gap.

The waveform glyphs are the view's only characters outside ASCII. Marks are placed at the playhead, or in the window at a shift-click.

### Varispeed

`(` and `)` change playback speed in semitone steps, and pitch moves with it, as on a tape machine or a turntable. Twelve presses is exactly an octave, so the range is 0.5x to 2.0x. The speed shows in the status bar as `1.19x (+3 st)` and `\` returns to normal.

This is not the pitch-preserving speed change of a podcast app. That is time-stretching, which needs a phase vocoder; this is a change of resampling ratio, which is what varispeed means.

### Commands

`:` opens a command line: `:seek 1:23`, `:volume 60`, `:playlist late night`. Every key's action has a command, and commands also take arguments no key can, such as a time or a name. Some commands work only in one view, as `:remove` in the selection. Tab completes, up recalls earlier lines, and `:help` lists every command. [docs/cheatsheet.md](docs/cheatsheet.md) has the full list.

`:scan ~/music` adds a directory to the library without leaving playr. It runs in the background and counts files on the bottom line; once it finishes, the library view shows the new tracks. `:rescan` or `:sync` re-scans every directory previously added that way, or by `playr scan`. If any tracks are missing, playr asks to prune them, unless `auto_prune` is set. `:prune` (or `:prune ~/music`) does what `playr prune` does, after asking. Saving a playlist or a mark while a scan runs waits for the scan to finish writing its current batch of 500 files, and fails with "database is locked" if that takes more than 5 seconds. `:open ~/music/some/album` plays a file or directory, as `playr <path>` does, and adds its tracks to the end of the selection.

## Configuration

playr reads `$XDG_CONFIG_HOME/playr/settings.toml`, or `~/.config/playr/settings.toml`, when it starts. `--settings <path>` reads another file instead. The file is optional, and it is read on top of the defaults in [`crates/playr-core/src/settings.toml`](crates/playr-core/src/settings.toml) and the default keys in [`crates/playr-app/src/keys.toml`](crates/playr-app/src/keys.toml), so it only needs what it changes. Copying either defaults file whole, or both into one, is also valid.

```toml
volume = 60                        # percent, 0 to 100
mode = "shuffle"                   # normal, shuffle, repeat or repeat-one, in full
speed = -3                         # semitones, -12 to 12
onset_sensitivity = 0.7            # for :slice onsets without a number, 0 to 1
samples = "~/Music/playr/samples"  # where :slice writes
auto_prune = true                  # after a scan, prune missing tracks without asking
theme = "light"                    # system, light or dark

[keys]                             # every view
right = "seek +10"
shift-right = "seek +60"
ctrl-s = "save"
q = "nop"
"?" = "help"

[keys.selection]                   # one view: library, selection, playlists or sampler
x = "remove"
```

- Each key's value is a `:` command, as listed in [docs/cheatsheet.md](docs/cheatsheet.md). `"nop"` makes a key do nothing, and `"command"` opens the `:` prompt.

- A key under `[keys.VIEW]` wins in that view over the same key under `[keys]`.

- A key under `[keys]` needs a command that works in every view. `d = "remove"` there is refused, with the table to put it in.

- Keys are named by their character (`j`, `J`), or as `space`, `enter`, `esc`, `tab`, `backtab`, `backspace`, `delete`, `insert`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown`, or `f1` to `f12`. Prefix `ctrl-`, `alt-` or `shift-` for a chord; a chord only matches a binding that names it. TOML needs quotes around a key that is not a letter, digit, `-` or `_`, such as `"?"`.

- `ctrl-c` always quits, and the keys inside prompts and help lists cannot be changed.

Any error stops playr before it starts, and every bad setting is listed with its line number. `?` lists the keys as bound in the view you are in. `:map` and `:unmap` change keys until playr exits.

`theme` sets the colours, `dark` unless set, and `:theme` changes them until playr exits. In the window, `system` follows the system's light or dark appearance. A terminal cannot report its background reliably, so there `system` and `dark` use the terminal's own ANSI colours, which its theme shades, and `light` uses fixed colours for a light background. With `NO_COLOR` set to any value, the terminal draws without colour and reverses the cursor row.

## Formats

Decoded: FLAC, ALAC, MP3, MP1, MP2, AAC-LC, Vorbis, PCM and ADPCM, in WAV, AIFF, CAF, MP4/M4A, MKV/WebM, OGG and raw FLAC containers. Opus as well, when built with `--features opus`.

Opus is decoded by playr itself, in both OGG and WebM. Symphonia 0.6 demuxes Opus but ships no decoder, so `crates/playr-core/src/audio/opus.rs` supplies one on top of libopus via the `opus` crate and registers it in a custom codec registry. Mono and stereo only; multistream surround is not handled.

Tags are not read from CAF, MKV or WebM files. Those are indexed under their file names, and MKV and WebM files also show no duration.

Not decoded: WavPack, WMA, Musepack, APE, DSD, TTA, TAK, and Opus unless the feature is enabled. playr reports such a file and moves to the next track rather than stopping. Run `playr formats` for the current list.

## Audio quality

The output stream is opened at the file's own sample rate whenever the device accepts it, so nothing is resampled in the common case. When a rate is refused, a sinc resampler converts it rather than linear interpolation. Decoding, mixing and volume are all f32, quantised once at the device. The device format is chosen in the order f32, f64, 32-bit, 24-bit, then 16-bit integer.

This is not bit-perfect output. On a PipeWire system the ALSA `default` device accepts every rate and may convert internally. Bit-perfect playback would need a `hw:` device, which playr does not yet select.

Volume is a float gain applied before quantisation.

## Roadmap

`TODO.md` lists what is missing and what is blocked upstream.

## Design

[docs/architecture.md](docs/architecture.md) describes the split. `playr-core` holds audio, library, samples and session, with no presentation dependency; `playr-app` holds keys, commands and interface state. The terminal, the window and the server are three frontends over that pair, each supplying only its own presentation, and a fourth, such as a Tauri app, would too. [docs/dev/gui.md](docs/dev/gui.md) and [docs/dev/server.md](docs/dev/server.md) record the window's and the server's designs.

## Tests

```sh
make test
```

`make test` runs the suite for all five crates in the workspace, `playr-core`, `playr-app`, `playr`, `playr-gui` and `playr-server`, twice: with and without the `opus` feature, so neither build can rot unnoticed. The format and scanner tests generate real audio with `ffmpeg` when it is present and skip themselves when it is not. The rendering tests draw into a headless terminal, and the window's tests drive it headless with `egui_kittest`, so neither needs a display or an audio device. The engine, key-handling and device-failure tests play to a fake output device, so they need no audio device. One smoke test plays to the real default device at zero volume, and skips without one. Set `PLAYR_REQUIRE_FFMPEG=1` or `PLAYR_REQUIRE_DEVICE=1` to fail instead of skip, so a CI run cannot pass by testing nothing.

`.github/workflows/test.yml` runs both builds' tests on Linux, macOS and Windows on every branch push and pull request, with `PLAYR_REQUIRE_FFMPEG=1` and ffmpeg 9.0 on every runner, and checks formatting and clippy on Linux. Runners have no audio device, so only the real-device smoke test skips there.

`.github/workflows/release.yml` builds and packages the three programs for every platform when a version tag is pushed, builds the TouchOSC layout, and publishes the release. Run by hand from the Actions tab with no tag, it builds and packages the chosen branch and keeps the archives as the run's artifacts without publishing, to try every platform's build before tagging.

`make page-test` drives `playr-server`'s page in Chromium with Playwright, and `make touchosc-test` checks the TouchOSC layout against the server's addresses. They need uv, and the page tests a browser and an audio device, so neither is part of `make test`.

Opus output was checked against `ffmpeg` by decoding the same file both ways: identical frame counts and 138.7 dB SNR, with no alignment offset.

## License

MIT. See `LICENSE`.