<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Raymond Camden</title>
    <link href="https://www.raymondcamden.com/feed.xml" rel="self" type="application/atom+xml"></link>
    <link href="https://www.raymondcamden.com/" rel="alternate" type="text/html"></link>
    <subtitle>Father, husband, developer relations and web standards expert, and cat demo builder.</subtitle>

    <updated>2026-06-04T14:19:47+00:00</updated>
    <author>
        <name>Raymond Camden</name>
        <email>raymondcamden@gmail.com</email>
    </author>
    <id>https://www.raymondcamden.com/feed.xml</id>

    <generator>Eleventy</generator>

        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/02/ai-versus-a-grue</id>
                <title>AI versus a Grue</title>
                <updated>2026-06-02T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/02/ai-versus-a-grue" rel="alternate" type="text/html" title="AI versus a Grue"/>
                <content type="html">
				
                        &lt;p&gt;&amp;quot;It is pitch dark. You are likely to be eaten by a grue.&amp;quot; If you recognize that quote, congrats, you probably need to see your doctor about pain in your back. (Ok, pain everywhere.) For those of us of a certain age, or folks who like classic games, the Infocom series was a tremendous source of entertainment. Using plain text commands to navigate your environment, players had to use their minds to figure out puzzles and mazes in order to win the game. The IF (Interactive Fiction) community is still strong and of course, I&apos;ve spoken before about my time playing and coding on MUDs, so with that in mind, I thought I&apos;d try tackling something that I assumed probably wouldn&apos;t work well - seeing if Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/built-in&quot;&gt;built-in AI&lt;/a&gt; could handle playing Zork 1, the most famous of Infocom&apos;s library. The short answer is... not so well. But honestly, it was kind of fun to try so I figured I&apos;d share the results.&lt;/p&gt;
&lt;h2 id=&quot;getting-zork&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#getting-zork&quot;&gt;Getting Zork&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before I could even begin trying to test AI&apos;s ability to play a 50 year old game (almost 50, Zork 1 launched in 1977), I first needed to find a JavaScript implementation. This turned out to be a bit tricky.&lt;/p&gt;
&lt;p&gt;I quickly found &lt;a href=&quot;https://github.com/curiousdannii/ifvms.js&quot;&gt;ifvms.js&lt;/a&gt;, an interactive fiction virtual machine library, but it was tailored to running at the command line. Also, there was little to no documentation on how to make use of the library. The repo mentions another repo, &lt;a href=&quot;https://github.com/curiousdannii/parchment&quot;&gt;parchment&lt;/a&gt; which lets you play games on the web, but I struggled to find documentation there as well, or documentation specific to my use case.&lt;/p&gt;
&lt;p&gt;To help, I went to Claude, and had it load &lt;code&gt;ifvms.js&lt;/code&gt; and create a &apos;stubbed&apos; version that I could use via calls like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// all of this loads Zork 1...
const res = await fetch(&apos;zork1.b64.txt&apos;);
const b64 = await res.text();

const binary = atob(b64.trim());
const arrayBuffer = new ArrayBuffer(binary.length);
const view = new Uint8Array(arrayBuffer);
for (let i = 0; i &amp;lt; binary.length; i++) {
    view[i] = binary.charCodeAt(i);
}

engine = new ZorkEngine();
engine.load(arrayBuffer);

// and then later:
let gameResponse = engine.getOutput(); // last thing the game did

