diff options
Diffstat (limited to 'blog')
| -rw-r--r-- | blog/blog.css | 99 | ||||
| -rw-r--r-- | blog/rockbox_stats/index.html | 560 | ||||
| -rw-r--r-- | blog/rockbox_stats/log-setting.bmp | bin | 153666 -> 0 bytes | |||
| -rw-r--r-- | blog/rockbox_stats/playback-settings.bmp | bin | 153666 -> 0 bytes | |||
| -rw-r--r-- | blog/rockbox_stats/player.bmp | bin | 153666 -> 0 bytes | |||
| -rw-r--r-- | blog/terminal_renderer_mkii/cover.png | bin | 6038269 -> 0 bytes | |||
| -rw-r--r-- | blog/terminal_renderer_mkii/david.png | bin | 27218 -> 0 bytes | |||
| -rw-r--r-- | blog/terminal_renderer_mkii/davidbayer.png | bin | 4213 -> 0 bytes | |||
| -rw-r--r-- | blog/terminal_renderer_mkii/davidthreshold.png | bin | 3324 -> 0 bytes | |||
| -rw-r--r-- | blog/terminal_renderer_mkii/index.html | 189 |
10 files changed, 0 insertions, 848 deletions
diff --git a/blog/blog.css b/blog/blog.css deleted file mode 100644 index 372964d..0000000 --- a/blog/blog.css +++ /dev/null @@ -1,99 +0,0 @@ -html { - background: url("/assets/bg.jpg"); - background-attachment: fixed; - background-size: cover; - image-rendering: pixelated; -} - -figure { - background-color: var(--bg0); - margin: 0; - margin-bottom: 1em; -} - -figure > img, figure > * > img, figure > svg, figure > * svg { - background-color: var(--bg-dim); -} - -.cover-image > img { - width: 100%; - height: auto; - padding: 0 50px; - display: block; - box-sizing: border-box; -} - -figcaption { - padding: 0.5em; - font-style: italic; -} - -body { - max-width: 800px; - margin: 1em auto; - background-color: var(--bg1); -} - -.text-section { - padding: 1em; -} - -.fig { - margin: 0.5em; -} - -.fig-horizontal { - width: min-content; - margin: 0.5em auto; -} - -.fig-right { - float: right; - width: min-content; -} - -.fig-left { - float: left; - width: min-content; -} - - -.fig > img, .fig > * > img { - padding: 0.5em; -} - -pre { - padding: 1em 0.5em; - margin: 0; - overflow: scroll; - background-color: var(--bg-dim); -} - -code { - background-color: var(--bg-dim); -} - -table.schema-table { - border-collapse: collapse; - display: inline-table; - margin-bottom: 0.5em; - background-color: var(--bg-dim); -} - -.schema-table th { - background-color: var(--bg0); -} - -.schema-table th,td { - border: 2px solid var(--bg2); - padding: 0.25em; -} - - - -@media (max-width: 700px) { - .fig-left, .fig-right { - float: unset; - margin: 0.5em auto; - } -}
\ No newline at end of file diff --git a/blog/rockbox_stats/index.html b/blog/rockbox_stats/index.html deleted file mode 100644 index 4aca821..0000000 --- a/blog/rockbox_stats/index.html +++ /dev/null @@ -1,560 +0,0 @@ -<!DOCTYPE html> - -<html> - -<head> - <title></title> - <link rel="stylesheet" href="/style.css"> - <link rel="stylesheet" href="/blog/blog.css"> -</head> - -<body> - <div class="text-section"> - <a href="..">↰ Back</a> - <a href="/">⌂ Home</a> - </div> - <article> - <section> - <div class="text-section"> - <!-- Header Section --> - <h1>Rockbox Stat Tracking</h1> - <p>September 22, 2025</p> - <p>In this post I talk about how I went about setting up a <a href="/rockstats" target="_blank">stat visualization page</a> for my rockbox mp3 player.</p> - </div> - <figure class="cover-image"> -<svg width="100%" height="360" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" viewBox="0 0 640 360"> -<rect width="640" height="360" x="0" y="0" fill="rgb(0,0,0)" fill-opacity="0"></rect> -<polyline points="427.2 233.9 440.6 240.7 455.6 240.7" fill="transparent" stroke="#e67e80" class="zr0-cls-0"></polyline> -<polyline points="202.3 203.4 192.6 206.3 177.6 206.3" fill="transparent" stroke="#e69875" class="zr0-cls-0"></polyline> -<polyline points="220 113.6 197.5 135.5 182.5 135.5" fill="transparent" stroke="#dbbc7f" class="zr0-cls-0"></polyline> -<polyline points="273.2 69.5 190.3 117.4 190.3 117.4" fill="transparent" stroke="#a7c080" class="zr0-cls-0"></polyline> -<polyline points="304.6 61 201.7 99.3 201.7 99.3" fill="transparent" stroke="#83c092" class="zr0-cls-0"></polyline> -<polyline points="310.7 60.4 217.9 81.2 217.9 81.2" fill="transparent" stroke="#7fbbb3" class="zr0-cls-0"></polyline> -<polyline points="315.4 60.1 242.2 63.1 242.2 63.1" fill="transparent" stroke="#d699b6" class="zr0-cls-0"></polyline> -<polyline points="318.6 60 318.4 45 303.4 45" fill="transparent" stroke="#f85552" class="zr0-cls-0"></polyline> -<path d="M320 60A120 120 0 1 1 223.6 251.5L320 180Z" fill="#e67e80" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="0" ecmeta_ssr_type="chart" class="zr0-cls-1"></path> -<path d="M223.6 251.5A120 120 0 0 1 203.6 150.7L320 180Z" fill="#e69875" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="1" ecmeta_ssr_type="chart" class="zr0-cls-2"></path> -<path d="M203.6 150.7A120 120 0 0 1 247.9 84.1L320 180Z" fill="#dbbc7f" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="2" ecmeta_ssr_type="chart" class="zr0-cls-3"></path> -<path d="M247.9 84.1A120 120 0 0 1 301.3 61.5L320 180Z" fill="#a7c080" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="3" ecmeta_ssr_type="chart" class="zr0-cls-4"></path> -<path d="M301.3 61.5A120 120 0 0 1 307.8 60.6L320 180Z" fill="#83c092" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="4" ecmeta_ssr_type="chart" class="zr0-cls-5"></path> -<path d="M307.8 60.6A120 120 0 0 1 313.6 60.2L320 180Z" fill="#7fbbb3" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="5" ecmeta_ssr_type="chart" class="zr0-cls-6"></path> -<path d="M313.6 60.2A120 120 0 0 1 317.2 60L320 180Z" fill="#d699b6" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="6" ecmeta_ssr_type="chart" class="zr0-cls-7"></path> -<path d="M317.2 60A120 120 0 0 1 320 60L320 180Z" fill="#f85552" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="7" ecmeta_ssr_type="chart" class="zr0-cls-8"></path> -<text dominant-baseline="central" text-anchor="start" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(460.5907 240.6868)" fill="var(--fg)">Progressive Rock</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(172.5853 206.2935)" fill="var(--fg)">Alternative</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(177.5244 135.5094)" fill="var(--fg)">Post-Rock</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(185.3496 117.4094)" fill="var(--fg)">Post-Hardcore</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(196.7011 99.3094)" fill="var(--fg)">Post-Metal</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(212.8733 81.2094)" fill="var(--fg)">Rock</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(237.2337 63.1094)" fill="var(--fg)">Shoegaze</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(298.4082 45.0094)" fill="var(--fg)">Progressive Metal</text> -<style> -.zr0-cls-0:hover { -cursor:pointer; -} -.zr0-cls-1:hover { -cursor:pointer; -fill:rgba(253,138,140,1); -} -.zr0-cls-2:hover { -cursor:pointer; -fill:rgba(253,167,128,1); -} -.zr0-cls-3:hover { -cursor:pointer; -fill:rgba(240,206,139,1); -} -.zr0-cls-4:hover { -cursor:pointer; -fill:rgba(183,211,140,1); -} -.zr0-cls-5:hover { -cursor:pointer; -fill:rgba(144,211,160,1); -} -.zr0-cls-6:hover { -cursor:pointer; -fill:rgba(139,205,196,1); -} -.zr0-cls-7:hover { -cursor:pointer; -fill:rgba(235,168,200,1); -} -.zr0-cls-8:hover { -cursor:pointer; -fill:rgba(255,93,90,1); -} - - - -</style> -</svg> - <figcaption>A static site generation experiment</figcaption> - </figure> - </section> - <section class="text-section"> - <h2>Preamble: Digital Sovereignity & Rockbox</h2> - <p> - I've been building up a pretty sizeable collection of digital music - over the last couple of years. I think there's a lot of value in owning - the music I pay for and being able to choose how I listen to it. - Purchasing music also allows me to support artists in a more direct - and substantial way than the fractions of cents for using streaming services, - but that's more of a happy consequence than some moral obligation I feel. - </p> - <p> - Over the years, I've enjoyed listening to my music in a variety of ways. - For years I kept all of my music files on all of my devices and used - various local music clients depending on the platform, most notably mpd - and ncmpcpp on linux. Eventually, as I charged headlong into the glorious - world of self-hosting, I began using a central Jellyfin media server that - I stream music and video from. It's super convenient, and works on all of - my devices (including my TV!). - </p> - <p> - My media server is great, and it's been the primary way I listen to music - for a while now. But it has limitations. For example, I don't expose my media - server to the internet, so I'm unable to stream from it while I'm out and - about. And even if I could, the bandwidth requirements would be pretty high. - I figured I would need a dedicated music player if I wanted to take my music - library on the go, and settled on the HIFI Walker H2 after reading some - online recommendations. The ability to install <a href="https://rockbox.org" target="_blank">Rockbox</a>, an open-source firmware, - was a big factor in my decision. I couldn't tell you how the device works - out of the box, since I flashed the firmware pretty much immediately once I got it, - but I've been super impressed with how the device works while running Rockbox. - </p> - <p> - <figure class="fig fig-right"> - <img src="player.bmp" alt="Screenshot of Rockbox player showing cool theme."> - <figcaption>I'm using a modified version of the <a - href="https://themes.rockbox.org/index.php?themeid=3266&target=aigoerosq" - target="_blank">InfoMatrix-v2</a> theme, which looks great.</figcaption> - </figure> - Rockbox comes with many codecs for common audio formats including FLAC and MP3. The - device boots extremely quickly, and the interface is snappy. Virtually every aspect - of the user experience is tweakable and customizable to a crazy degree. I've even begun - listening to music on my player even at home, since a device specifically for the - purpose provides less distraction while I'm trying to be productive. - </p> - <p> - All this to say I'm pretty much sold on Rockbox. But there's certain things I - still miss from my days of being a user of popular services like Spotify with - fancy APIs and data tracking. Things like Spotify wrapped or third-party apps - for visualizing playback statistics are a fun way to see what my listening history - looks like and could potentially be used to help find more music that I'd enjoy. - This is why when I noticed that Rockbock has a playback logging feature, a little - lightbulb lit up over my head. - </p> - </section> - <section class="text-section"> - <h2>Generating and Parsing Logs</h2> - <p> - <figure class="fig fig-right"> - <img src="log-setting.bmp" alt="Logging"> - <figcaption>The logging feature can be accessed through the settings menu.</figcaption> - </figure> - Rockbox has a feature that logs playback information to a text file. This feature can - be enabled by setting <b>Playback Settings > Logging</b> to "On". With this setting enabled, a - new line gets added to the end of the <b>.rockbox/playback.log</b> file every time you play a track, - containing info about what you played and when. - </p> - <p> - The logging feature is actually already used by the LastFM scrobbler plugin that comes preloaded with - Rockbox, which is probably the simplest way to get insights into your playback. However, - I personally want to avoid using third-party services as much as possible, because it's more fun. - </p> - <p> - If I take a look at a logfile generated after a bit of listening, I'll see that I've wound up with - a series of lines that each look something like this: - <figure class="fig"> - <pre><samp>1758478258:336689:336718:/<microSD0>/Music/This Is The Glasshouse/This Is The Glasshouse - 867/This Is The Glasshouse - 867 - 01 Streetlight By Streetlight.flac</samp></pre> - <figcaption>An example of a log entry for "Streetlight by Streetlight" by This is the Glasshouse. - </figcaption> - </figure> - </p> - <p> - I wasn't really able to find any information online about the format of these logs, but they appear - to be simple enough to figure out. From what I can tell, each event is broken up into 4 pieces: - <ol> - <li><b>Timestamp:</b> The number of milliseconds since the UNIX epoch. - <li><b>Playback Duration:</b> The amount of the song that was played, in milliseconds. - <li><b>Total Track Length:</b> The length of the played track, in milliseconds. - <li><b>File Path:</b> An absolute path to the file containing the track on the filesystem. - </ol> - All of this is enough to know what I was listening to and when. I can use the file path to check for - audio tags which can help glean even more information about my listening habits. - </p> - <p>Now that I have this information and know how to interpret it, I'm ready to start processing it!</p> - </section> - <section class="text-section"> - <h2>Analyzing Playback History</h2> - <p> - In order to get some useful information out of my playback history, I think it's a good idea to start by - building - a database. I created a sqlite database with the following tables: - <table class="schema-table"> - <thead> - <tr> - <th colspan="3">songs</th> - </tr> - </thead> - <tbody> - <tr> - <td>id</td> - <td>i64</td> - <td>PK</td> - </tr> - <tr> - <td>title</td> - <td>String</td> - <td></td> - </tr> - <tr> - <td>artists</td> - <td>JSON</td> - <td></td> - </tr> - <tr> - <td>album_id</td> - <td>i64?</td> - <td></td> - </tr> - <tr> - <td>genre</td> - <td>String?</td> - <td></td> - </tr> - </tbody> - </table> - <table class="schema-table"> - <thead> - <tr> - <th colspan="3">albums</th> - </tr> - </thead> - <tbody> - <tr> - <td>id</td> - <td>i64</td> - <td>PK</td> - </tr> - <tr> - <td>title</td> - <td>String</td> - <td></td> - </tr> - <tr> - <td>artist</td> - <td>String</td> - <td></td> - </tr> - <tr> - <td>cover_art</td> - <td>Blob?</td> - <td></td> - </tr> - </tbody> - </table> - <table class="schema-table"> - <thead> - <tr> - <th colspan="3">history</th> - </tr> - </thead> - <tbody> - <tr> - <td>id</td> - <td>i64</td> - <td>PK</td> - </tr> - <tr> - <td>timestamp</td> - <td>Datetime</td> - <td></td> - </tr> - <tr> - <td>duration</td> - <td>i64</td> - <td></td> - </tr> - <tr> - <td>song_id</td> - <td>i64</td> - <td></td> - </tr> - </tbody> - </table> - <br> - I can add more columns later, but this is a good place to start. - </p> - <p> - Now, as I read through the logfile line-by-line, I can check if each album exists before - inserting it into the database: - <figure class="fig"> - <pre><code>for line in log_file.lines().flatten() { - println!("{line}"); - // Skip comments - if line.starts_with("#") { - continue; - } - let chunks = line.split(":").collect::<Vec<_>>(); - - let timestamp = DateTime::from_timestamp_secs( - i64::from_str_radix(chunks[0], 10).context("Failed to parse timestamp")?, - ) - .context("Failed to convert timestamp")?; - - // Load tags from file on device - let file_path = chunks[chunks.len() - 1][1..] - .split_once("/") - .context("Missing file")? - .1; - let tags = Tag::new() - .read_from_path(args.mount_point.join(file_path)) - .context("Failed to read audio tags")?; - - //... -}</code></pre> - <figcaption>Parsing log entry and loading audio metadata.</figcaption> - </figure> - <figure class="fig"> - <pre><code>if let Some(existing_album) = - sqlx::query("SELECT id FROM albums WHERE title=$1 AND artist=$2") - .bind(album_title) - .bind(album_artist) - .fetch_optional(&mut *db) - .await - .context("Failed to execute query to find existing album")? -{ - let album_id: i64 = existing_album.get("id"); - info!("Album already exists, id {album_id}"); - //... -} else { - info!("Inserting new album: {album_title} by {album_artist}"); - //... - let result = sqlx::query( - "INSERT INTO albums (title, artist, cover_art) VALUES ($1, $2, $3);", - ) - .bind(album_title) - .bind(album_artist) - .bind(cover) - .execute(&mut *db) - .await - .context("Failed to execute query to insert album into database")?; - - //... -}</code></pre> - <figcaption>Checking for an album with matching artist and title before creating a new row in the - database.</figcaption> - </figure> - I did something similar with the <b>songs</b> and <b>history</b> tables, basically building up a cache - of history information and skipping anything that's already in the database on repeat runs. - </p> - <p> - With this database constructed, it's pretty easy to get a bunch of different information - about my listening. For example (forgive me if my SQL skills are kind of ass lol): - <figure class="fig"> - <pre><code>SELECT - songs.title AS song_title, - songs.artists AS song_artists, - songs.genre AS song_genre, - albums.title AS album_title, - albums.artist AS album_artist, - history.timestamp AS timestamp, - history.duration AS duration -FROM history -CROSS JOIN songs ON songs.id = history.song_id -CROSS JOIN albums ON albums.id = songs.album_id -ORDER BY timestamp DESC;</code></pre> - <figcaption>Querying for a list of each history entry along with track metadata, sorted from most to - least recent.</figcaption> - </figure> - <figure class="fig"> - <pre><code>SELECT - songs.genre, - SUM(history.duration) AS total_duration -FROM history -CROSS JOIN songs ON history.song_id = songs.id -GROUP BY genre -ORDER BY total_duration DESC -LIMIT 10; </code></pre> - <figcaption>Querying for the top 10 most listened genres by playtime.</figcaption> - </figure> - </p> - <p> - It's all well and good to be able to view this information using a database client, - but it would be really cool if I could visualize this data somehow. - </p> - </section> - <section class="text-section"> - <h2>Visualizing this Data Somehow</h2> - <p> - I wanted to make this data available on my website for people to view, and for a bunch of mostly trivial - reasons I won't get into here, I have a couple of requirements for pages on this site: - <ol> - <li>Pages need to be static. - <li>Pages need to be JavaScript-free. - </ol> - This means any chart rendering needs to be done automatically at build time before - deploying. I don't currently use a static site generator for my site (just for fun), - so I'm basically going to need to write one specifically to generate this page. - </p> - <p> - I won't get too deep into the specifics of how I queried the database and generated each visualization - on - the page, but I can explain the visualizations I created using the queries from the previous section. - For the - listening history I wanted to generate a table displaying the information. To accomplish this, I first - used a combination of <a href="https://crates.io/crates/sqlx" target="_blank">sqlx</a>'s ability to convert a row to a struct and <a href="https://crates.io/crates/serde" target="_blank">serde</a> to serialize - the rows as JSON values. - <figure class="fig"> - <pre><code>#[derive(Serialize, Deserialize, FromRow)] -struct HistoryEntry { - song_title: String, - song_artists: Value, - timestamp: DateTime<Utc>, - duration: i64, - album_title: String, - album_artist: Option<String>, - song_genre: Option<String>, -} - -//...later -let history = sqlx::query_as::<_, HistoryEntry>( - /* SELECT... */ -).fetch_all(&mut *db).await; - -//...later still, tera context accepts -let mut context = tera::Context::new(); -context.insert("history", &history); -</code></pre> - <figcaption>Struct definition for a history entry, allowing conversion from a sqlx row and - de/serialization from/to JSON.</figcaption> - </figure> - </p> - <p> - In order to keep the generation as painless as possible, I decided to use the <a href="https://keats.github.io/tera" target="_blank">Tera</a> template - engine, which allows me to define a template HTML file and substitute in values from - a context which I can define before rendering. In the case of the table, I can just generate a <code><tr></code> - matching the data for each item: - <figure class="fig"> - <pre><code>{% macro history_table(history) %} -<h3>Playback History</h3> -<div class="table-container"> - <table> - <thead> - <tr> - <th>Timestamp</th> - <th>Played Duration</th> - <th>Title</th> - <th>Artists</th> - <th>Album</th> - <th>Genre</th> - </tr> - </thead> - <tbody> - {% for item in history %}<tr> - <td>{{ item.timestamp | date(format="%Y-%m-%d %H:%M:%S") }}</td> - <td>{{ item.duration | hms }}</td> - <td>{{ item.song_title }}</td> - <td>{{ item.song_artists }}</td> - <td>{{ item.album_title }}</td> - <td>{{ item.song_genre }}</td> - </tr> - {% endfor %} - </tbody> - </table> -</div> -{% endmacro history_table %}</code></pre> - <figcaption> - A Tera macro for generating a table from a list of playback history items. - I used a macro so I can re-use this later if I want to add time range views. - (last month, year, etc.) - </figcaption> - </figure> - </p> - <p> - I wrote similar macros for each of the visualizations I wanted to create. Most are - easy, but for my top 10 genres I wanted to display a pie chart. I found a pretty decent - data visualization crate called <a href="https://crates.io/crates/charming" target="_blank">charming</a> that's able to render to html, however - the output contains javascript so it's a no-go for me. Luckily, it can also render to - an SVG which I can embed nicely within the page. - <figure class="fig"> -<svg width="100%" height="360" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" viewBox="0 0 640 360"> -<rect width="640" height="360" x="0" y="0" fill="rgb(0,0,0)" fill-opacity="0"></rect> -<polyline points="427.2 233.9 440.6 240.7 455.6 240.7" fill="transparent" stroke="#e67e80" class="zr0-cls-0"></polyline> -<polyline points="202.3 203.4 192.6 206.3 177.6 206.3" fill="transparent" stroke="#e69875" class="zr0-cls-0"></polyline> -<polyline points="220 113.6 197.5 135.5 182.5 135.5" fill="transparent" stroke="#dbbc7f" class="zr0-cls-0"></polyline> -<polyline points="273.2 69.5 190.3 117.4 190.3 117.4" fill="transparent" stroke="#a7c080" class="zr0-cls-0"></polyline> -<polyline points="304.6 61 201.7 99.3 201.7 99.3" fill="transparent" stroke="#83c092" class="zr0-cls-0"></polyline> -<polyline points="310.7 60.4 217.9 81.2 217.9 81.2" fill="transparent" stroke="#7fbbb3" class="zr0-cls-0"></polyline> -<polyline points="315.4 60.1 242.2 63.1 242.2 63.1" fill="transparent" stroke="#d699b6" class="zr0-cls-0"></polyline> -<polyline points="318.6 60 318.4 45 303.4 45" fill="transparent" stroke="#f85552" class="zr0-cls-0"></polyline> -<path d="M320 60A120 120 0 1 1 223.6 251.5L320 180Z" fill="#e67e80" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="0" ecmeta_ssr_type="chart" class="zr0-cls-1"></path> -<path d="M223.6 251.5A120 120 0 0 1 203.6 150.7L320 180Z" fill="#e69875" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="1" ecmeta_ssr_type="chart" class="zr0-cls-2"></path> -<path d="M203.6 150.7A120 120 0 0 1 247.9 84.1L320 180Z" fill="#dbbc7f" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="2" ecmeta_ssr_type="chart" class="zr0-cls-3"></path> -<path d="M247.9 84.1A120 120 0 0 1 301.3 61.5L320 180Z" fill="#a7c080" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="3" ecmeta_ssr_type="chart" class="zr0-cls-4"></path> -<path d="M301.3 61.5A120 120 0 0 1 307.8 60.6L320 180Z" fill="#83c092" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="4" ecmeta_ssr_type="chart" class="zr0-cls-5"></path> -<path d="M307.8 60.6A120 120 0 0 1 313.6 60.2L320 180Z" fill="#7fbbb3" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="5" ecmeta_ssr_type="chart" class="zr0-cls-6"></path> -<path d="M313.6 60.2A120 120 0 0 1 317.2 60L320 180Z" fill="#d699b6" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="6" ecmeta_ssr_type="chart" class="zr0-cls-7"></path> -<path d="M317.2 60A120 120 0 0 1 320 60L320 180Z" fill="#f85552" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="7" ecmeta_ssr_type="chart" class="zr0-cls-8"></path> -<text dominant-baseline="central" text-anchor="start" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(460.5907 240.6868)" fill="var(--fg)">Progressive Rock</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(172.5853 206.2935)" fill="var(--fg)">Alternative</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(177.5244 135.5094)" fill="var(--fg)">Post-Rock</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(185.3496 117.4094)" fill="var(--fg)">Post-Hardcore</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(196.7011 99.3094)" fill="var(--fg)">Post-Metal</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(212.8733 81.2094)" fill="var(--fg)">Rock</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(237.2337 63.1094)" fill="var(--fg)">Shoegaze</text> -<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(298.4082 45.0094)" fill="var(--fg)">Progressive Metal</text> -<style> -.zr0-cls-0:hover { -cursor:pointer; -} -.zr0-cls-1:hover { -cursor:pointer; -fill:rgba(253,138,140,1); -} -.zr0-cls-2:hover { -cursor:pointer; -fill:rgba(253,167,128,1); -} -.zr0-cls-3:hover { -cursor:pointer; -fill:rgba(240,206,139,1); -} -.zr0-cls-4:hover { -cursor:pointer; -fill:rgba(183,211,140,1); -} -.zr0-cls-5:hover { -cursor:pointer; -fill:rgba(144,211,160,1); -} -.zr0-cls-6:hover { -cursor:pointer; -fill:rgba(139,205,196,1); -} -.zr0-cls-7:hover { -cursor:pointer; -fill:rgba(235,168,200,1); -} -.zr0-cls-8:hover { -cursor:pointer; -fill:rgba(255,93,90,1); -} - - - -</style> -</svg> - <figcaption>Here's one I generated just now.</figcaption> - </figure> - </p> - <p> - And that's pretty much all there is to it! The finished thing can be found <a href="/rockstats" target="_blank">here</a>. - </p> - </section> - </article> -</body> - -</html>
\ No newline at end of file diff --git a/blog/rockbox_stats/log-setting.bmp b/blog/rockbox_stats/log-setting.bmp Binary files differdeleted file mode 100644 index 29789fa..0000000 --- a/blog/rockbox_stats/log-setting.bmp +++ /dev/null diff --git a/blog/rockbox_stats/playback-settings.bmp b/blog/rockbox_stats/playback-settings.bmp Binary files differdeleted file mode 100644 index cee3cfb..0000000 --- a/blog/rockbox_stats/playback-settings.bmp +++ /dev/null diff --git a/blog/rockbox_stats/player.bmp b/blog/rockbox_stats/player.bmp Binary files differdeleted file mode 100644 index 452a057..0000000 --- a/blog/rockbox_stats/player.bmp +++ /dev/null diff --git a/blog/terminal_renderer_mkii/cover.png b/blog/terminal_renderer_mkii/cover.png Binary files differdeleted file mode 100644 index b3ddfd9..0000000 --- a/blog/terminal_renderer_mkii/cover.png +++ /dev/null diff --git a/blog/terminal_renderer_mkii/david.png b/blog/terminal_renderer_mkii/david.png Binary files differdeleted file mode 100644 index 6cfa884..0000000 --- a/blog/terminal_renderer_mkii/david.png +++ /dev/null diff --git a/blog/terminal_renderer_mkii/davidbayer.png b/blog/terminal_renderer_mkii/davidbayer.png Binary files differdeleted file mode 100644 index af4bfc4..0000000 --- a/blog/terminal_renderer_mkii/davidbayer.png +++ /dev/null diff --git a/blog/terminal_renderer_mkii/davidthreshold.png b/blog/terminal_renderer_mkii/davidthreshold.png Binary files differdeleted file mode 100644 index 6c6e014..0000000 --- a/blog/terminal_renderer_mkii/davidthreshold.png +++ /dev/null diff --git a/blog/terminal_renderer_mkii/index.html b/blog/terminal_renderer_mkii/index.html deleted file mode 100644 index 326c5d4..0000000 --- a/blog/terminal_renderer_mkii/index.html +++ /dev/null @@ -1,189 +0,0 @@ -<!DOCTYPE html> - -<html> - -<head> - <title>Terminal Renderer Mk. II - Rendering to Text with Compute</title> - <meta name="og:title" content="Terminal Renderer Mk. II - Rendering to Text with Compute"/> - <meta name="og:description" content="This week I brought my terminal renderer to the next level by performing text rendering on the GPU."/> - <meta name="og:image" content="https://soaos.dev/blog/terminal_renderer_mkii/cover.png"/> - <link rel="stylesheet" href="/style.css"> - <link rel="stylesheet" href="/blog/blog.css"> -</head> - -<body> - <div class="text-section"> - <a href="..">↰ Back</a> - <a href="/">⌂ Home</a> - </div> - <article> - <section> - <div class="text-section"> - <!-- Header Section --> - <h1>Terminal Renderer Mk. II - Rendering to Text with Compute</h1> - <p>October 2, 2025</p> - <p>This week I brought my terminal renderer to the next level by performing text rendering on the GPU. - </p> - </div> - <figure class="cover-image"> - <img src="cover.png" alt=""> - <figcaption>The Stanford Dragon, outlined and rendered as Braille characters in a terminal emulator. <a href="https://tv.soaos.dev/w/fBnDAUPsTPHaoPeNNxBGch" target="_blank"> -Full video</a> - </figcaption> - </figure> - </section> - <section class="text-section"> - <h2>Context</h2> - <h3>Unicode Braille</h3> - <p> - I first messed around with rendering images to the terminal with Braille characters in like 2022 I - think? I wrote a simple CLI tool - that applied a threshold to an input image and output it as Braille characters in the terminal. <a - href="https://tv.soaos.dev/w/twpHAu4Jv8LJc9YjZbfw5g" target="_blank">Here's a recording I took back - when I did it.</a> - </p> - - <p> - <figure class="fig fig-right"> - <div class="centered"> - <table class="schema-table"> - <tbody> - <tr> - <td>0</td> - <td>3</td> - </tr> - <tr> - <td>1</td> - <td>4</td> - </tr> - <tr> - <td>2</td> - <td>5</td> - </tr> - <tr> - <td>6</td> - <td>7</td> - </tr> - </tbody> - </table> - </div> - <figcaption>The corresponding bit position for each braille dot.</figcaption> - </figure> - This effect is pretty cool, and it was pretty easy to implement as well. The trick lies in how the - <a href="https://en.wikipedia.org/wiki/Braille_Patterns#Block" target="_blank">Unicode Braille block</a> - is laid out. Every 8-dot Braille combination happens to add up to 256 combinations, the perfect amount to - fit in the range between <code>0x2800</code> (⠀) and <code>0x28FF</code> (⣿). In other words, every - character - within the block can be represented by changing the value of a single byte. - </p> - <p> - The lowest 6 bits of the pattern map on to a 6-dot braille pattern. However, due - to historical reasons the 8-dot values were tacked on after the fact, which adds - a slightly annoying mapping to the conversion process. Either way, it's a lot easier - than it could be to just read a pixel value, check its brightness, and then use a - bitwise operation to set/clear a dot. - </p> - <h3>Ordered Dithering</h3> - <p> - Comparing the brightnes of a pixel against a constant threshold is a fine way to - display black and white images, but it's far from ideal and often results in the loss - of a lot of detail from the original image. - </p> - <figure class="fig fig-horizontal"> - <div class="horizontal-container"> - <img src="david.png" alt="" /> - <img src="davidthreshold.png" alt="" /> - <img src="davidbayer.png" alt="" /> - </div> - <figcaption>From left to right: Original image, threshold, and ordered dither. <a - href="https://en.wikipedia.org/wiki/Dither" target="_blank">Wikipedia</a></figcaption> - </figure> - <p>By using <a href="https://en.wikipedia.org/wiki/Ordered_dithering" target="_blank">ordered dithering</a>, - we - can preserve much more of the subtleties of the original image. While not the "truest" version of - dithering possible, - ordered dithering (and <i>Bayer</i> dithering in particular) provides a few advantages that make it very - well suited to realtime computer graphics: - <ul> - <li>Each pixel is dithered independent of any other pixel in the image, making it extremely - parallelizable and good for shaders.</li> - <li>It's visually stable, changes to one part of the image won't disturb other areas.</li> - <li>It's dead simple.</li> - </ul> - Feel free to read up on the specifics of threshold maps and stuff, but for the purposes of this little - explanation it's - enough to know that it's basically just a matrix of 𝓃⨉𝓃 values between 0 and 1, and then to determine - whether a pixel (𝓍,𝓎) - is white or black, you check the brightness against the threshold value at (𝓍%𝓃,𝓎%𝓃) in the map. - </p> - </section> - <section class="text-section"> - <h2>The old way™</h2> - <p> - My first attempt at <i>realtime</i> terminal graphics with ordered dithering - (<a href="https://tv.soaos.dev/w/dzHBnPJXtDBwtSvirgwTvY" target="_blank">I put a video up at the time</a>) - ran entirely on the CPU. I pre-calculated the threshold map at the beginning of execution and ran each - frame - through a sequential function to dither it and convert it to Braille characters. - </p> - <p> - To be honest, I never noticed - any significant performance issues doing this, as you can imagine the image size required to fill a - terminal - screen is signficantly smaller than a normal window. However, I knew I could easily perform the - dithering on the GPU - as a post-processing effect, so I eventually wrote a shader to do that. In combination with another - effect I used to - add outlines to objects, I was able to significantly improve the visual fidelity of the experience. A - good example of - where the renderer was at until like a week ago can be seen in <a - href="https://tv.soaos.dev/w/9Pf2tP3PYY5pJ3Cimhqs9x" target="_blank">this video</a>. - </p> - <p> - Until now I hadn't really considered moving the text conversion to the GPU. I mean, <i>G</i>PU is for - graphics, - right? I just copied the <i>entire framebuffer</i> back onto the CPU after dithering - and used the same sequential conversion algorithm. Then I had an idea that would drastically reduce the - amount - of copying necessary. - </p> - </section> - <section class="text-section"> - <h2>Compute post-processing</h2> - <p> - What if, instead of extracting and copying the framebuffer every single frame, we "rendered" the text on - the GPU - and read <i>that</i> back instead? Assuming each pixel in a texture is 32 bits (RGBA8), and knowing that - each braille - character is a block of 8 pixels, could we not theoretically shave off <i>at least</i> 7/8 of the bytes - copied? - </p> - <p> - As it turns out, it's remarkably easy to do. I'm using the <a href="https://bevy.org" - target="_blank">Bevy engine</a>, - and hooking in a compute node to my existing post-processing render pipeline worked right out of the - box. - I allocated a storage buffer large enough to hold the necessary amount of characters, read it back each - frame, and dumped - the contents into the terminal. - </p> - <p> - I used UTF-32 encoding on the storage buffer because I knew I could easily convert a "wide string" into - UTF-8 before printing it, and - 32 bits provides a consistent space to fill for each workgroup in the shader versus a variable-length - encoding like UTF-8. <a href="https://tv.soaos.dev/w/fBnDAUPsTPHaoPeNNxBGch" target="_blank">Here's a video of the new renderer working</a>. - Although now that I think about it, I could probably switch to using UTF-16 since all the Braille - characters could be represented - in 2 bytes, and that would be half the size of the UTF-32 text, which is half empty bytes anyways. - </p> - <p> - Okay so I went and tried that but remembered that shaders only accept 32-bit primitive types, so it doesn't matter anyways. This little side quest has been a part of my - broader efforts to revive a project I - spent a lot of time on. I'm taking the opportunity to really dig in and rework some of the stuff I'm not - totally happy with. So there might be quite a few of this kind of post in the near future. Stay tuned. - </p> - </section> - </article> -</body> - -</html> |
