Tuesday, October 6, 2026

My journey with agentic coding

Some people may know that I tend to enjoy developing niche software primarily targeting older versions of Windows. My preferred language is C, as it is a fairly low-level language when compared to more modern alternatives, and the resulting binaries are usually rather small when compared to most other software released today. I primarily write these programs for my own amusement and technical experiments, and I share them online if I feel that other people might find them useful. When I write code, I usually don't write comments, as I firmly believe that the code should speak for itself. I also imagine that I am developing software for my past self, as this helps me structure how the user interface should be designed among other things.
All of this is to say that I program as a hobby, and some may consider my methodology more involved when compared to most other hobbyist programmers.
A modern trend that I have noticed take off in the past year or so is using large language models to generate code. I have noticed that many of the resulting projects range from barely useful, to technically impressive software that I thought was practically impossible to develop in a short amount of time, and everything in between. Starting in early august of 2026, I decided to take a crack at this software development trend, as I wanted to see if my experience with developing low-level software would make a difference in the resulting projects. I spent a month developing a Win32 application with the assistance of AI, and I have mixed opinions on how my previous experience with software development impacted the final project. This document serves as a reflection on the development process, as well as developer documentation for the project itself.
To elaborate on the latter point, I have observed that most documentation that is generated by large language models is outright useless. It usually says a lot and nothing at all at the same time, and it tends to get things wrong, even when it is about the code that it had previously generated. In my opinion, the journey of the development process written by the person who prompted the software into existence is far more valuable than any document that describes the software alone.
A couple years ago, I decided to make a fork of the Timidity MIDI player. The original objective was to have a lightweight software synthesizer library that could easily be integrated into other applications, and as far as I know this has never been done with the Timidity engine. I started with the GSPlayer MIDI plugin, as the code was fairly minimal and thus would be easy to work with. GSPlayer is an open source media player primarily targeting Windows CE, and its MIDI plugin used a lightly modified version of Timidity 0.2i.
The first obstacle I faced was the structure of the code itself. Timidity was originally a command-line application written in the late 90s, and it used global variables everywhere. The GSPlayer adaptation already removed most of the code that formed the basis for the user interface of the original program, so that part was already taken care of in the early 2000s. I moved all global variables into a state structure, which is effectively an instance handle that applications use to communicate with the library. In other words, multiple instances of the synthesizer can safely exist in a single process.
The audio output mechanism of the original program was not really flexible either, as it implemented audio output callbacks which would communicate with audio hardware or write to audio files. The GSPlayer version of the code somewhat held onto this concept, as it implemented an audio output callback that would write to a Win32 wave header structure who's pointer was stored in a global variable that was set in the plugin code. The library that I developed doesn't implement this mechanism for outputting audio, as the caller provides a buffer that the library fills as fast as it can. In other words, the library doesn't care what happens to the generated PCM, as it can be streamed to an audio device, written to a file, or processed in any other imaginable way. several common PCM formats are supported by the library, including 8 through 32-bit integer, 32 and 64-bit floating point, and 8-bit uLaw.
Internally, Timidity mixes audio into a 32-bit fixed point accumulator, which uses a particular number of bits as a guard to prevent overflows. The 8 through 24-bit fixed point output functions simply shift the samples to the right and clamp them appropriately. The 32-bit fixed point output function first clamps the samples, then shifts them to the left so that the audio output level matches the other rendering functions.
The floating point rendering functions are especially interesting, as they do nothing more than scale the samples down appropriately. This is significant because floating point PCM can represent audio above 0 dBFS, so this allows the guard bits to be used as extra headroom, where each additional bit represents approximately 6 dB. It is worth noting that as the samples are internally 32-bit integers, the dynamic range of the floating point output functions has a hard sealing. This isn't much of a problem in practice, however the audio will inevitably clip if the amplification setting in the synthesizer is significantly increased, or a rather large number of notes are playing simultaneously. I find it fascinating that this decades old code produces data that takes advantage of a more modern PCM format when properly scaled, and I imagine this was a use case that the original developers most likely never considered.
The uLaw output support is more or less a holdover from the original Timidity code that I decided to keep in. This rendering function works by reducing to 13 bits in the same way as the 8 through 24-bit fixed point rendering functions, and then using a lookup table for determining the final 8-bit values.
Despite all the audio output changes, the original synthesis logic was kept in tact. This has a nice side-effect of the code running reasonably well on relatively slow processors, and the output PCM is almost byte for byte identical when compared with the original program. A notable change that I made to the MIDI handling logic was implementing mono and poly channel mode messages. There was a flag in the channel structure for monophonic operation, and the code only allowed a single note to play on the appropriate channel if the flag was set. However, there weren't any code paths to enable or disable this flag, so I essentially finished work on a feature that was only partially implemented.
I also changed a few of the default synthesizer settings. I increased the maximum allowed number of simultaneous voices from 48 to 1024, and I decreased the default amplification from 70% to 50%. The reason I decreased the default amplification is because I noticed that the output clipped more frequently with the original default setting.
When I started work on this Timidity library, my priority was live MIDI input, which would allow applications to directly program the synthesizer. The MIDI file player logic from the original code was later merged into the library, so it could now act as either a low-level synthesizer or a high-level file player. I continued improving the library over time, and I eventually got it into a state that I was happy with. I have integrated the library into several projects, including a VST instrument, a user-mode Windows MIDI driver, and a DirectShow source filter. I have always envisioned a standalone player application based around the library, however I could never find the motivation to sit down and make this a reality. As this project was more or less a GUI layer around a solid library that was entirely written by hand, and it wouldn't be my primary MIDI player anyway, I felt it was a good candidate for experimenting with LLM assisted code generation.
Before going into the actual development process, I would like to talk about the operating system compatibility of this program, as well as my development environment. This application relies on Win32 API functions that were introduced in Windows 2000, so naturally that is the minimum supported Windows version. I did test the ANSI version of the application on Windows Millennium edition, and that went as well as I expected. Windows Me was notorious for being a rather unstable operating system back in the day, so I wasn't too surprised when the system randomly crashed after I attempted to add a folder of MIDI files to the player.
On my main machine, I have a MinGW compiler set up so that myself (or the AI agent) can quickly generate builds for testing purposes. The production builds are generated by copying the code to a Windows XP virtual machine, and running a script located in the root directory of the repository. The virtual machine has Visual studio 2005 and the Windows Server 2003 Platform SDK installed, and the build script compiles the code for the X86, X64, and IA64 processor architectures. Before anyone asks, I don't have any Itanium hardware, and the main reason I bother building for IA64 at all is simply because the builds are trivial to produce. The other reason for the IA64 builds is because I have a weird sense of humor, and one of these days I hope to get my hands on an Itanium system and test some of my projects on it. I know that the performance of Itanium was abysmal, however I tend to enjoy experimenting with obscure hardware and software.
I had already written a command-line program that could convert MIDI files to WAV using the Timidity library, so I started by prompting the coding agent to create a batch converter with a standard Win32 GUI. As the MIDI to WAV converter that I had previously written was part of the Timidity Windows driver project, I set up the coding agent to work in a local copy of that repository so it would learn from what I had already created and reuse existing code when appropriate. When the first build arrived, I was honestly impressed. The agent had created a dialog-based Win32 application that could allow me to add up to 512 MIDI files, and convert them using the WAV file writer code that I had already written for the CLI converter.
Something that the agent did without me explicitly prompting it was that it wrote the code so that a dedicated thread was created for the actual conversion process. This batch converter was far from finished, so I prompted in some new functionality. This included a button for setting the default output directory, as well as generating statistics about the converted files. When I prompted the statistics feature into existence, I told the agent to reference the code for the CLI converter for reference. After that was done, I told the agent to make this program into a standalone player, while making the batch converter accessible through an option in the main user interface. For the code that managed the audio device, I told the agent to reference the MIDI driver code, as it implemented an audio player based on the waveOut API. It was at this point where I started brainstorming features and prompting them as I thought of them, and this is when things started to get a little frustrating.
Before continuing, I would like to discuss a notable characteristic of AI coding agents that I feel is important to keep in mind. While the generated code might work when given common test cases, it usually falls apart when given edge cases, unless the agent is explicitly prompted to write the code to take particular things into account. This is how I initially ended up with obscure bugs, such as the player not loading the next file in the cue if the currently playing file was removed from the playlist. Another notable bug that I ran into was that if a message box automatically appeared when a modal dialog was opened, the window would get into a disabled state, and I would have to restart the application. You could argue that I signed up for this behavior as I was working directly with the Win32 API, which most modern apps don't do anymore.
The features that I got implemented in the initial brainstorm session included seeking, a sleep timer, a jump to time dialog, playlist support, a recent files menu, system tray mode, and media key handling. Some of these features worked the way I wanted on the first implementation, and others took several prompts until I was happy with them. The initial implementation of the media key support only functioned when the app was in focus, and I eventually implemented global hotkeys for play/pause, stop, next track, and previous track. I also implemented global hotkeys for volume up/down, however I eventually put these behind a setting called intercept volume media keys (disabled by default), as these keys are usually expected to adjust the entire system volume. I don't actually blame that initial flaw on the AI agent, as that is an implementation detail that I may have overlooked even if I was coding this app by hand.
As a quick historical note, the earliest revision of the Windows header files that appear to include the media key declarations are from late 1999, which is around the time Windows 2000 was released. Several keyboards from the late 90s and early 2000s included dedicated media buttons, and many modern keyboards have methods of invoking the media functions as well.
After hours of prompting and testing, I had a single C source file that was 113 KB in size, and I went to sleep for several more hours. The next time I started working on the project, I prompted the agent to strip all comments from the file. Like documentation, I found that LLM generated code comments got in the way, and were extremely verbose. I also prompted the agent to split the code into multiple translation units, as I wanted to make the code easier to maintain by hand when needed. Additionally, I prompted the agent to move all global app state into a single data structure, as I am not fond of many global variables scattered throughout the code. After that was done, I did my first manual code review, which took some time. Whenever I made manual fixes, I prompted the agent to look them over, and most of the fixes came back clean.
Over the next few weeks, I prompted more functionality into the program, got bugs fixed as I found them, and added support for several more C/C++ compilers that I have on my computer. For the record, the supported compilers include various versions of the Microsoft C/C++ compiler, Borland C++ 5.5, Digital Mars C++, MinGW, Open Watcom, and the Tiny C Compiler (TCC). Adding the Digital Mars build support took a bit more effort than the other compilers, as I had to have the agent create a compatibility shim for functionality that wasn't declared in the supplied Win32 API headers and libraries.
The new functionality that was added includes live MIDI playback, a channel mixer, a virtual keyboard, and a feature that allows the user to inject raw MIDI events directly into the synthesizer. I had the agent put the code for these features into new source files to keep manual maintainability practical.
Another feature that I added early on allows the user to quickly convert the currently loaded MIDI file to WAV, and this is where the multi instance capability of the Timidity library really becomes useful. Like the batch converter,the quick conversion process runs on its own thread, so MIDI files can play while a conversion is running in the background. While a quick conversion is in progress, the status bar is updated with the percentage of the conversion, and information relevant to the state of the realtime playback is omitted.
The batch converter gained more functionality, including the abilities to output audio files in various PCM formats and save the conversion statistics to a CSV file. An option to preserve the folder structure of the input paths when generating the output paths was added as well. Options for customizing the main window of the application were also added, including toggles for showing or hiding the status bar, seek bar, and playlist view. An always on top toggle was added to the system menu, because I wanted the program to feel like a real application that could have been around in the late 90s.
A more sophisticated playlist manager was also implemented, as well as an option to shuffle the playlist. Besides the ability to reorder and remove items, the playlist manager includes search functionality and the ability to play the selected track. Like some of the other features, the playlist manager took several prompts until I was happy with the result.
A statistics dialog that shows information about the currently loaded file was added as well, and most of the info shown in this dialog is queried straight from the library. A statistic that Is shown which isn't provided by the library is the CPU load, which is a percentage that shows how well the system is holding up when rendering audio in realtime. For example, if it takes 50 MS to render 50 MS of audio, this results in 100% CPU usage. This is obviously a worst case scenario, and real world usage on a modern processor can reach under 1% with the default synthesizer configuration, which really shows how efficient this decades old synthesis engine is. To really drive the efficiency point home, the default configuration performs realtime synthesis reasonably well on processors from the Pentium II era.
The status bar also shows some statistics, however I limited this representation to information that a user would more likely want to know right away. The status bar only shows the transport state and playback position, song title and copyright when applicable, the active number of voices, the audio output volume, and the CPU load. By default, the title bar shows the name of the currently loaded file alongside the name of the application, however this behavior can be disabled in the player settings dialog if it isn't desired.
The program gained the ability to take command-line arguments. The names of files, folders, and wildcards can be provided, and the /add switch can be appended after the name of the executable to add the files without discarding a playlist if one already exists in the programs memory. This command-line handling is quite elegant, as the files are passed to an existing instance if the program is already running. The /c switch can be appended right after the name of the executable to launch straight into the batch converter without bringing up the main interface, and there are additional switches to control how the batch converter is set up when initialized in this way.
A design decision that I made early on was that only one instance of the application is allowed to run at any given time. If the user simply launches the program without any command-line arguments while another instance is already running, the window is brought to the foreground if system tray mode is disabled. If additional arguments are passed and the program is already running, the user is asked if they want to load files into the existing instance. This dialog has an additional checkbox to suppress this prompt, and the actual setting is saved in the registry when yes or no is answered. This setting can also be changed in the player settings dialog.
Near the end of the development process, I fixed a bug in the sleep timer that I didn't catch early on. When the user paused or stopped playback, the timer would continue running. The revised logic gets the current time when the user pauses playback and stops the timer. The result of this fix is that the remainder of the time runs when the user resumes playback from a paused state. When the user fully stops playback while the timer is running, the timer is stopped as well. I also made it so that the sleep timer has no effect when the program is acting as a live MIDI synthesizer, as a sleep timer simply doesn't make any sense for that particular use case.
Another small bug that I fixed was that a pop could be heard when a MIDI file finished playing, and this only happened when the audio driver was configured to output 8-bit PCM. For a bit of background, I designed the function in my Timidity library that generates audio from a loaded MIDI file to return 0 once all events have been processed and all notes have fully stopped. The audio output code in the application was sending a buffer of silence to the audio device when this condition was met, which isn't a problem in itself. The problem was that 8-bit PCM is usually unsigned, while 16-bit PCM is usually signed. silence is represented by 0 in signed PCM, while in unsigned PCM, silence is represented by the middle value of the data type. in the case of an unsigned 8-bit integer, this value is 128.
A much more significant audio bug that I found manifested when the synthesizer was reconfigured to use a different sample rate, bit depth, or number of channels while playback was active. Changing the sample rate resulted in the audio playing back at the wrong speed, and the change in bit depth or number of channels resulted in distorted playback on top of that. Even worse, if the synthesizer attempted to write stereo data into a buffer that was allocated for mono data, the application would crash. The problem was that the audio device wasn't reopened when the synthesizer was configured to output in a different format, and this took several prompts to get properly fixed. This is yet another instance of the AI agent not having a true understanding of the system as a whole, and why manual testing really pays off in the end.
Another feature that I got the agent to implement was the ability to restore the settings to their defaults, as well as the ability to export and import settings files. When settings are restored or imported, the application's ability to write to the registry is limited for the rest of the session, which almost guarantees that the default or imported settings take effect the next time the program starts. Under normal circumstances, the current playlist is saved to the registry when the user exits the application, unless this feature is disabled in the player settings dialog. However, when settings have been restored or imported, this obviously can't happen. This had the unintended side-effect of silently discarding the users playlist when the application exited under these conditions.
I had the agent make it so that any action that results in the playlist being modified marks it as not manually saved, and saving the playlist as an M3U file sets this flag to true. When settings are restored or imported and this flag is false, the user is asked if they want to save the playlist when they exit the application. This is another example of the agent not accounting for human behavior during the initial implementation of a feature, and I am extremely glad that I caught this before public release.
When I implemented clipboard pasting functionality, I had an interesting bug crop up relating to character sets. Rather than limiting this feature to file objects, I also made it so that the user could copy a line-separated list of file paths to the clipboard, and paste the text into the application. My primary build target was Unicode, and when I initially tested this feature with the ANSI build, I got what amounted to a garbled mess of characters. This was another instance of the agent failing to account for edge cases, and the fix was quite simple in the end, as the data type requested from the clipboard is different depending on the build target.
I ran into a similar issue with the binary-based settings file format, as settings files exported by a Unicode build could only be imported by a Unicode build, and the ANSI version had a similar issue. I made it so that the header of the settings file includes a flag that states whether the file was generated by a Unicode build or not, and text strings are converted during the import process as needed. This is yet another instance of the AI agent introducing obscure bugs into the code, and I almost guarantee they would have stuck around if I didn't explicitly think of testing for these sorts of things.
Another thing that the AI agent really struggled with was keyboard shortcuts. If a menu or dialog had multiple options that had a shared letter in the name, the agent tended to chews the same letter for more than one item in that particular resource. This resulted in the menu or dialog manager in the operating system moving between items rather than activating them, which is something that I try to avoid. As editing resource scripts is something that I feel I am really good at, these fixes were mostly done manually. However, I would occasionally prompt the agent to do a thorough check for shortcut key conflicts in the resource script, and it would sometimes find things that I had missed in my manual edits.
Something else that I did throughout the development process was prompting the agent to clean up after itself by removing duplicated code introduced by new functionality, and this was a rather tedious process. I also prompted the agent to make sure that functions only relevant to a single translation unit were declared as static, and to make sure that the order of the function declarations in the shared header file matched the order of the functions in the source files.
Another thing I did near the end of the development process was integrating reverb into the Timidity library, and the reverb engine I used was a standalone version of code that was derived from the EAX reverb implementation found in older versions of the OpenALSoft project. When reverb is disabled (which is the default setting) the audio generation still runs as fast as it did before the reverb engine was implemented. A notable characteristic of the way I implemented reverb into the Timidity library is that per channel reverb level is taken into account, so it isn't strictly a global effect.
This integration was mostly done by hand, and the only thing I used the agent for was sketching out the architecture for integrating the reverb engine into the existing synthesis pipeline, and generating the array that contains all 113 EAX reverb presets. To be clear, the agent didn't generate the actual preset data, as I simply told it to use the presets header file that I copied from the OpenALSoft project some time ago. I reviewed and manually edited the code that connected the reverb engine to the rest of the library, and this process didn't take much time as the code is fairly minimal and straightforward.
There is actually an interesting bit of irony when it comes to the reverb integration. Timidity uses Gravis Ultrasound patches for instrument samples, and the reverb I integrated emulates a standard that was developed by Creative Labs. The ironic part is that Creative and Gravis were close competitors back in the 90s, and I didn't even think of this connection when I integrated the EAX reverb engine into the Timidity library.
As you could probably imagine, I felt I was making real progress. However, in the back of my mind I felt I wasn't really gaining many new skills either, and I didn't want to get addicted to this method of software development. As these AI tools rely on the cloud in order to function, they can go away at any time, and the people who rely on these tools as a crutch will essentially have no way of maintaining their projects when these tools inevitably go away. I am aware that local models exist, however the system requirements are quite high, and the output may not be as useful when compared to the models that are produced by the major AI providers. All of this is to say that I decided to set a deadline as to when I was going to stop using the LLM, and by the end of August I had gotten all the features that I wanted implemented anyway.
When September 6th rolled around, I had fixed all major bugs that I found during the development process. After deleting the session files that the coding agent relied on, I canceled my subscription to the AI provider, and moved the code out of the copy of the Timidity driver repository that I was using for this project. Moving the code to its own repository was quite straightforward, as the only changes made during this process were updating include statements in some source files, as well as the build scripts for all the supported compilers.
Over the next several days, I did another manual code review, and fixed a couple small things that stood out. For example, I fixed the placement of some UI elements, silenced compiler warnings in callback functions where one or more arguments were unused, and moved some local variables into the scopes where they were actually needed rather than declaring them function-wide. As you could probably tell, these changes were purely cosmetic, as they had no impact on the behavior of the application. I also implemented a trivial feature that allows the user to clear the recent file list, and promoted the maximum number of files that the player and batch converter could support from 512 to 1024.
The Timidity library got some updates as well. Modulation wheel support was added, and this was trivial to implement as there was already a mechanism for generating vibrato in the original code. The vibrato generator was Initially only used for instrument patches that include vibrato information.
To complement the reverb integration, Support for the chorus effect controller was implemented. Like reverb, chorus is disabled by default. The underlying DSP algorithm that generates the chorus effect is based on public domain code that I found online, and the function that processes audio takes one input sample and generates one output sample. This means that two instances of the effect are allocated, and only one instance is used when generating mono audio data. the low frequency oscillator is out of phase on the instance that is used for the right channel, in order to make the effect more pronounced in stereo.
Finally, I added optional TPDF dithering for the fixed point rendering functions, and fixed the reset all controllers event to reset the volume and pitch of actively playing notes. The pseudorandom number generator that is used for the dithering functionality is a public domain implementation of the Mersenne Twister algorithm, and it is seeded with a hard-coded value when the synthesizer is reset. This means that if the exact same MIDI file is converted more than once with the exact same synthesizer configuration, the hashes of the output WAV files should be identical.
Over all, this was an interesting experiment, and I will admit that coding by hand is more rewarding in the end. Even though manually writing code takes longer, I find it more enjoyable to solve problems purely on my own.
If you decide to test this program and you find an obscure bug, you now know why it may have been introduced. At the time of this writing, I believe I have tracked down all major issues. However, if you find anything significant, feel free to let me know so I can attempt to fix things without the help of an AI agent. By releasing this program to the public, I claim full responsibility for any problems that may arise. To end on a more positive statement, I hope that someone else out there finds this program useful or at the very least interesting. However, I understand that the probability of this occurring is quite low, especially because this is an extremely niche application that I mainly prompted together as an experiment for my own amusement.