// send a command
let lastResponse = engine.send(command);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That source file above mentioned in the first line comes from an online source. Zork itself is open source (&lt;a href=&quot;https://github.com/historicalsource/zork1&quot;&gt;https://github.com/historicalsource/zork1&lt;/a&gt;), but I forget where I got my copy of the source. It&apos;s not a text file, I renamed it to that when initially attempting to build my demo on CodePen. I believe it was pure binary I converted to base64 and then renamed.&lt;/p&gt;
&lt;h2 id=&quot;building-the-game&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#building-the-game&quot;&gt;Building the Game&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So with Claude helping me build the version of &lt;code&gt;ifvms&lt;/code&gt; I could use interactively, I then had to whip up a quick UI and point Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;Prompt API&lt;/a&gt;, which as of Chrome 148 is GA to everyone. (Well, everyone on Chrome.)&lt;/p&gt;
&lt;p&gt;My UI was just a giant text area where I&apos;d stream the output from the game and Chrome&apos;s inputs. All of that code is kinda basic, so I&apos;ll focus on the prompt aspect. Here&apos;s how I create my session:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;$session = await LanguageModel.create({
    initialPrompts:[
        { 
            role: &apos;system&apos;, 
            content: 
                    `
You are playing the classic text adventure game Zork. You will be given the current game output, and you will 
respond with a single command to play the game. You will not explain your command, just give the command. 

Remember to keep your commands concise and to the point, as if you were playing the game yourself. Do not include 
any extra text or explanations.

Zork is a text-based adventure game where you explore a mysterious underground world filled with treasures, puzzles, 
and dangers. Your goal is to navigate through the various locations, solve puzzles, and collect treasures 
while avoiding traps and monsters.

Here are some examples of commands you might use in Zork:
- &amp;quot;go north&amp;quot;
- &amp;quot;take lamp&amp;quot;
- &amp;quot;open mailbox&amp;quot;
- &amp;quot;look around&amp;quot;
- &amp;quot;inventory&amp;quot;

Do your best to play the game effectively and efficiently, making smart choices based on the current game output.

Try not to keep returning to the same location repeatedly, as that can lead to getting stuck in loops. Instead, 
explore new areas and try different actions to progress through the game. Remember that you can travel in all directions.
                    `
        }
    ],		
    monitor(m) {
            m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
                console.log(`Downloaded ${e.loaded * 100}%`);
                if(e.loaded === 0 || e.loaded === 1) {
                    $output.innerHTML = &apos;&apos;;
                    return;
                }
                $output.innerHTML = `Downloading AI model, currently at ${Math.floor(e.loaded * 100)}%`;
            });
        }		
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see, a majority of this is the big prompt. I iterated over this multiple times to help guide Chrome to effectively solve the game. (Which to be clear, it never did.)&lt;/p&gt;
&lt;p&gt;Outside of that, the main loop of the game is this heartbeat function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function gameHeartBeat() {

    console.log(&apos;in hb&apos;);
    let gameResponse = engine.getOutput();
    if(!gameResponse) gameResponse = lastResponse;

    if(gameResponse) {
        $gameoutput.value += gameResponse + &apos;\n&apos;;
        $gameoutput.scrollTop = $gameoutput.scrollHeight;

        let command = await $session.prompt(gameResponse);
        lastResponse = engine.send(command);

        $gameoutput.value += `&amp;gt; ${command}\n`;
        $gameoutput.scrollTop = $gameoutput.scrollHeight;

        console.log(&apos;Resp&apos;,command);
        setTimeout(gameHeartBeat, 1000 * 2);
    }
 
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The variable &lt;code&gt;$gameoutput&lt;/code&gt; is just a DOM pointer to the &lt;code&gt;textarea&lt;/code&gt; used to render the game.&lt;/p&gt;
&lt;p&gt;Before going any further, I&apos;ll note a huge issue with my code and that&apos;s the lack of session management. Chrome absolutely provides you a way to monitor that usage and re-init the session when it is nearly filled. I didn&apos;t bother with that because I was being lazy when I built this.&lt;/p&gt;
&lt;p&gt;I&apos;d also argue that we could help Chrome&apos;s model quite a bit by keeping track of a list of rooms it has already visited to help it remember and not loop over the same place. My prompt tries to do that, but I could also help it out myself.&lt;/p&gt;
&lt;p&gt;All in all - this was a quick implementation and the results were... well what you would expect with a lightweight model and me just hacking quickly on it. ;)&lt;/p&gt;
&lt;p&gt;Here&apos;s a transcript from a test I just did.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ZORK I: The Great Underground Empire
Infocom interactive fiction - a fantasy story
Copyright (c) 1981, 1982, 1983, 1984, 1985, 1986 Infocom, Inc. All rights reserved.
ZORK is a registered trademark of Infocom, Inc.
Release 119 / Serial number 880429

West of House
You are standing in an open field west of a white house, with a boarded front door.
There is a small mailbox here.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 0
&amp;gt; examine mailbox
The small mailbox is closed.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 1
&amp;gt; open mailbox
Opening the small mailbox reveals a leaflet.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 2
&amp;gt; read leaflet
(Taken)
&amp;quot;WELCOME TO ZORK!

ZORK is a game of adventure, danger, and low cunning. In it you will explore some of the most amazing territory ever seen by mortals. No computer should be without one!&amp;quot;

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 3
&amp;gt; look around
West of House
You are standing in an open field west of a white house, with a boarded front door.
There is a small mailbox here.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 4
&amp;gt; go north
North of House
You are facing the north side of a white house. There is no door here, and all the windows are boarded up. To the north a narrow path winds through the trees.

&amp;gt;                                                                                 North of HouseScore: 0  Turns: 5
&amp;gt; go north
Forest Path
This is a path winding through a dimly lit forest. The path heads north-south here. One particularly large tree with some low branches stands at the edge of the path.

&amp;gt;                                                                                 Forest PathScore: 0  Turns: 6
&amp;gt; examine tree
There&apos;s nothing special about the tree.

&amp;gt;                                                                                 Forest PathScore: 0  Turns: 7
&amp;gt; go north
Clearing
You are in a clearing, with a forest surrounding you on all sides. A path leads south.
On the ground is a pile of leaves.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 8
&amp;gt; take leaves
In disturbing the pile of leaves, a grating is revealed.
Taken.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 9
&amp;gt; open grating
The grating is locked.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 10
&amp;gt; look around
Clearing
You are in a clearing, with a forest surrounding you on all sides. A path leads south.
There is a grating securely fastened into the ground.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 11
&amp;gt; go south
Forest Path

&amp;gt;                                                                                 Forest PathScore: 0  Turns: 12
&amp;gt; go north
Clearing

&amp;gt;                                                                                 ClearingScore: 0  Turns: 13
&amp;gt; examine leaves
There&apos;s nothing special about the pile of leaves.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 14
&amp;gt; go south
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my tests, it never seems to get much further in the game than the outer area, but certainly tries hard, so I guess I&apos;ll give it a gold star for that.&lt;/p&gt;
&lt;p&gt;If you want to try this yourself, you can point Chrome at &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/chrome_zork/&quot;&gt;https://cfjedimaster.github.io/webdemos/chrome_zork/&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you want to peruse the code yourself, you can check it out here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/chrome_zork&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/chrome_zork&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Now that I&apos;ve got a bit more time on my hands (ahem), I may try hacking on this a bit more, but it was certainly fun to see it in action.&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@sortino?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Joshua Sortino&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/photo-of-light-towards-inside-of-cave-rAqzj79GUmA?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/01/creating-a-heavy-rain-alert-with-raindrop-and-val-town</id>
                <title>Creating a Heavy Rain Alert with RainDrop and Val Town</title>
                <updated>2026-06-01T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/01/creating-a-heavy-rain-alert-with-raindrop-and-val-town" rel="alternate" type="text/html" title="Creating a Heavy Rain Alert with RainDrop and Val Town"/>
                <content type="html">
				
                        &lt;p&gt;One of the &amp;quot;joys&amp;quot; of living in Louisiana is the rainy season, which is heavier in spring but honestly, feels like it lasts all year long. I can still remember being on a business trip in 2016, about to fly home, and hearing about some sort of &apos;rain event&apos; back in Louisiana. This surprised me as there wasn&apos;t a hurricane involved, just an incredible amount of rain (up to 2-3 inches &lt;strong&gt;per hour&lt;/strong&gt;). You can read more about it on the &lt;a href=&quot;https://en.wikipedia.org/wiki/2016_Louisiana_floods&quot;&gt;Wikipedia page&lt;/a&gt; about the event, but it goes without saying - rain is a big deal down here.&lt;/p&gt;
&lt;p&gt;A while back I discovered &lt;a href=&quot;https://www.raindrop.farm/&quot;&gt;RainDrop&lt;/a&gt;, which is an app and a web site that lets you check on rainfall totals. You can see my zipcode here, &lt;a href=&quot;https://www.raindrop.farm/rainfall-totals/zipcode/70508#map&quot;&gt;https://www.raindrop.farm/rainfall-totals/zipcode/70508#map&lt;/a&gt;, and while we aren&apos;t having any rain this second, we&apos;ve got some forecast for today.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/raindrop1c.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot from RainDrop&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;When I discovered the site, I did some digging and discovered they had an API: &lt;a href=&quot;https://api.raindrop.farm/docs&quot;&gt;https://api.raindrop.farm/docs&lt;/a&gt;. This API gives you &lt;em&gt;incredibly&lt;/em&gt; detailed information about rainfall (and snow) and can even return historical data.&lt;/p&gt;
&lt;p&gt;Given an API key passed in an &lt;code&gt;Authorization&lt;/code&gt; header, this gets the current precipitation for my location:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://api.raindrop.farm/v1/precipitation/current?lat=30.216667&amp;amp;lon=-92.033333
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which returns (as of 11:34 AM, and I just heard thunder):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &amp;quot;lat&amp;quot;: 30.216667,
    &amp;quot;lon&amp;quot;: -92.033333,
    &amp;quot;timestamp&amp;quot;: &amp;quot;2026-06-01T16:30:00Z&amp;quot;,
    &amp;quot;precipitationIntensity&amp;quot;: 0.0
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ok - slight delay here - I just got out of a meeting, and the rain is here:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &amp;quot;lat&amp;quot;: 30.216667,
    &amp;quot;lon&amp;quot;: -92.033333,
    &amp;quot;timestamp&amp;quot;: &amp;quot;2026-06-01T17:06:00Z&amp;quot;,
    &amp;quot;precipitationIntensity&amp;quot;: 1.2999999523162842
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That value is in millimeters per hour so it looks like just a trickle, so nothing to worry about. Of course, what if it &lt;em&gt;was&lt;/em&gt; something to worry about? I thought - why not automate checking for this using my go to platform lately, &lt;a href=&quot;https://www.val.town/&quot;&gt;Val Town&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-app&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-app&quot;&gt;The App&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Alright, so the app I had in mind was fairly simple:&lt;/p&gt;
&lt;p&gt;Every X minutes, use the RainDrop API to check the rain intensity and if it is over a certain threshold, send an alert my way.&lt;/p&gt;
&lt;p&gt;My RainDrop API access had pretty high usage limits (120 a minute) that were above what made sense to realistically check. On the other hand, my free tier level at Val Town capped me at one execution per fifteen minutes.&lt;/p&gt;
&lt;p&gt;To be honest, once every fifteen minutes seemed perfectly reasonable, but keep in mind that both RainDrop and Val Town would support much higher frequencies if necessary.&lt;/p&gt;
&lt;p&gt;Given the above, I created a new val and set the trigger to Cron, set to every 15 minutes:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/raindrop2.png&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot from Val Town showing the Cron settings&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I then created an environment variable for my RainDrop key. With that in place, the entire &amp;quot;early warning flood system&amp;quot; (that&apos;s what I&apos;ll call it when doing interviews for my next job) is one file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { email } from &amp;quot;https://esm.town/v/std/email&amp;quot;;

/**
 * Converts speed from millimeters per hour to inches per hour.
 * @param {number} mmPerHour - Speed in millimeters per hour.
 * @returns {number} Speed in inches per hour.
 */
function mmPerHourToInchesPerHour(mmPerHour) {
  return mmPerHour / 25.4;
}

function dtFormat(timestamp) {
  const date = new Date(timestamp);

  const options = {
    weekday: &amp;quot;long&amp;quot;,
    year: &amp;quot;numeric&amp;quot;,
    month: &amp;quot;long&amp;quot;,
    day: &amp;quot;numeric&amp;quot;,
    hour: &amp;quot;2-digit&amp;quot;,
    minute: &amp;quot;2-digit&amp;quot;,
    timeZoneName: &amp;quot;short&amp;quot;,
  };

  // Create the formatter (e.g., for US English)
  const formatter = new Intl.DateTimeFormat(&amp;quot;en-US&amp;quot;, options);

  return formatter.format(date);
}

export default async function (interval: Interval) {
  // Lafayette, LA
  const lat = &amp;quot;30.216667&amp;quot;;
  const lng = &amp;quot;-92.033333&amp;quot;;

  // The threshold value is inches of rain per hour - any value &amp;gt;= will trigger a warning
  const threshold = 0.5;

  let key = Deno.env.get(&amp;quot;API_KEY&amp;quot;);
  let precipReq = await fetch(
    `https://api.raindrop.farm/v1/precipitation/current?lat=${lat}&amp;amp;lon=${lng}`,
    {
      headers: {
        &amp;quot;Authorization&amp;quot;: key,
      },
    },
  );

  let precip = await precipReq.json();
  let rainInches = mmPerHourToInchesPerHour(precip.precipitationIntensity);
  console.log(&amp;quot;rainInches&amp;quot;, rainInches);
  console.log(precip);

  if (rainInches &amp;gt;= threshold) {
    let html = `
&amp;lt;h2&amp;gt;Heavy Rain Alert&amp;lt;/h2&amp;gt;

&amp;lt;p&amp;gt;
As of ${
      dtFormat(precip.timestamp)
    }, we have detected ${rainInches.toFixed(2)} inches of rain per hour.
&amp;lt;/p&amp;gt;
    `;

    await email({
      subject: &amp;quot;Rain Alert&amp;quot;,
      html,
    });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On top of my script are two utility functions with the interesting one being the simple wrapper to convert RainDrop&apos;s values to inches.&lt;/p&gt;
&lt;p&gt;After that, I hit the API, check against a threshold (currently set to half an inch), and if it matches, I fire off an email. As a reminder, I&apos;m using Val Town&apos;s built in &amp;quot;mail the owner&amp;quot; feature. I could also use a proper email API to have more control over the delivery, but this works well for now.&lt;/p&gt;
&lt;p&gt;And of course, as soon as I built this there were no events, which is &lt;em&gt;good&lt;/em&gt; I suppose, but I did a quick tweak to force it to email and here&apos;s an example. Not terribly thrilling with 0 inches:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/raindrop3.png&quot; loading=&quot;lazy&quot; alt=&quot;Email example&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;If you want to play with this, you can fork the val here: https://www.val.town/x/raymondcamden/raindropAlert. And of course if you do, let me know!&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@r_shayesrehpour?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;reza shayestehpour&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/grayscale-photography-of-raindrops-Nw_D8v79PM4?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="serverless" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/27/looking-for-my-next-role-hire-me</id>
                <title>Looking for My Next Role - Hire Me</title>
                <updated>2026-05-27T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/27/looking-for-my-next-role-hire-me" rel="alternate" type="text/html" title="Looking for My Next Role - Hire Me"/>
                <content type="html">
				
                        &lt;p&gt;Yep, I get to write this post again...&lt;/p&gt;
&lt;p&gt;Well, today I find myself in the same position I&apos;ve been a few times over the past few years. Webflow went through a company-wide restructuring today and as part of that re-org, my role (and many others) were eliminated. So once again - I&apos;m asking my faithful readers to help me find my next role. You can find my &lt;a href=&quot;/resume&quot;&gt;resume&lt;/a&gt; if you need the latest edition and feel free to contact me with any opportunities you may have!&lt;/p&gt;

                        
                
				</content>

                
                
                <category term="uncategorized" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/20/dang-good-office-parsing-on-the-web-with-officeparser</id>
                <title>Dang good Office parsing on the web with officeParser</title>
                <updated>2026-05-20T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/20/dang-good-office-parsing-on-the-web-with-officeparser" rel="alternate" type="text/html" title="Dang good Office parsing on the web with officeParser"/>
                <content type="html">
				
                        &lt;p&gt;A few weeks ago I wrote about using Chrome&apos;s built-in AI support to summarize documents - &lt;a href=&quot;https://www.raymondcamden.com/2026/04/17/summarizing-docs-with-built-in-ai&quot;&gt;&amp;quot;Summarizing Docs with Built-in AI&amp;quot;&lt;/a&gt;. This was a followup on an earlier post that was PDF only and made use of an excellent library, &lt;a href=&quot;https://officeparser.harshankur.com/&quot;&gt;officeParser&lt;/a&gt;, to work with Microsoft Office files. This library worked well, but had one issue that made it a bit harder to use.&lt;/p&gt;
&lt;p&gt;Parsing a doc itself was super easy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const getAST = async (file, config) =&amp;gt; (await OfficeParser.parseOffice(file, config));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the issue I ran into was taking that result and turning it into something meaningful for Chrome&apos;s model to analyze. PDFs supported a &lt;code&gt;toText()&lt;/code&gt; method but for other formats I had to do a bit of work to get a text value. For example, here&apos;s the code I used to turn an Excel file into CSV:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let arrayBuffer = await file.arrayBuffer();
let data = await getAST(arrayBuffer, {});

const getCSV = s =&amp;gt; {
    let result = &apos;&apos;;
    let rows = s.children.filter(c =&amp;gt; c.type === &apos;row&apos;);
    console.log(rows[0]);
    rows.forEach(r =&amp;gt; {
        let data = [];
        let cells = r.children.filter(c =&amp;gt; c.type === &apos;cell&apos;);
        cells.forEach(c =&amp;gt; data.push(c.text));
        result += data.join(&apos;, &apos;) + &apos;\n&apos;;

    });
    return result;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yeah that&apos;s not bad, but it did mean a bit of work for each of the different Office types.&lt;/p&gt;
&lt;p&gt;Well, the good news is Harsh Ankur (the author of this awesome library) released a &lt;a href=&quot;https://github.com/harshankur/officeParser/blob/master/CHANGELOG.md&quot;&gt;v7&lt;/a&gt; that added a &lt;code&gt;to&lt;/code&gt; method that supports converting files to CSV, Markdown, RTF, PDF, Text, and HTML. Even better, his HTML support isn&apos;t just &amp;quot;convert to Markdown then render the Markdown to html&amp;quot;, but proper support straight from the parsed doc to create a richer result.&lt;/p&gt;
&lt;p&gt;I went ahead and built a demo you can use to test right now (and this demo doesn&apos;t use any of the Chrome AI stuff). I created a simple HTML file with a file field and 4 tabs: Text, Markdown, HTML, CSV. You can select a PDF or Office file and it will render into each of the tabs (well, only Excel will use CSV). The HTML isn&apos;t interesting, but here&apos;s the JavaScript:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { OfficeParser } from &apos;https://esm.sh/officeparser&apos;;

let $sourceFile;
let $textResult, $mdResult, $htmlResult, $csvResult;

const supportedFiles = [&apos;pdf&apos;,&apos;xlsx&apos;,&apos;docx&apos;,&apos;pptx&apos;];

document.addEventListener(&apos;DOMContentLoaded&apos;, init, false);
async function init() {
	console.log(&apos;lets go&apos;);
	$sourceFile = document.querySelector(&apos;#sourceFile&apos;);
	$textResult = document.querySelector(&apos;#textResult&apos;);
	$mdResult = document.querySelector(&apos;#mdResult&apos;);
	$htmlResult = document.querySelector(&apos;#htmlResult&apos;);
	$csvResult = document.querySelector(&apos;#csvResult&apos;);

	$sourceFile.addEventListener(&apos;change&apos;, filePreview, false);
}

async function filePreview(e) {
	const file = e.target.files[0];
	if (!file) return;
	let ext = file.name.split(&apos;.&apos;).pop();
	if(!supportedFiles.includes(ext)) return;
	let arrayBuffer = await file.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	
	let text = await data.to(&apos;text&apos;);
	$textResult.innerText = text.value;
	
	let md = await data.to(&apos;md&apos;);
	$mdResult.innerText = md.value;
	
	let html = await data.to(&apos;html&apos;);
	$htmlResult.contentDocument.open();
	$htmlResult.contentDocument.write(html.value);
	$htmlResult.contentDocument.close();

	if(ext === &apos;xlsx&apos;) {
		$csvResult.innerText = (await data.to(&apos;csv&apos;)).value;
	} else $csvResult.innerText = &apos;Only used for Excel files.&apos;;
}

const getAST = async (file, config) =&amp;gt; (await OfficeParser.parseOffice(file, config));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can play with this below:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;600&quot; data-pen-title=&quot;officeParser demo&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;NPbpNqp&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019e3d03-91b9-7e58-a88f-b730b7371377&quot;&gt;
  officeParser demo&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;p&gt;So - given that - I took a look at my previous &lt;a href=&quot;https://codepen.io/cfjedimaster/pen/MYjxbrv&quot;&gt;demo&lt;/a&gt; and modified the code to make use of these new features. Here&apos;s the new code that gets the text version of a document and passes it to the AI model:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;document.querySelector(&apos;#upload&apos;).addEventListener(&apos;change&apos;, async (e) =&amp;gt; {
    $output.innerHTML = &apos;&apos;;
    const file = e.target.files[0];
    if (!file) return;
    
    let ast = await getAST(await file.arrayBuffer());

    let summary = {
        title: ast.metadata?.title ?? &apos;No Title&apos;,
        text: (await ast.to(&apos;md&apos;)).value
    }

    doSummary(summary);

});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compared to the previous version, this one is over a hundred lines shorter. As I said... ddddaaaaannnnnngggg. You can try out this version below, but note that it is Chrome only. (If folks ask, or heck, even if you don&apos;t ask, I may build a Transformer.js version.)&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;600&quot; data-pen-title=&quot;Chrome AI, Doc Summaries (V3)&quot; data-preview=&quot;true&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;PwbpLJQ&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/cfjedimaster/pen/PwbpLJQ&quot;&gt;
  Chrome AI, Doc Summaries (V3)&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/19/my-first-ai-skill-for-my-blog</id>
                <title>My First AI Skill for My Blog</title>
                <updated>2026-05-19T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/19/my-first-ai-skill-for-my-blog" rel="alternate" type="text/html" title="My First AI Skill for My Blog"/>
                <content type="html">
				
                        &lt;p&gt;I&apos;ve been a professional writer now for thirty plus years, and honestly, it&apos;s one of the things I&apos;m most proud about. When generative AI first exploded on the scene, a lot of people used it to help them write, and frankly, that wasn&apos;t for me. I&apos;m not the best writer, but I damn well know how to write and damn well know my own voice. That being said, I&apos;ve been really interested in how GenAI can &lt;em&gt;help&lt;/em&gt; with the process.&lt;/p&gt;
&lt;p&gt;I first wrote about this over two years ago: &lt;a href=&quot;https://www.raymondcamden.com/2024/02/02/using-generative-ai-as-your-content-assistant&quot;&gt;Using Generative AI as Your Content Assistant&lt;/a&gt;. In that post I talked about using GenAI for two very specific tasks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Helping with my titles&lt;/li&gt;
&lt;li&gt;Writing the description (which is part of the metadata for a post)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Honestly, I built those tools as proof of concept implementations and don&apos;t think I used them ever again. But lately it&apos;s been on my mind that I should think about those tools and see if there would possibly be an easier way to use GenAI to help me with my content. Specifically I&apos;m looking for two things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spelling and grammar. I&apos;ve got a Visual Studio Code extension for that but it&apos;s not perfect and I also just miss it sometimes.&lt;/li&gt;
&lt;li&gt;And here&apos;s the big one. Sometimes I&apos;ll write about a topic and simply forget to cover something. I&apos;d love to use my AI tool to try to find those missing aspects and let me know. Maybe I won&apos;t care. Maybe I &lt;em&gt;intentionally&lt;/em&gt; skipped something. That being said, I&apos;d still like to know.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You&apos;ll notice that none of the above involves AI actually &lt;em&gt;writing&lt;/em&gt; for me. Outside of correcting spelling mistakes I made, I don&apos;t want to use my tool for that. But both of these could really help me catch things before I publish and just make the final result a little bit better.&lt;/p&gt;
&lt;h2 id=&quot;the-skill&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-skill&quot;&gt;The Skill&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To build this, I made use of &lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview&quot;&gt;Claude Skills&lt;/a&gt;. Skills are persistent instructions that can be used across your entire environment or per project. They&apos;re written in simple Markdown and are stored in a particular folder depending on your use. I actually cheated a bit and used Claude itself to help me create the skill.&lt;/p&gt;
&lt;p&gt;I knew about skills, knew they needed to be in a particular folder, but rather than looking it up I simply asked Claude to help me create a skill based on the needs above. It did an admirable job but I took over and tweaked things a bit.&lt;/p&gt;
&lt;p&gt;The directory is in my repository now, at &lt;code&gt;.claude/skills/prepublish&lt;/code&gt;. Here&apos;s what it looks like now (I say &apos;now&apos; as I expect to be tweaking this over time - you can also check out the &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023&quot;&gt;repo&lt;/a&gt; to see the latest!):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: prepublish
description: Review a new blog post before publishing. Finds the latest post by its dated path under `src/posts/`, then spell-checks and offers content suggestions — without touching tone.
---

# Prepublish blog post review

Run this right before pushing a new post. Blog posts live in `src/posts/YYYY/MM/DD/` as `.md` files.

## Step 1 — Find the post

Do NOT use git to figure out which post is newest. Use the dated path under `src/posts/` — sort highest year → month → day and pick the most recent `.md` file.

A reliable command: `find src/posts -type f -name &apos;*.md&apos; | sort -r | head -5`. The first entry is the latest post; the rest are there in case there&apos;s a tie or the user wants to pick another.

If multiple `.md` files share the newest date directory, ask the user which one. Otherwise, confirm in one line (&amp;quot;Reviewing `path/to/post.md` — sound right?&amp;quot;) before proceeding.

## Step 2 — Spell check

Read the post and surface misspellings. Ignore code blocks, fenced code, inline `code`, URLs, and proper nouns the user clearly intended (product names, people, libraries).

Present findings as a short list: `word → suggestion (line N)`. Don&apos;t auto-edit. Ask which corrections to apply, then apply them with Edit.

## Step 3 — Content review

Read the post end-to-end and give feedback focused on substance, not tone:

- **Things that sound stupid or unclear** — sentences that don&apos;t land, claims that need backing, awkward logic jumps.
- **Gaps worth filling** — missing context a reader would want, an example that would help, a link the user usually includes (docs, repo, related post).
- **Factual / technical accuracy** — flag anything that looks wrong or outdated. If the post references code, check it makes sense.
- **Loose ends** — TODO markers, &amp;quot;fill this in&amp;quot;, broken-looking links, placeholder text.

**Do NOT** comment on tone, voice, snark level, casual phrasing, or stylistic choices. The user likes their voice; leave it alone.

Present feedback as a numbered list grouped by category. Keep each item to one or two sentences. Don&apos;t rewrite paragraphs — point at the issue and let the user decide.

## Step 4 — Apply changes

Ask which suggestions to act on. Apply only what&apos;s confirmed. Then stop — the user handles the actual publish/push.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;an-example-in-action&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#an-example-in-action&quot;&gt;An Example in Action&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To test, I&apos;m going to run this right now on this post, which means it may get a bit confused as the post isn&apos;t actually done, but let&apos;s just see what happens:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/claude1.png&quot; loading=&quot;lazy&quot; alt=&quot;Results&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Hopefully that image is readable, let me know if not. But it did a great job of finding multiple grammar issues. My VS Code extension doesn&apos;t handle that (obviously).&lt;/p&gt;
&lt;p&gt;I disagreed/didn&apos;t care about the awkward comment. I did care about linking to Claude Skills so I added a link. Gap 3 was incorrect. I do talk about how I &apos;cheated&apos; (to be clear, it isn&apos;t cheating) in the following paragraph. And of course, issue 5 is simply because I ran this before I was done. Item 8 is the same.&lt;/p&gt;
&lt;p&gt;You&apos;ll notice it also offered to correct those mistakes, but I did them myself and I imagine I&apos;ll do so usually.&lt;/p&gt;
&lt;p&gt;In the end, these are all things a good editor would handle, but this is my personal blog and unfortunately I don&apos;t have an editor on call. (And even so, I&apos;d want to do this kind of check beforehand anyway.)&lt;/p&gt;
&lt;h2 id=&quot;what-next%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-next%3F&quot;&gt;What next?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Looking at the skill I wrote, and recognizing my own weaknesses (grammar, forgetting to cover things I should), I may actually move this skill into my global directory so I can use it for my non-blog writing as well. Anybody want to chime in and share if they&apos;ve done something similar?&lt;/p&gt;
&lt;p&gt;p.s. I ran the skill one more time - here&apos;s the latest:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/claude1.png&quot; loading=&quot;lazy&quot; alt=&quot;Final Results&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@ningdamao?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;宁 宁&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-cat-peacefully-sits-on-a-stack-of-papers-xDDkC_odjbU?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/17/links-for-you-51726</id>
                <title>Links For You (5/17/26)</title>
                <updated>2026-05-17T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/17/links-for-you-51726" rel="alternate" type="text/html" title="Links For You (5/17/26)"/>
                <content type="html">
				
                        &lt;p&gt;Happy Sunday, and I hope your Sunday is going better than mine. One of my kids just went to use the dryer and sparks flew. I don&apos;t mean metaphorically. Nothing like the thought of replacing another major appliance to brighten up your day, amiright?!?!&lt;/p&gt;
&lt;h2 id=&quot;find-your-(tech)-community&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#find-your-(tech)-community&quot;&gt;Find Your (Tech) Community&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is a new endeavour by Brian Rinaldi to help fill the gap left behind by Meetup.com (which is still a thing just not a good thing lately) and the lack of smaller and more affordable community conferences. &lt;a href=&quot;https://devrelish.tech/&quot;&gt;DevRel(ish)&lt;/a&gt; is a community site supporting tech groups of all sizes and nature who need help organizing IRL meetups. According to the &lt;a href=&quot;https://remotesynthesis.com/posts/introducting-devrelish/&quot;&gt;launch announcement&lt;/a&gt;, this is not meant to be a replacement for larger event apps, but hopefully something more suitable for smaller groups.&lt;/p&gt;
&lt;p&gt;Check it out at &lt;a href=&quot;https://devrelish.tech/&quot;&gt;https://devrelish.tech/&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;blame-the-ox&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#blame-the-ox&quot;&gt;Blame the Ox&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, this isn&apos;t tech related at all, but, &amp;quot;The Ox That&apos;s Breaking Your Fantasy Map&amp;quot; is one of the most fascinating videos I&apos;ve seen in a while. And yes, it is about oxen - and fantasy maps. Even if you don&apos;t like fantasy (or oxen), it&apos;s a great watch and reminder of just how the economy affects organization and government.&lt;/p&gt;
&lt;lite-youtube videoid=&quot;MIqpvpNS5pI&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/MIqpvpNS5pI/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=MIqpvpNS5pI&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;h2 id=&quot;ai-and-goblins&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#ai-and-goblins&quot;&gt;AI and Goblins&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Last up is a good look into why &lt;a href=&quot;https://openai.com/index/where-the-goblins-came-from/&quot;&gt;goblins started appearing&lt;/a&gt; in OpenAI models. The post details how initial reports of the behavior led to investigations that ultimately led to the smoking &lt;strike&gt;goblin&lt;/strike&gt; gun.&lt;/p&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Speaking of fantasy, I recently remembered that my young kids still have yet to see the Never Ending Story. One of them really loves horses too. This is going to be great!&lt;/p&gt;
&lt;lite-youtube videoid=&quot;2WN0T-Ee3q4&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/2WN0T-Ee3q4/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=2WN0T-Ee3q4&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/15/is-it-hotter-or-colder-this-year</id>
                <title>Is it hotter or colder this year?</title>
                <updated>2026-05-15T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/15/is-it-hotter-or-colder-this-year" rel="alternate" type="text/html" title="Is it hotter or colder this year?"/>
                <content type="html">
				
                        &lt;p&gt;Where I live could generously be called &amp;quot;warm&amp;quot;, but is usually closer to the surface of the sun, especially in late summer. That&apos;s why when the weather is &lt;em&gt;not&lt;/em&gt; oppressively hot, I try my best to enjoy it. We&apos;re mid-May now and honestly, this spring has been... pleasant. Suspiciously pleasant but I&apos;ll take what I can get.&lt;/p&gt;
&lt;p&gt;The last few weeks I&apos;ve been telling myself that the weather must be a good bit cooler than last year, and I finally decided to do something about it. I worked with Claude and created a little web app that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lets you enter a free form address and then use &lt;a href=&quot;https://www.geocod.io/&quot;&gt;Geocoding&lt;/a&gt; to convert it to a proper longitude and latitude. This is a super simple geocoding API with a generous free tier. Do note though it&apos;s North America only.&lt;/li&gt;
&lt;li&gt;Uses the &lt;a href=&quot;https://pirateweather.net/en/latest/&quot;&gt;Pirate Weather&lt;/a&gt; API to get historical weather information. Date wise, I&apos;m using this week, and then the same days over the previous four days.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As I mentioned, I worked with Claude on this and let it design the layout and write the code initially. I was kinda impressed by one part - the &lt;code&gt;mapWithConcurrency&lt;/code&gt; function that lets you pass an array of async function with a desired max number to run at once. It handles doing the batching and returning the final result. That makes the calls for the weather data a bit more gentle on the provider.&lt;/p&gt;
&lt;p&gt;However - I noticed it was taking a &lt;em&gt;long&lt;/em&gt; time to finish. In theory, I&apos;m doing 7 times 5 (this week plus four previous years) of calls which is 35 which doesn&apos;t &lt;em&gt;seem&lt;/em&gt; like a lot, but I did some digging. Claude had used an endpoint that was a bit old. Doing some more research I switched to the proper endpoint... which didn&apos;t support CORS.&lt;/p&gt;
&lt;p&gt;Oh no!&lt;/p&gt;
&lt;p&gt;Oh - actually - I just moved to this to &lt;a href=&quot;https://val.town&quot;&gt;val.town&lt;/a&gt; and built a quick server side proxy. It takes in the same arguments that&apos;s send to my client side code (lat, lng, and a timestamp), and passes it to the historical Pirate Weather endpoint. So here&apos;s the frontend code - again - this is being driven by a concurrency function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function fetchDayTemperatures(lat, lng, unixSeconds) {
  let req = await fetch(PIRATE_WEATHER_API_BASE, {
    method: &amp;quot;POST&amp;quot;,
    body: JSON.stringify({
      lat,
      lng,
      unixSeconds,
    }),
  });
  return await req.json();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here&apos;s the backend code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();
  const key = Deno.env.get(&amp;quot;PIRATE&amp;quot;);

  const url = new URL(
    `https://timemachine.pirateweather.net/forecast/${key}/${body.lat},${body.lng},${body.unixSeconds}`,
  );
  url.searchParams.set(&amp;quot;units&amp;quot;, &amp;quot;us&amp;quot;);
  url.searchParams.set(&amp;quot;exclude&amp;quot;, &amp;quot;minutely,hourly,alerts,flags&amp;quot;);

  const response = await fetch(url);
  const payload = await response.json();

  if (!response.ok) {
    throw new Error(payload.error || &amp;quot;Weather lookup failed.&amp;quot;);
  }

  const day = payload.daily?.data?.[0];
  if (!day) {
    throw new Error(&amp;quot;Weather data did not include a daily summary.&amp;quot;);
  }

  return Response.json({
    timezone: payload.timezone,
    high: day.temperatureMax,
    low: day.temperatureMin,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I barely modified this from the original client side code - only switching to an environment variable for the API (their API is free, but I might as well) and returning a proper &lt;code&gt;Response&lt;/code&gt; object.&lt;/p&gt;
&lt;p&gt;Here&apos;s a screenshot of it in action:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/weather.png&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;You can try this yourself here: &lt;a href=&quot;https://weathercomparison.val.run/&quot;&gt;https://weathercomparison.val.run/&lt;/a&gt;. If you want to see the code, and possibly fork the val, you can do so here: &lt;a href=&quot;https://www.val.town/x/raymondcamden/weather-comparison&quot;&gt;https://www.val.town/x/raymondcamden/weather-comparison&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;so%2C-was-it-cooler%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#so%2C-was-it-cooler%3F&quot;&gt;So, was it cooler?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;No. I was wrong. Last year was cooler than this year, but the three years before that were all higher, with 2022 being pure hell. I remember that year seeing the city working on a road that had literally buckled because of heat.&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@peteralbanese?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Peter Albanese&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/black-and-white-cat-on-glass-window-K9_Igf8ZpC0?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/14/send-me-a-message-in-a-panel</id>
                <title>Send me a message in a panel...</title>
                <updated>2026-05-14T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/14/send-me-a-message-in-a-panel" rel="alternate" type="text/html" title="Send me a message in a panel..."/>
                <content type="html">
				
                        &lt;p&gt;On my birthday a few weeks ago, one of things I got was something I&apos;ve wanted to play with for a while, the &lt;a href=&quot;https://divoom.com/products/pixoo-64&quot;&gt;Divoom Pixoo64&lt;/a&gt; pixel frame. This is pixel art frame you can hang on your wall and with an app, select art, clock faces, and more. It&apos;s fun, although the app itself isn&apos;t my favorite. But - what excites me is that it has an API you can use to change what&apos;s shown on the frame. I actually built a demo of this with Webflow you can see below:&lt;/p&gt;
&lt;lite-youtube videoid=&quot;oRyVxxi6ew8&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/oRyVxxi6ew8/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=oRyVxxi6ew8&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;p&gt;I was thinking about how else I could play with the API and decided to do something a bit risky - build a tool that lets you (yes, you!) send me a message right to my device. How did I do it? Let me describe the process from the bottom up.&lt;/p&gt;
&lt;h2 id=&quot;the-python-server&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-python-server&quot;&gt;The Python Server&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;At the lowest level is a Python server running on my machine. Yes, this isn&apos;t persistent and not stable, but who cares. The server handles accepting a string to display and rendering it on the Pixoo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from pixoo_ng import Pixoo, Channel
from pixoo_ng.config import PixooConfig
import time

def split_string(text: str, max_chars: int) -&amp;gt; list[str]:
    &amp;quot;&amp;quot;&amp;quot;Split a string into chunks of up to max_chars characters, without breaking words.&amp;quot;&amp;quot;&amp;quot;
    words = text.split()
    chunks = []
    current = &amp;quot;&amp;quot;

    for word in words:
        if not current:
            current = word
        elif len(current) + 1 + len(word) &amp;lt;= max_chars:
            current += &amp;quot; &amp;quot; + word
        else:
            chunks.append(current)
            current = word

    if current:
        chunks.append(current)

    return chunks

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == &apos;/favicon.ico&apos;:
            self.send_response(404)
            self.end_headers()
            return

        query = parse_qs(urlparse(self.path).query)
        input_text = (query.get(&apos;input&apos;, [&apos;&apos;])[0]).strip()

        if not input_text:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b&amp;quot;Missing &apos;input&apos; query parameter&amp;quot;)
            return

        pix = Pixoo(PixooConfig(address=&apos;192.168.0.191&apos;))

        startY = 5
        strings = split_string(input_text, 15)

        for line in strings[:8]:
            print(line, startY)
            pix.draw_text(line, (2, startY), (0, 255, 0))
            startY += 5

        pix.push()

        self.send_response(204)
        self.end_headers()
        self.wfile.flush()

        # Show it for 10 seconds...
        time.sleep(10)

        # Then go back to my regular face
        pix.set_channel(Channel.FACES)
        pix.set_face(0)

    def log_message(self, format, *args):
        print(f&amp;quot;{self.address_string()} - {format % args}&amp;quot;)


if __name__ == &amp;quot;__main__&amp;quot;:
    server = ThreadingHTTPServer((&amp;quot;&amp;quot;, 8099), Handler)
    print(&amp;quot;Listening on port 8099&amp;quot;)
    server.serve_forever()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important bits is how it handles dynamic strings. When you send text to the Pixoo, you have to handle ensuring it actually fits, so to do that, I used a function, &lt;code&gt;split_string&lt;/code&gt;, which wraps text on words into an array. I then take that array (up to 7 lines) and send each line progressively lower on the panel.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/pix2.png&quot; loading=&quot;lazy&quot; alt=&quot;Pixoo device&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;To make this little server &amp;quot;live&amp;quot;, I simply used ngrok to expose it.&lt;/p&gt;
&lt;h2 id=&quot;calling-the-server&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#calling-the-server&quot;&gt;Calling the Server&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To call the server, I set up an API route on &lt;a href=&quot;val.town&quot;&gt;https://val.town&lt;/a&gt; that does the minimal work of proxying a front end call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();

  //trim message to 80
  let msg = body.message.substring(0, 80);
  let API = Deno.env.get(&amp;quot;API&amp;quot;);
  console.log(`Sending ${msg}`);
  await fetch(API + `?input=${msg}`);

  return Response.json({ ok: true });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ngrok URL is an environment variable and as I don&apos;t care about the response, I return a basic boolean value back.&lt;/p&gt;
&lt;h2 id=&quot;the-front-end&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-front-end&quot;&gt;The Front End&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;And lastly, I used Claude to build a simple front end. It&apos;s just a form with a field and button:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/pix3.png&quot; loading=&quot;lazy&quot; alt=&quot;Pixoo device&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The JavaScript literally just takes the field and sends it to the server code above, but if you want to see the complete project, you can check it out on val.town: &lt;a href=&quot;https://www.val.town/x/raymondcamden/send-me-a-message&quot;&gt;https://www.val.town/x/raymondcamden/send-me-a-message&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Want to try it? I&apos;ll share the URL, but first...&lt;/p&gt;
&lt;h2 id=&quot;privilege&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#privilege&quot;&gt;Privilege&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I feel safe sharing this demo as my device is in my office, a room my kids don&apos;t go into, and I can shut it down in seconds. Heck, I&apos;ll probably forget it&apos;s running and next time I boot this machine, it won&apos;t run anyway. So yeah, no big deal.&lt;/p&gt;
&lt;p&gt;But also - I&apos;m a guy. Probably the worst thing I&apos;ll get is someone calling me foul names.&lt;/p&gt;
&lt;p&gt;I can&apos;t imagine there is any world where a woman would feel safe with this kind of connection to random people on the internet.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#try-it&quot;&gt;Try It&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ready to try it? I make no guarantees it will work, but then again, you&apos;ll never know. ;) Hit the form here: &lt;a href=&quot;https://raymondcamden--45cb2f404fa111f1b5dcee650bb23af1.web.val.run/&quot;&gt;https://raymondcamden--45cb2f404fa111f1b5dcee650bb23af1.web.val.run/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@jayneharr33?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Jayne Harris&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-message-in-a-bottle-sitting-on-the-beach-EDTXcRCCVGk?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="python" />
                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/13/what-was-that-song-the-one-with-the-words</id>
                <title>What was that song, the one with the words?</title>
                <updated>2026-05-13T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/13/what-was-that-song-the-one-with-the-words" rel="alternate" type="text/html" title="What was that song, the one with the words?"/>
                <content type="html">
				
                        &lt;p&gt;My wife and I are both big music lovers, and I&apos;m happy to have influenced her listening habits a bit and have loved what she&apos;s introduced me to. Given we both love music, we&apos;ve also been known to sing along at times. (You can take a guess as to how well that goes.) She normally gets the lyrics right. I&apos;m normally a bit more... loose in terms of how well I remember the lyrics. I was thinking about this and was curious how well AI could be used to identity lyrics and match them to a song, especially when the lyrics may not be exactly right. I spent some time hacking on it and here&apos;s what I built.&lt;/p&gt;
&lt;h2 id=&quot;strike-one&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#strike-one&quot;&gt;Strike One&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I meant to type take one, accidentally wrote strike one, and as it didn&apos;t work, I&apos;m keeping the title. ;) So my first attempt was rather simple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&quot;&gt;SpeechRecognition&lt;/a&gt; API to get a transcript while you talk or sing.&lt;/li&gt;
&lt;li&gt;Use Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/built-in&quot;&gt;Built-in AI&lt;/a&gt; to identify the song based on the lyrics.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Obviously Chrome&apos;s model would be date limited and wouldn&apos;t be able to pick up a recent song, but I figured I&apos;d give that a shot first.&lt;/p&gt;
&lt;p&gt;The SpeechRecognition API works &lt;em&gt;real&lt;/em&gt; well, but one issue I ran into was on my mobile browser. For some reason the transcription would show up twice. I was working with Cursor to build the demo and it was able to handle that issue well.&lt;/p&gt;
&lt;p&gt;Speaking of Cursor, it built the UI for me and honestly I think it did a great job:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/voice1.png&quot; loading=&quot;lazy&quot; alt=&quot;Email of NFL News&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The code isn&apos;t too terribly long, so I&apos;ll share the whole thing then call out important bits:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const SpeechRecognition =
  window.SpeechRecognition || window.webkitSpeechRecognition;

const isMobileSpeech =
  /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);

let recordBtn;
let transcriptEl;
let statusPanel;
let statusMessage;
let resultPanel;
let songTitleEl;
let songArtistEl;

let recognition = null;
let isRecording = false;
let finalTranscript = &amp;quot;&amp;quot;;
let displayTranscript = &amp;quot;&amp;quot;;

let session = null;

const schema = {
	type:&amp;quot;object&amp;quot;, 
	properties: {
		song: {
			type:&amp;quot;string&amp;quot;,
			description:&amp;quot;The song name.&amp;quot;
		},
		artist: {
			type:&amp;quot;string&amp;quot;,
			description:&amp;quot;The artist name.&amp;quot;
		}
	},
	required: [&amp;quot;song&amp;quot;, &amp;quot;artist&amp;quot;],
	additionalProperties: false
};

async function canDoIt() {
  if (!window.LanguageModel) {
    return false;
  }

  return (await LanguageModel.availability()) !== &amp;quot;unavailable&amp;quot;;
}

function showUnsupportedMessage() {
  const notice = document.createElement(&amp;quot;p&amp;quot;);
  notice.className = &amp;quot;intro&amp;quot;;
  notice.textContent =
    &amp;quot;Sorry, your browser doesn&apos;t support this. This must be run on Chrome 148 or later.&amp;quot;;
  document.querySelector(&amp;quot;.app&amp;quot;)?.append(notice);
}

function setRecordingUi(recording) {
  isRecording = recording;
  recordBtn.classList.toggle(&amp;quot;is-recording&amp;quot;, recording);
  recordBtn.setAttribute(&amp;quot;aria-pressed&amp;quot;, String(recording));

  const icon = recordBtn.querySelector(&amp;quot;.record-btn__icon&amp;quot;);
  const label = recordBtn.querySelector(&amp;quot;.record-btn__label&amp;quot;);

  icon.classList.toggle(&amp;quot;record-btn__icon--mic&amp;quot;, !recording);
  icon.classList.toggle(&amp;quot;record-btn__icon--stop&amp;quot;, recording);
  label.textContent = recording ? &amp;quot;Stop&amp;quot; : &amp;quot;Start recording&amp;quot;;
}

function resetOutput() {
  statusPanel.hidden = true;
  resultPanel.hidden = true;
  statusMessage.textContent = &amp;quot;&amp;quot;;
  songTitleEl.textContent = &amp;quot;&amp;quot;;
  songArtistEl.textContent = &amp;quot;&amp;quot;;
}

function transcriptFromResults(results) {
  let interimTranscript = &amp;quot;&amp;quot;;
  let finalPart = &amp;quot;&amp;quot;;

  for (let i = 0; i &amp;lt; results.length; i += 1) {
    const result = results[i];
    const text = result[0].transcript;

    if (result.isFinal) {
      finalPart += text;
    } else {
      interimTranscript += text;
    }
  }

  return {
    display: `${finalPart}${interimTranscript}`.trim(),
    final: finalPart.trim(),
  };
}

function startRecording() {
  if (!SpeechRecognition) {
    transcriptEl.textContent =
      &amp;quot;Speech recognition is not supported in this browser. Try Chrome.&amp;quot;;
    return;
  }

  resetOutput();
  finalTranscript = &amp;quot;&amp;quot;;
  displayTranscript = &amp;quot;&amp;quot;;
  transcriptEl.textContent = &amp;quot;Listening...&amp;quot;;

  recognition = new SpeechRecognition();
  recognition.lang = &amp;quot;en-US&amp;quot;;
  recognition.interimResults = true;
  recognition.continuous = !isMobileSpeech;

  recognition.onresult = (event) =&amp;gt; {
    if (isMobileSpeech) {
      const transcript = transcriptFromResults(event.results);
      displayTranscript = transcript.display;
      finalTranscript = transcript.final;
    } else {
      let interimTranscript = &amp;quot;&amp;quot;;

      for (let i = event.resultIndex; i &amp;lt; event.results.length; i += 1) {
        const result = event.results[i];
        const text = result[0].transcript;

        if (result.isFinal) {
          finalTranscript += `${text} `;
        } else {
          interimTranscript += text;
        }
      }

      displayTranscript = `${finalTranscript}${interimTranscript}`.trim();
    }

    transcriptEl.textContent = displayTranscript || &amp;quot;Listening...&amp;quot;;
  };

  recognition.onerror = (event) =&amp;gt; {
    transcriptEl.textContent = `Recognition error: ${event.error}`;
    stopRecording();
  };

  recognition.onend = () =&amp;gt; {
    if (isRecording &amp;amp;&amp;amp; recognition.continuous) {
      recognition.start();
    }
  };

  recognition.start();
  setRecordingUi(true);
}

function stopRecording() {
  if (!recognition) {
    return;
  }

  setRecordingUi(false);
  recognition.onend = null;
  recognition.stop();
  recognition = null;

  const transcript = displayTranscript.trim() || finalTranscript.trim();
  if (transcript) {
    transcriptEl.textContent = transcript;
  }
  recognize(transcript);
}

async function recognize(transcript) {
  resetOutput();
  statusPanel.hidden = false;
  statusMessage.textContent = &amp;quot;Analyzing your dulcet tones...&amp;quot;;

  if(!session) {
		session = await LanguageModel.create({
			initialInputs: [
        { 
						role: &apos;system&apos;, 
						content: 
							&apos;You are a song id bot. Given lyrics, sometimes incorrect, you try to identity the song. You only return the song and artist.&apos; 
					}
			],
			monitor(m) {
        m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
          if(e.loaded === 0 || e.loaded === 1) return;
          statusPanel.innerHTML = `Downloading, currently at ${Math.floor(e.loaded * 100)}%`;
        });
    	}	
		});
	}

  console.log(`Passing: ${transcript}`);
  let thisSession = await session.clone();
  let result = await thisSession.prompt(
    [
      { role: &apos;user&apos;, content: transcript }
    ], { responseConstraint: schema });
  
  console.log(result);
  let { song, artist } = JSON.parse(result);
  songTitleEl.textContent = song;
  songArtistEl.textContent = artist;
  resultPanel.hidden = false;
}

function initApp() {
  recordBtn = document.getElementById(&amp;quot;recordBtn&amp;quot;);
  transcriptEl = document.getElementById(&amp;quot;transcript&amp;quot;);
  statusPanel = document.getElementById(&amp;quot;statusPanel&amp;quot;);
  statusMessage = document.getElementById(&amp;quot;statusMessage&amp;quot;);
  resultPanel = document.getElementById(&amp;quot;resultPanel&amp;quot;);
  songTitleEl = document.getElementById(&amp;quot;songTitle&amp;quot;);
  songArtistEl = document.getElementById(&amp;quot;songArtist&amp;quot;);

  document.querySelector(&amp;quot;.controls&amp;quot;).hidden = false;
  document.querySelector(&amp;quot;.transcript-panel&amp;quot;).hidden = false;

  recordBtn.addEventListener(&amp;quot;click&amp;quot;, () =&amp;gt; {
    if (isRecording) {
      stopRecording();
    } else {
      startRecording();
    }
  });
}

document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, async () =&amp;gt; {
  const supported = await canDoIt();

  if (!supported) {
    showUnsupportedMessage();
    return;
  }

  initApp();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So up top - a bunch of variables that will point to the DOM and two checks - one for speech recognition (it should always exist, just not at the same place - scratch that - not supported in Firefox, sorry) and one for mobile.&lt;/p&gt;
&lt;p&gt;I&apos;m using a JSON schema to shape Chrome AI&apos;s response to ensure it just returns the song and artist.&lt;/p&gt;
&lt;p&gt;After that - I&apos;ve got code to handle clicking the record button and transcribing. As soon as you stop recording, the transcription is handed off to Chrome for analysis.&lt;/p&gt;
&lt;p&gt;And... it worked. Poorly. If you get the lyrics right it can do ok, but it didn&apos;t really work well enough to consider it a success. You, if you are on Chrome 148 or higher, can now test this yourself, no need to flip a feature flag. It&apos;s up and running here: &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/voice_to_song/&quot;&gt;https://cfjedimaster.github.io/webdemos/voice_to_song/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;But as I said... temper your expectations.&lt;/p&gt;
&lt;h2 id=&quot;version-do-re-mi&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#version-do-re-mi&quot;&gt;Version Do Re Mi&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I decided to pivot and go from on-device AI to hitting the Gemini API directly. One nice thing about the Gemini APIs is that they have a proper free tier which meant I could put up a demo and not worry about the cost. (That being said, if you try the demo and it fails due to rate limits and such... I&apos;m sorry. I&apos;ll offer you a full and complete refund at my earlier convenience.)&lt;/p&gt;
&lt;p&gt;For this, I went back to to &lt;a href=&quot;https://www.val.town/&quot;&gt;val.town&lt;/a&gt; which I&apos;ve been enjoying the heck out of lately. I first did the quick hack to allow for static files by using this in main.ts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { staticHTTPServer } from &amp;quot;https://esm.town/v/std/utils/index.ts&amp;quot;;
export default staticHTTPServer(import.meta.url);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then added a HTTP trigger to make it accessible. (This feels like something I think val.town could make even easier.) My HTML stayed the same (except for one tweak I&apos;ll mention below) and my JavaScript changed to remove the check for Chrome&apos;s AI and just do a simple fetch call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let req = await fetch(API, {
method: &amp;quot;post&amp;quot;,
body: JSON.stringify({ transcript }),
});

let result = await req.json();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is calling &lt;code&gt;api.ts&lt;/code&gt; which also has a HTTP trigger:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { GoogleGenerativeAI } from &amp;quot;npm:@google/generative-ai&amp;quot;;

const schema = {
  type: &amp;quot;object&amp;quot;,
  properties: {
    song: {
      type: &amp;quot;string&amp;quot;,
      description: &amp;quot;The song name.&amp;quot;,
    },
    artist: {
      type: &amp;quot;string&amp;quot;,
      description: &amp;quot;The artist name.&amp;quot;,
    },
  },
  required: [&amp;quot;song&amp;quot;, &amp;quot;artist&amp;quot;],
};

const genAI = new GoogleGenerativeAI(Deno.env.get(&amp;quot;GEMINI_API_KEY&amp;quot;));
const model = genAI.getGenerativeModel({
  model: &amp;quot;gemini-flash-latest&amp;quot;,
});

export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();
  console.log(&amp;quot;req&amp;quot;, body);

  let result = await model.generateContent(
    {
      contents: [{
        role: &amp;quot;user&amp;quot;,
        parts: [{
          text:
            `Identify this song based on lyrics remembered. You will be passed lyrics that the user guesses are in the song. Lyrics could be wrong. Search against the lyrics, not the title of the song. Here&apos;s what they remember: ${body.transcript}`,
        }],
      }],
      generationConfig: {
        responseMimeType: &amp;quot;application/json&amp;quot;,
        responseSchema: schema,
      },
    },
  );

  console.log(result.response.text());

  return Response.json(JSON.parse(result.response.text()));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll note I improved the system message a bit here. While testing with a buddy, he noted it seemed to sometimes focus on matching a title in the transcript, so I wanted to try to avoid that.&lt;/p&gt;
&lt;p&gt;Back in the HTML, I made one more important change to the instructions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sing (or say) a few bars into your mic. We&apos;ll listen, pretend to think
very hard, then guess what song you had in mind. No guarantees. Maximum
synth.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I removed the mention of humming as that won&apos;t actually be transcribed. In theory, we could record that audio and send it to Gemini as well, but I was trying to keep this simpler.&lt;/p&gt;
&lt;p&gt;Want to try this version? Want to belt out some tunes? Head over to the live version here: &lt;a href=&quot;https://raymondcamden--4d1152f64d8111f1a0fbee650bb23af1.web.val.run/&quot;&gt;https://raymondcamden--4d1152f64d8111f1a0fbee650bb23af1.web.val.run/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;As I said, this is a free tier Gemini call so I fully expect you may hit limits. You can always fork my val (embedded below) and use your own key.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/voice1.png&quot; loading=&quot;lazy&quot; alt=&quot;Email of NFL News&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;How well does this one work? Better! But myself, and my friends who tested, still see mistakes. That being said, it was kind of fun. Let me know what you find!&lt;/p&gt;
&lt;p&gt;Also, a big thank you (again!) to &lt;a href=&quot;https://blog.tomayac.com/&quot;&gt;Thomas Steiner&lt;/a&gt; from the Chrome team for help while I was building this out!&lt;/p&gt;
&lt;p&gt;I was going to embed the Val here, but apparently you can only embed one file so - instead, here&apos;s a simple link instead: &lt;a href=&quot;https://www.val.town/x/raymondcamden/voice-to-song&quot;&gt;https://www.val.town/x/raymondcamden/voice-to-song&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@maykogob?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Mayko Sousa&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-cat-yawning-with-its-mouth-open-wvDs1EswZZk?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/04/using-val-town-and-gemini-for-sports-ball-stuff</id>
                <title>Using Val Town and Gemini for Sports Ball Stuff</title>
                <updated>2026-05-04T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/04/using-val-town-and-gemini-for-sports-ball-stuff" rel="alternate" type="text/html" title="Using Val Town and Gemini for Sports Ball Stuff"/>
                <content type="html">
				
                        &lt;p&gt;This is trivial as heck as the kids say, but I really want to explore &lt;a href=&quot;https://www.val.town/&quot;&gt;Val Town&lt;/a&gt; more this year and I thought of a great, and simple use for it. Both my wife and I are big Saints fans (this is their year, honest) and attend most of the games. If they&apos;re not playing at home, we&apos;re absolutely watching it on TV. We both &lt;em&gt;really&lt;/em&gt; enjoy watching football, but honestly, not enough to watch ESPN and follow the news.&lt;/p&gt;
&lt;p&gt;I thought - why not simply get a summary of NFL news from the past week and build an automation of it? I had this running in less than ten minutes with Val Town.&lt;/p&gt;
&lt;p&gt;First, the code makes use of Google&apos;s Node SDK for working with Gemini. I setup my environment variable first and then used this code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { GoogleGenerativeAI } from &amp;quot;npm:@google/generative-ai&amp;quot;;
import { email } from &amp;quot;https://esm.town/v/std/email&amp;quot;;
import { marked } from &amp;quot;npm:marked&amp;quot;;

const genAI = new GoogleGenerativeAI(Deno.env.get(&amp;quot;GEMINI_API_KEY&amp;quot;));
const model = genAI.getGenerativeModel({ model: &amp;quot;gemini-2.5-pro&amp;quot; });

const prompt = `
  Act as a sports news curator. I am a casual football fan who watches games on Sundays 
  but avoids ESPN. 

  Provide a high-level summary of NFL news from the past 7 days. 
  
  Requirements:
  - Length: 3-4 paragraphs.
  - Include relevant links for further reading.
  - Tone: Informative but accessible for a casual fan.
  - Focus: Major trades, schedule updates, and significant roster moves.

In your response, don&apos;t mention the prompt per se, just give me the summary report.
For each item in your report, generate a heading title.
`;

async function getNFLSummary(p) {
  try {
    const result = await model.generateContent(p);
    const response = await result.response;
    return response.text();

    console.log(text);
  } catch (error) {
    console.error(&amp;quot;Error generating report:&amp;quot;, error);
  }
}

let summary = await getNFLSummary(prompt);
console.log(summary);

let html = `
&amp;lt;h2&amp;gt;NFL News Summary&amp;lt;/h2&amp;gt;

${marked.parse(summary)}
`;

await email({
  subject: &amp;quot;NFL News Summary&amp;quot;,
  html,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The prompt is pretty specific and grew as I tested. The final paragraph in particular was necessary as I kept getting &amp;quot;chat&amp;quot; like responses which wouldn&apos;t make sense for an email report. I also had to ask specifically for titles for the summaries which makes it easier to skip over things I don&apos;t care about. Lastly, I considered adding a note about focusing on the Saints, but I really wanted something more generic, especially as we tend to hear a lot of Saints news via local updates and such.&lt;/p&gt;
&lt;p&gt;And the last bit just sends an email to me, from Val Town, as I don&apos;t need a custom FROM/TO here, this works just fine.&lt;/p&gt;
&lt;p&gt;The last, &lt;em&gt;last&lt;/em&gt; bit was the CRON schedule which I set as the trigger and for 9AM on Mondays. Doing a quick run produces this:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/nfl1.png&quot; loading=&quot;lazy&quot; alt=&quot;Email of NFL News&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I&apos;ve embedded the Val below - let me know if you fork it!&lt;/p&gt;
&lt;iframe width=&quot;100%&quot; height=&quot;400px&quot; src=&quot;https://www.val.town/embed/x/raymondcamden/nfl-roundup/main.ts&quot; title=&quot;Val Town&quot; frameborder=&quot;0&quot; allow=&quot;web-share&quot; allowfullscreen&gt;&lt;/iframe&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@aussiedave?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Dave Adamson&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/brown-and-black-wilson-football--nATH0CrkMU?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="serverless" />
                
                <category term="generative ai" />
                
                
                <category term="javascript" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
</feed>