diff --git a/.gitignore b/.gitignore index 712bb84..01ac619 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ nob.old nob.exe nob.exe.old nob.obj + +plug.plist \ No newline at end of file diff --git a/README.md b/README.md index 8935c1e..136c072 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ https://github.com/tsoding/musializer/assets/165283/8b9f9653-9b3d-4c04-9569-338f ## Supported Audio Formats +Supported directly: + - wav - ogg - mp3 @@ -27,6 +29,15 @@ https://github.com/tsoding/musializer/assets/165283/8b9f9653-9b3d-4c04-9569-338f - mod - flac +Supported through FFmpeg: + +- m4a +- aac +- wma +- aiff +- ape +- opus + ## Download Binaries - Windows: [musializer-alpha2-win64.zip](https://github.com/tsoding/musializer/releases/download/alpha2/musializer-alpha2-win64.zip) @@ -35,7 +46,7 @@ https://github.com/tsoding/musializer/assets/165283/8b9f9653-9b3d-4c04-9569-338f ## Build from Source External Dependencies: -- [ffmpeg](https://ffmpeg.org/) executable available in `PATH` environment variable. It is called as a child process during the rendering of the videos. So if you don't plan to render any videos it's completely **optional**. +- [ffmpeg](https://ffmpeg.org/) executable available in `PATH` (or `ffmpeg.exe` beside `musializer.exe` on Windows). It is used for video rendering, cover extraction, and formats that raylib cannot decode directly. It remains optional when you only use the directly supported formats and do not render videos. We are using Custom Build System written entirely in C called `nob`. [nob.c](./nob.c) is the program that builds Musializer. For more info on this Build System see the [nob.h repo](https://github.com/tsoding/nob.h). @@ -61,9 +72,27 @@ $ sudo apt install libx11-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi On other distro's, use the appropriate package manager. +### Windows MinGW-w64 (automatic setup) + +From PowerShell, run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\setup-windows.ps1 +``` + +The script installs MSYS2 when needed, then installs the UCRT64 MinGW-w64 toolchain, FFmpeg, and zip. It adds the tools to your user `PATH`, bootstraps `nob`, and builds Musializer. The script is safe to run again when you need to update or repair the toolchain. Open a new terminal afterward so it sees the updated `PATH`. + +Run the app from the repository root: + +```powershell +.\build\musializer.exe +``` + +Use `-SkipUpdate`, `-SkipPathUpdate`, or `-SkipBuild` if you want the script to omit those steps. If MSYS2 is installed in an unusual location, pass it with `-Msys2Root C:\path\to\msys64`. + ### Windows MSVC -From within `vcvarsall.bat` do +The Windows build uses native Win32 threading and does not require pthreads or another compatibility library. Run these commands from an MSVC developer prompt initialized by `vcvarsall.bat`: ```console > cl.exe nob.c # ONLY ONCE!!! diff --git a/resources/fonts/FreeSans.ttf b/resources/fonts/FreeSans.ttf new file mode 100644 index 0000000..9db9585 Binary files /dev/null and b/resources/fonts/FreeSans.ttf differ diff --git a/setup-windows.ps1 b/setup-windows.ps1 new file mode 100644 index 0000000..3efde65 --- /dev/null +++ b/setup-windows.ps1 @@ -0,0 +1,318 @@ +[CmdletBinding()] +param( + [string]$Msys2Root, + [switch]$SkipUpdate, + [switch]$SkipPathUpdate, + [switch]$SkipBuild +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Write-Step { + param([string]$Message) + Write-Host "`n==> $Message" -ForegroundColor Cyan +} + +function Invoke-Native { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')" + } +} + +function Test-Msys2Root { + param([AllowNull()][AllowEmptyString()][string]$Path) + return -not [string]::IsNullOrWhiteSpace($Path) -and + (Test-Path -LiteralPath (Join-Path $Path "usr\bin\bash.exe") -PathType Leaf) +} + +function Find-Msys2Root { + param([AllowNull()][AllowEmptyString()][string]$PreferredRoot) + + $candidates = [System.Collections.Generic.List[string]]::new() + if (-not [string]::IsNullOrWhiteSpace($PreferredRoot)) { + [void]$candidates.Add($PreferredRoot) + } + $environmentRoot = [Environment]::GetEnvironmentVariable("MSYS2_ROOT") + if (-not [string]::IsNullOrWhiteSpace($environmentRoot)) { + [void]$candidates.Add($environmentRoot) + } + [void]$candidates.Add((Join-Path $env:SystemDrive "msys64")) + [void]$candidates.Add((Join-Path $env:LOCALAPPDATA "Programs\msys64")) + [void]$candidates.Add((Join-Path $env:LOCALAPPDATA "msys64")) + + foreach ($candidate in $candidates) { + if (Test-Msys2Root $candidate) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + + $uninstallRoots = @( + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + foreach ($uninstallRoot in $uninstallRoots) { + $entries = Get-ItemProperty -Path $uninstallRoot -ErrorAction SilentlyContinue | + Where-Object { + $displayName = $_.PSObject.Properties["DisplayName"] + $null -ne $displayName -and $displayName.Value -like "MSYS2*" + } + foreach ($entry in $entries) { + $installLocation = $entry.PSObject.Properties["InstallLocation"] + if ($null -ne $installLocation -and (Test-Msys2Root $installLocation.Value)) { + return (Resolve-Path -LiteralPath $installLocation.Value).Path + } + } + } + + return $null +} + +function Invoke-Msys2 { + param( + [Parameter(Mandatory = $true)][string]$BashPath, + [Parameter(Mandatory = $true)][string]$Command + ) + Invoke-Native -FilePath $BashPath -ArgumentList @("-lc", $Command) +} + +function Add-UserPathEntries { + param([Parameter(Mandatory = $true)][string[]]$Entries) + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $parts = [System.Collections.Generic.List[string]]::new() + foreach ($entry in $Entries) { + $normalizedEntry = $entry.TrimEnd("\") + $alreadyPresent = $false + foreach ($part in ($userPath -split ";")) { + if ($part.Trim().TrimEnd("\").Equals($normalizedEntry, [StringComparison]::OrdinalIgnoreCase)) { + $alreadyPresent = $true + break + } + } + if (-not $alreadyPresent) { + [void]$parts.Add($entry) + } + } + + if (-not [string]::IsNullOrWhiteSpace($userPath)) { + [void]$parts.Add($userPath.Trim(";")) + } + + if ($parts.Count -gt 0 -and ($parts -join ";") -ne $userPath) { + [Environment]::SetEnvironmentVariable("Path", ($parts -join ";"), "User") + return $true + } + return $false +} + +function Publish-EnvironmentChange { + if ($null -eq ("MusializerSetup.EnvironmentNotifier" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +namespace MusializerSetup +{ + public static class EnvironmentNotifier + { + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr SendMessageTimeout( + IntPtr window, + uint message, + IntPtr wordParameter, + string longParameter, + uint flags, + uint timeout, + out IntPtr result); + + public static void Broadcast() + { + IntPtr result; + SendMessageTimeout( + new IntPtr(0xffff), + 0x001a, + IntPtr.Zero, + "Environment", + 0x0002, + 5000, + out result); + } + } +} +"@ + } + + [MusializerSetup.EnvironmentNotifier]::Broadcast() +} + +function Set-MingwBuildTarget { + param([Parameter(Mandatory = $true)][string]$ConfigPath) + + if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { + return + } + + $content = [IO.File]::ReadAllText($ConfigPath) + $activeTargetPattern = "(?m)^\s*#define\s+(MUSIALIZER_TARGET_(?:LINUX|WIN64_MINGW|WIN64_MSVC|MACOS|OPENBSD))\s*$" + $activeTargets = [regex]::Matches($content, $activeTargetPattern) + if ($activeTargets.Count -eq 1 -and + $activeTargets[0].Groups[1].Value -eq "MUSIALIZER_TARGET_WIN64_MINGW") { + return + } + + if ($content -notmatch "MUSIALIZER_TARGET_WIN64_MINGW" -or + $content -match "(?m)^\s*#define\s+MUSIALIZER_TARGET\s*$") { + throw "The existing build\config.h uses an unsupported schema. Move the build directory aside and run this script again." + } + + $backupPath = "$ConfigPath.before-windows-setup.bak" + if (-not (Test-Path -LiteralPath $backupPath)) { + Copy-Item -LiteralPath $ConfigPath -Destination $backupPath + } + + $targets = @( + "MUSIALIZER_TARGET_LINUX", + "MUSIALIZER_TARGET_WIN64_MINGW", + "MUSIALIZER_TARGET_WIN64_MSVC", + "MUSIALIZER_TARGET_MACOS", + "MUSIALIZER_TARGET_OPENBSD" + ) + foreach ($target in $targets) { + $pattern = "(?m)^\s*(?://\s*)?#define\s+$target\s*$" + $replacement = if ($target -eq "MUSIALIZER_TARGET_WIN64_MINGW") { + "#define $target" + } else { + "// #define $target" + } + $content = [regex]::Replace($content, $pattern, $replacement) + } + + $utf8WithoutBom = [Text.UTF8Encoding]::new($false) + [IO.File]::WriteAllText($ConfigPath, $content, $utf8WithoutBom) + Write-Host "Configured build\config.h for MinGW-w64 (backup: $backupPath)." +} + +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw "This setup script must be run on Windows." +} +if (-not [Environment]::Is64BitOperatingSystem) { + throw "Musializer's Windows build requires 64-bit Windows." +} + +$repoRoot = $PSScriptRoot +if (-not (Test-Path -LiteralPath (Join-Path $repoRoot "nob.c") -PathType Leaf)) { + throw "Could not find nob.c beside this script. Run the script from a Musializer source checkout." +} + +Write-Step "Locating MSYS2" +$resolvedMsys2Root = Find-Msys2Root $Msys2Root +if ($null -eq $resolvedMsys2Root) { + $winget = Get-Command winget.exe -ErrorAction SilentlyContinue + if ($null -eq $winget) { + throw "MSYS2 is not installed and winget.exe is unavailable. Install App Installer from Microsoft, then run this script again." + } + + $installRoot = if ([string]::IsNullOrWhiteSpace($Msys2Root)) { + Join-Path $env:SystemDrive "msys64" + } else { + $Msys2Root + } + Write-Step "Installing MSYS2 in $installRoot" + Invoke-Native -FilePath $winget.Source -ArgumentList @( + "install", + "--id", "MSYS2.MSYS2", + "--exact", + "--architecture", "x64", + "--scope", "user", + "--source", "winget", + "--location", $installRoot, + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity" + ) + $resolvedMsys2Root = Find-Msys2Root $installRoot + if ($null -eq $resolvedMsys2Root) { + throw "MSYS2 was installed, but its usr\bin\bash.exe could not be found. Re-run with -Msys2Root pointing to the installation directory." + } +} else { + Write-Host "Using MSYS2 at $resolvedMsys2Root" +} + +$bash = Join-Path $resolvedMsys2Root "usr\bin\bash.exe" +if (-not $SkipUpdate) { + Write-Step "Updating MSYS2 packages" + try { + Invoke-Msys2 -BashPath $bash -Command "pacman -Syu --noconfirm" + } catch { + # A core runtime update can terminate its own shell. The fresh second pass + # distinguishes that expected case from a persistent update failure. + Write-Warning "The first update pass ended early; retrying in a fresh MSYS2 shell." + } + Invoke-Msys2 -BashPath $bash -Command "pacman -Syu --noconfirm" +} + +Write-Step "Installing the UCRT64 MinGW-w64 toolchain, FFmpeg, and zip" +Invoke-Msys2 -BashPath $bash -Command ( + "pacman -S --needed --noconfirm " + + "mingw-w64-ucrt-x86_64-toolchain " + + "mingw-w64-ucrt-x86_64-ffmpeg zip" +) + +$ucrtBin = Join-Path $resolvedMsys2Root "ucrt64\bin" +$msysBin = Join-Path $resolvedMsys2Root "usr\bin" +$env:Path = "$ucrtBin;$msysBin;$env:Path" + +if (-not $SkipPathUpdate) { + Write-Step "Adding MSYS2 tools to your user PATH" + $pathChanged = Add-UserPathEntries @($ucrtBin, $msysBin) + if ($pathChanged) { + Publish-EnvironmentChange + Write-Host "PATH updated. Open a new terminal after this script finishes." + } else { + Write-Host "The required PATH entries are already present." + } +} + +$requiredTools = @( + (Join-Path $ucrtBin "gcc.exe"), + (Join-Path $ucrtBin "windres.exe"), + (Join-Path $ucrtBin "ar.exe"), + (Join-Path $ucrtBin "ffmpeg.exe"), + (Join-Path $msysBin "zip.exe") +) +foreach ($tool in $requiredTools) { + if (-not (Test-Path -LiteralPath $tool -PathType Leaf)) { + throw "A required tool was not installed: $tool" + } +} + +Write-Step "Verifying the compiler and FFmpeg" +Invoke-Native -FilePath (Join-Path $ucrtBin "gcc.exe") -ArgumentList @("--version") +Invoke-Native -FilePath (Join-Path $ucrtBin "ffmpeg.exe") -ArgumentList @("-version") + +if (-not $SkipBuild) { + Write-Step "Building Musializer" + Push-Location $repoRoot + try { + Set-MingwBuildTarget (Join-Path $repoRoot "build\config.h") + Invoke-Native -FilePath (Join-Path $ucrtBin "gcc.exe") -ArgumentList @("-o", "nob.exe", "nob.c") + Invoke-Native -FilePath (Join-Path $repoRoot "nob.exe") -ArgumentList @() + } finally { + Pop-Location + } +} + +Write-Host "`nWindows setup completed successfully." -ForegroundColor Green +if (-not $SkipBuild) { + Write-Host "Run Musializer from the repository root with: .\build\musializer.exe" +} + +# powershell -NoProfile -ExecutionPolicy Bypass -File .\setup-windows.ps1 \ No newline at end of file diff --git a/src/ffmpeg_windows.c b/src/ffmpeg_windows.c index 53e4f9c..ea28fec 100644 --- a/src/ffmpeg_windows.c +++ b/src/ffmpeg_windows.c @@ -2,6 +2,7 @@ #include #include #include +#include #define WIN32_LEAN_AND_MEAN #define _WINUSER_ @@ -13,6 +14,7 @@ #include #include "ffmpeg.h" +#include "win32_utf8.h" struct FFMPEG { HANDLE hProcess; @@ -21,8 +23,9 @@ struct FFMPEG { FFMPEG *ffmpeg_start_rendering(const char *output_path, size_t width, size_t height, size_t fps, const char *sound_file_path) { - HANDLE pipe_read; - HANDLE pipe_write; + HANDLE pipe_read = INVALID_HANDLE_VALUE; + HANDLE pipe_write = INVALID_HANDLE_VALUE; + HANDLE null_output = INVALID_HANDLE_VALUE; SECURITY_ATTRIBUTES saAttr = {0}; saAttr.nLength = sizeof(SECURITY_ATTRIBUTES); @@ -35,25 +38,30 @@ FFMPEG *ffmpeg_start_rendering(const char *output_path, size_t width, size_t hei if (!SetHandleInformation(pipe_write, HANDLE_FLAG_INHERIT, 0)) { TraceLog(LOG_ERROR, "FFMPEG: Could not mark write pipe as non-inheritable. System Error Code: %d", GetLastError()); + CloseHandle(pipe_write); + CloseHandle(pipe_read); return NULL; } // https://docs.microsoft.com/en-us/windows/win32/procthread/creating-a-child-process-with-redirected-input-and-output - STARTUPINFO siStartInfo; + STARTUPINFOW siStartInfo; ZeroMemory(&siStartInfo, sizeof(siStartInfo)); - siStartInfo.cb = sizeof(STARTUPINFO); - // NOTE: theoretically setting NULL to std handles should not be a problem - // https://docs.microsoft.com/en-us/windows/console/getstdhandle?redirectedfrom=MSDN#attachdetach-behavior + siStartInfo.cb = sizeof(siStartInfo); siStartInfo.hStdError = GetStdHandle(STD_ERROR_HANDLE); - if (siStartInfo.hStdError == INVALID_HANDLE_VALUE) { - TraceLog(LOG_ERROR, "FFMPEG: Could get standard error handle for the child. System Error Code: %d", GetLastError()); - return NULL; - } siStartInfo.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE); - if (siStartInfo.hStdOutput == INVALID_HANDLE_VALUE) { - TraceLog(LOG_ERROR, "FFMPEG: Could get standard output handle for the child. System Error Code: %d", GetLastError()); - return NULL; + if (siStartInfo.hStdError == NULL || siStartInfo.hStdError == INVALID_HANDLE_VALUE || + siStartInfo.hStdOutput == NULL || siStartInfo.hStdOutput == INVALID_HANDLE_VALUE) { + null_output = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &saAttr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (null_output == INVALID_HANDLE_VALUE) { + TraceLog(LOG_ERROR, "FFMPEG: Could not open NUL for child output. System Error Code: %d", GetLastError()); + CloseHandle(pipe_write); + CloseHandle(pipe_read); + return NULL; + } + siStartInfo.hStdError = null_output; + siStartInfo.hStdOutput = null_output; } siStartInfo.hStdInput = pipe_read; siStartInfo.dwFlags |= STARTF_USESTDHANDLES; @@ -61,20 +69,43 @@ FFMPEG *ffmpeg_start_rendering(const char *output_path, size_t width, size_t hei PROCESS_INFORMATION piProcInfo; ZeroMemory(&piProcInfo, sizeof(PROCESS_INFORMATION)); - // TODO: use String_Builder in here - // TODO: sanitize user input through sound_file_path - char cmd_buffer[1024*2]; - snprintf(cmd_buffer, sizeof(cmd_buffer), "ffmpeg.exe -loglevel verbose -y -f rawvideo -pix_fmt rgba -s %dx%d -r %d -i - -i \"%s\" -c:v libx264 -vb 2500k -c:a aac -ab 200k -pix_fmt yuv420p %s", (int)width, (int)height, (int)fps, sound_file_path, output_path); + char resolution[64]; + char framerate[64]; + snprintf(resolution, sizeof(resolution), "%zux%zu", width, height); + snprintf(framerate, sizeof(framerate), "%zu", fps); + const char *const argv[] = { + "ffmpeg.exe", + "-loglevel", "verbose", "-y", + "-f", "rawvideo", "-pix_fmt", "rgba", + "-s", resolution, "-r", framerate, "-i", "-", + "-i", sound_file_path, + "-c:v", "libx264", "-vb", "2500k", + "-c:a", "aac", "-ab", "200k", + "-pix_fmt", "yuv420p", output_path, + NULL, + }; + wchar_t *command_line = win32_command_line_from_utf8_argv(argv); + if (command_line == NULL) { + TraceLog(LOG_ERROR, "FFMPEG: Could not construct the child process command line"); + if (null_output != INVALID_HANDLE_VALUE) CloseHandle(null_output); + CloseHandle(pipe_write); + CloseHandle(pipe_read); + return NULL; + } - if (!CreateProcess(NULL, cmd_buffer, NULL, NULL, TRUE, 0, NULL, NULL, &siStartInfo, &piProcInfo)) { + if (!CreateProcessW(NULL, command_line, NULL, NULL, TRUE, 0, NULL, NULL, &siStartInfo, &piProcInfo)) { TraceLog(LOG_ERROR, "FFMPEG: Could not create child process. System Error Code: %d", GetLastError()); + free(command_line); + if (null_output != INVALID_HANDLE_VALUE) CloseHandle(null_output); CloseHandle(pipe_write); CloseHandle(pipe_read); return NULL; } + free(command_line); + if (null_output != INVALID_HANDLE_VALUE) CloseHandle(null_output); CloseHandle(pipe_read); CloseHandle(piProcInfo.hThread); @@ -87,13 +118,20 @@ FFMPEG *ffmpeg_start_rendering(const char *output_path, size_t width, size_t hei bool ffmpeg_send_frame_flipped(FFMPEG *ffmpeg, void *data, size_t width, size_t height) { - DWORD written; + if (width > MAXDWORD/sizeof(uint32_t)) return false; + DWORD row_size = (DWORD)(sizeof(uint32_t)*width); for (size_t y = height; y > 0; --y) { - // TODO: handle ERROR_IO_PENDING - if (!WriteFile(ffmpeg->hPipeWrite, (uint32_t*)data + (y - 1)*width, sizeof(uint32_t)*width, &written, NULL)) { - TraceLog(LOG_ERROR, "FFMPEG: failed to write into ffmpeg pipe. System Error Code: %d", GetLastError()); - return false; + const unsigned char *row = (const unsigned char *)data + (y - 1)*row_size; + DWORD remaining = row_size; + while (remaining > 0) { + DWORD written = 0; + if (!WriteFile(ffmpeg->hPipeWrite, row, remaining, &written, NULL) || written == 0) { + TraceLog(LOG_ERROR, "FFMPEG: failed to write into ffmpeg pipe. System Error Code: %d", GetLastError()); + return false; + } + row += written; + remaining -= written; } } return true; diff --git a/src/musializer.c b/src/musializer.c index 7987af5..b4fc654 100644 --- a/src/musializer.c +++ b/src/musializer.c @@ -49,6 +49,7 @@ int main(void) plug_update(); } + plug_shutdown(); CloseAudioDevice(); CloseWindow(); diff --git a/src/platform.h b/src/platform.h new file mode 100644 index 0000000..7d93de6 --- /dev/null +++ b/src/platform.h @@ -0,0 +1,40 @@ +#ifndef MUSIALIZER_PLATFORM_H_ +#define MUSIALIZER_PLATFORM_H_ + +#include +#include +#include + +typedef struct Platform_Condition Platform_Condition; +typedef struct Platform_Mutex Platform_Mutex; +typedef struct Platform_Thread Platform_Thread; + +typedef void *(*Platform_Thread_Function)(void *arg); + +Platform_Mutex *platform_mutex_create(void); +void platform_mutex_destroy(Platform_Mutex *mutex); +void platform_mutex_lock(Platform_Mutex *mutex); +bool platform_mutex_try_lock(Platform_Mutex *mutex); +void platform_mutex_unlock(Platform_Mutex *mutex); + +Platform_Condition *platform_condition_create(void); +void platform_condition_destroy(Platform_Condition *condition); +void platform_condition_wait(Platform_Condition *condition, Platform_Mutex *mutex); +void platform_condition_signal(Platform_Condition *condition); +void platform_condition_broadcast(Platform_Condition *condition); + +Platform_Thread *platform_thread_start(Platform_Thread_Function function, void *arg); +void platform_thread_join(Platform_Thread *thread); + +// Creates an empty, uniquely named file in the system temporary directory. +// `suffix` should include its leading dot (for example, ".wav"). +bool platform_make_temp_file(char *path, size_t path_capacity, const char *prefix, const char *suffix); +bool platform_remove_file(const char *path); +FILE *platform_fopen(const char *path, const char *mode); +bool platform_read_entire_file(const char *path, unsigned char **data, size_t *size); + +// Runs a NULL-terminated argument vector and waits for it to finish. argv[0] +// is both the executable name and the first argument passed to the process. +bool platform_run_command(const char *const argv[], bool quiet); + +#endif // MUSIALIZER_PLATFORM_H_ diff --git a/src/platform_posix.c b/src/platform_posix.c new file mode 100644 index 0000000..41eee63 --- /dev/null +++ b/src/platform_posix.c @@ -0,0 +1,206 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform.h" + +struct Platform_Mutex { + pthread_mutex_t value; +}; + +struct Platform_Condition { + pthread_cond_t value; +}; + +struct Platform_Thread { + pthread_t value; +}; + +Platform_Mutex *platform_mutex_create(void) +{ + Platform_Mutex *mutex = malloc(sizeof(*mutex)); + if (mutex == NULL) return NULL; + if (pthread_mutex_init(&mutex->value, NULL) != 0) { + free(mutex); + return NULL; + } + return mutex; +} + +void platform_mutex_destroy(Platform_Mutex *mutex) +{ + if (mutex == NULL) return; + pthread_mutex_destroy(&mutex->value); + free(mutex); +} + +void platform_mutex_lock(Platform_Mutex *mutex) +{ + pthread_mutex_lock(&mutex->value); +} + +bool platform_mutex_try_lock(Platform_Mutex *mutex) +{ + return pthread_mutex_trylock(&mutex->value) == 0; +} + +void platform_mutex_unlock(Platform_Mutex *mutex) +{ + pthread_mutex_unlock(&mutex->value); +} + +Platform_Condition *platform_condition_create(void) +{ + Platform_Condition *condition = malloc(sizeof(*condition)); + if (condition == NULL) return NULL; + if (pthread_cond_init(&condition->value, NULL) != 0) { + free(condition); + return NULL; + } + return condition; +} + +void platform_condition_destroy(Platform_Condition *condition) +{ + if (condition == NULL) return; + pthread_cond_destroy(&condition->value); + free(condition); +} + +void platform_condition_wait(Platform_Condition *condition, Platform_Mutex *mutex) +{ + pthread_cond_wait(&condition->value, &mutex->value); +} + +void platform_condition_signal(Platform_Condition *condition) +{ + pthread_cond_signal(&condition->value); +} + +void platform_condition_broadcast(Platform_Condition *condition) +{ + pthread_cond_broadcast(&condition->value); +} + +Platform_Thread *platform_thread_start(Platform_Thread_Function function, void *arg) +{ + Platform_Thread *thread = malloc(sizeof(*thread)); + if (thread == NULL) return NULL; + if (pthread_create(&thread->value, NULL, function, arg) != 0) { + free(thread); + return NULL; + } + return thread; +} + +void platform_thread_join(Platform_Thread *thread) +{ + if (thread == NULL) return; + pthread_join(thread->value, NULL); + free(thread); +} + +bool platform_make_temp_file(char *path, size_t path_capacity, const char *prefix, const char *suffix) +{ + if (path == NULL || path_capacity == 0) return false; + path[0] = '\0'; + if (prefix == NULL) prefix = "musializer"; + if (suffix == NULL) suffix = ""; + + const char *temp_directory = getenv("TMPDIR"); + if (temp_directory == NULL || temp_directory[0] == '\0') temp_directory = "/tmp"; + + char base_path[4096]; + int length = snprintf(base_path, sizeof(base_path), "%s/%s_XXXXXX", temp_directory, prefix); + if (length < 0 || (size_t)length >= sizeof(base_path)) return false; + + int file = mkstemp(base_path); + if (file < 0) return false; + close(file); + + length = snprintf(path, path_capacity, "%s%s", base_path, suffix); + if (length < 0 || (size_t)length >= path_capacity) { + remove(base_path); + path[0] = '\0'; + return false; + } + + if (suffix[0] != '\0' && rename(base_path, path) != 0) { + remove(base_path); + path[0] = '\0'; + return false; + } + return true; +} + +bool platform_remove_file(const char *path) +{ + if (path == NULL || path[0] == '\0') return true; + return remove(path) == 0 || errno == ENOENT; +} + +FILE *platform_fopen(const char *path, const char *mode) +{ + return fopen(path, mode); +} + +bool platform_read_entire_file(const char *path, unsigned char **data, size_t *size) +{ + if (data == NULL || size == NULL) return false; + *data = NULL; + *size = 0; + + FILE *file = platform_fopen(path, "rb"); + if (file == NULL) return false; + if (fseek(file, 0, SEEK_END) != 0) goto fail; + long file_size = ftell(file); + if (file_size < 0 || fseek(file, 0, SEEK_SET) != 0) goto fail; + + unsigned char *contents = malloc(file_size > 0 ? (size_t)file_size : 1); + if (contents == NULL) goto fail; + if (file_size > 0 && fread(contents, 1, (size_t)file_size, file) != (size_t)file_size) { + free(contents); + goto fail; + } + + fclose(file); + *data = contents; + *size = (size_t)file_size; + return true; + +fail: + fclose(file); + return false; +} + +bool platform_run_command(const char *const argv[], bool quiet) +{ + if (argv == NULL || argv[0] == NULL) return false; + + pid_t child = fork(); + if (child < 0) return false; + if (child == 0) { + if (quiet) { + int null_file = open("/dev/null", O_WRONLY); + if (null_file >= 0) { + dup2(null_file, STDOUT_FILENO); + dup2(null_file, STDERR_FILENO); + close(null_file); + } + } + execvp(argv[0], (char *const *)argv); + _exit(127); + } + + int status = 0; + while (waitpid(child, &status, 0) < 0) { + if (errno != EINTR) return false; + } + return WIFEXITED(status) && WEXITSTATUS(status) == 0; +} diff --git a/src/platform_windows.c b/src/platform_windows.c new file mode 100644 index 0000000..3fd0616 --- /dev/null +++ b/src/platform_windows.c @@ -0,0 +1,270 @@ +#include +#include +#include +#include +#include +#include + +#define WIN32_LEAN_AND_MEAN +#include + +#include "platform.h" +#include "win32_utf8.h" + +struct Platform_Mutex { + CRITICAL_SECTION value; +}; + +struct Platform_Condition { + CONDITION_VARIABLE value; +}; + +struct Platform_Thread { + HANDLE handle; + Platform_Thread_Function function; + void *arg; +}; + +Platform_Mutex *platform_mutex_create(void) +{ + Platform_Mutex *mutex = malloc(sizeof(*mutex)); + if (mutex == NULL) return NULL; + InitializeCriticalSection(&mutex->value); + return mutex; +} + +void platform_mutex_destroy(Platform_Mutex *mutex) +{ + if (mutex == NULL) return; + DeleteCriticalSection(&mutex->value); + free(mutex); +} + +void platform_mutex_lock(Platform_Mutex *mutex) +{ + EnterCriticalSection(&mutex->value); +} + +bool platform_mutex_try_lock(Platform_Mutex *mutex) +{ + return TryEnterCriticalSection(&mutex->value) != 0; +} + +void platform_mutex_unlock(Platform_Mutex *mutex) +{ + LeaveCriticalSection(&mutex->value); +} + +Platform_Condition *platform_condition_create(void) +{ + Platform_Condition *condition = malloc(sizeof(*condition)); + if (condition == NULL) return NULL; + InitializeConditionVariable(&condition->value); + return condition; +} + +void platform_condition_destroy(Platform_Condition *condition) +{ + free(condition); +} + +void platform_condition_wait(Platform_Condition *condition, Platform_Mutex *mutex) +{ + SleepConditionVariableCS(&condition->value, &mutex->value, INFINITE); +} + +void platform_condition_signal(Platform_Condition *condition) +{ + WakeConditionVariable(&condition->value); +} + +void platform_condition_broadcast(Platform_Condition *condition) +{ + WakeAllConditionVariable(&condition->value); +} + +static unsigned __stdcall platform_thread_entry(void *data) +{ + Platform_Thread *thread = data; + thread->function(thread->arg); + return 0; +} + +Platform_Thread *platform_thread_start(Platform_Thread_Function function, void *arg) +{ + if (function == NULL) return NULL; + Platform_Thread *thread = malloc(sizeof(*thread)); + if (thread == NULL) return NULL; + thread->function = function; + thread->arg = arg; + thread->handle = (HANDLE)_beginthreadex(NULL, 0, platform_thread_entry, thread, 0, NULL); + if (thread->handle == NULL) { + free(thread); + return NULL; + } + return thread; +} + +void platform_thread_join(Platform_Thread *thread) +{ + if (thread == NULL) return; + WaitForSingleObject(thread->handle, INFINITE); + CloseHandle(thread->handle); + free(thread); +} + +bool platform_make_temp_file(char *path, size_t path_capacity, const char *prefix, const char *suffix) +{ + if (path == NULL || path_capacity == 0) return false; + path[0] = '\0'; + if (suffix == NULL) suffix = ""; + + wchar_t temp_directory[MAX_PATH + 1]; + DWORD directory_length = GetTempPathW(MAX_PATH + 1, temp_directory); + if (directory_length == 0 || directory_length > MAX_PATH) return false; + + wchar_t wide_prefix[4] = L"mus"; + if (prefix != NULL && prefix[0] != '\0') { + wchar_t *converted_prefix = win32_utf8_to_utf16(prefix); + if (converted_prefix != NULL) { + size_t i = 0; + for (; i < 3 && converted_prefix[i] != L'\0'; ++i) wide_prefix[i] = converted_prefix[i]; + wide_prefix[i] = L'\0'; + free(converted_prefix); + } + } + + wchar_t base_path[MAX_PATH + 1]; + if (GetTempFileNameW(temp_directory, wide_prefix, 0, base_path) == 0) return false; + + wchar_t *wide_suffix = win32_utf8_to_utf16(suffix); + if (wide_suffix == NULL) { + DeleteFileW(base_path); + return false; + } + + wchar_t final_path[MAX_PATH + 1]; + int length = swprintf(final_path, MAX_PATH + 1, L"%ls%ls", base_path, wide_suffix); + free(wide_suffix); + if (length < 0 || length > MAX_PATH) { + DeleteFileW(base_path); + return false; + } + + if (suffix[0] != '\0') { + if (!MoveFileW(base_path, final_path)) { + DeleteFileW(base_path); + return false; + } + } + + if (!win32_utf16_to_utf8(final_path, path, path_capacity)) { + DeleteFileW(final_path); + path[0] = '\0'; + return false; + } + return true; +} + +bool platform_remove_file(const char *path) +{ + if (path == NULL || path[0] == '\0') return true; + wchar_t *wide_path = win32_utf8_to_utf16(path); + if (wide_path == NULL) return false; + bool result = DeleteFileW(wide_path) != 0 || GetLastError() == ERROR_FILE_NOT_FOUND; + free(wide_path); + return result; +} + +FILE *platform_fopen(const char *path, const char *mode) +{ + wchar_t *wide_path = win32_utf8_to_utf16(path); + wchar_t *wide_mode = win32_utf8_to_utf16(mode); + if (wide_path == NULL || wide_mode == NULL) { + free(wide_path); + free(wide_mode); + return NULL; + } + FILE *file = _wfopen(wide_path, wide_mode); + free(wide_path); + free(wide_mode); + return file; +} + +bool platform_read_entire_file(const char *path, unsigned char **data, size_t *size) +{ + if (data == NULL || size == NULL) return false; + *data = NULL; + *size = 0; + + FILE *file = platform_fopen(path, "rb"); + if (file == NULL) return false; + if (_fseeki64(file, 0, SEEK_END) != 0) goto fail; + __int64 file_size = _ftelli64(file); + if (file_size < 0 || (uint64_t)file_size > SIZE_MAX || _fseeki64(file, 0, SEEK_SET) != 0) goto fail; + + unsigned char *contents = malloc(file_size > 0 ? (size_t)file_size : 1); + if (contents == NULL) goto fail; + if (file_size > 0 && fread(contents, 1, (size_t)file_size, file) != (size_t)file_size) { + free(contents); + goto fail; + } + + fclose(file); + *data = contents; + *size = (size_t)file_size; + return true; + +fail: + fclose(file); + return false; +} + +bool platform_run_command(const char *const argv[], bool quiet) +{ + wchar_t *command_line = win32_command_line_from_utf8_argv(argv); + if (command_line == NULL) return false; + + SECURITY_ATTRIBUTES security = {0}; + security.nLength = sizeof(security); + security.bInheritHandle = TRUE; + + HANDLE null_input = INVALID_HANDLE_VALUE; + HANDLE null_output = INVALID_HANDLE_VALUE; + STARTUPINFOW startup = {0}; + startup.cb = sizeof(startup); + BOOL inherit_handles = FALSE; + if (quiet) { + null_input = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + null_output = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (null_input == INVALID_HANDLE_VALUE || null_output == INVALID_HANDLE_VALUE) goto fail; + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = null_input; + startup.hStdOutput = null_output; + startup.hStdError = null_output; + inherit_handles = TRUE; + } + + PROCESS_INFORMATION process = {0}; + if (!CreateProcessW(NULL, command_line, NULL, NULL, inherit_handles, CREATE_NO_WINDOW, + NULL, NULL, &startup, &process)) goto fail; + + CloseHandle(process.hThread); + DWORD wait_result = WaitForSingleObject(process.hProcess, INFINITE); + DWORD exit_code = 1; + bool result = wait_result == WAIT_OBJECT_0 && + GetExitCodeProcess(process.hProcess, &exit_code) && exit_code == 0; + CloseHandle(process.hProcess); + if (null_input != INVALID_HANDLE_VALUE) CloseHandle(null_input); + if (null_output != INVALID_HANDLE_VALUE) CloseHandle(null_output); + free(command_line); + return result; + +fail: + if (null_input != INVALID_HANDLE_VALUE) CloseHandle(null_input); + if (null_output != INVALID_HANDLE_VALUE) CloseHandle(null_output); + free(command_line); + return false; +} diff --git a/src/plug.c b/src/plug.c index 84d0ef2..a57e130 100644 --- a/src/plug.c +++ b/src/plug.c @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -9,6 +10,7 @@ #include "build/config.h" #include "plug.h" #include "ffmpeg.h" +#include "platform.h" #define NOB_IMPLEMENTATION #define NOB_STRIP_PREFIX // #define NOB_WARN_DEPRECATED @@ -70,7 +72,12 @@ MUSIALIZER_PLUG void *plug_load_resource(const char *file_path, size_t *size) #define GLSL_VERSION 330 #define FFT_SIZE (1<<13) -#define FONT_SIZE 64 +#define FFT_LOG_STEP 1.06f +#define WAVEFORM_CACHE_BINS 4096 +#define EQ_LOW_FC 300.0f +#define EQ_HIGH_FC 8000.0f +#define EQ_SAMPLE_RATE 44100.0f +#define FONT_SIZE 48 #define PREVIEW_FPS 60 @@ -90,6 +97,7 @@ MUSIALIZER_PLUG void *plug_load_resource(const char *file_path, size_t *size) #define COLOR_HUD_BUTTON_BACKGROUND COLOR_TRACK_BUTTON_BACKGROUND #define COLOR_HUD_BUTTON_HOVEROVER COLOR_TRACK_BUTTON_HOVEROVER #define COLOR_POPUP_BACKGROUND ColorFromHSV(0, 0.75, 0.8) +#define COLOR_POPUP_SUCCESS ColorFromHSV(120, 0.75, 0.8) #define COLOR_TOOLTIP_BACKGROUND COLOR_TRACK_PANEL_BACKGROUND #define COLOR_TOOLTIP_FOREGROUND WHITE #define HUD_TIMER_SECS 1.0f @@ -103,10 +111,20 @@ MUSIALIZER_PLUG void *plug_load_resource(const char *file_path, size_t *size) #define KEY_TOGGLE_PLAY KEY_SPACE #define KEY_RENDER KEY_R +#define IS_KEY_DOWN_MOD(mod) (IsKeyDown(KEY_LEFT_##mod) || IsKeyDown(KEY_RIGHT_##mod)) +#define IS_CTRL_DOWN IS_KEY_DOWN_MOD(CONTROL) #define KEY_FULLSCREEN KEY_F #define KEY_CAPTURE KEY_C #define KEY_TOGGLE_MUTE KEY_M +static char *duplicate_string(const char *text) +{ + size_t length = strlen(text) + 1; + char *result = malloc(length); + if (result != NULL) memcpy(result, text, length); + return result; +} + // Microsoft could not update their parser OMEGALUL: // https://learn.microsoft.com/en-us/cpp/c-runtime-library/complex-math-support?view=msvc-170#types-used-in-complex-math #ifdef _MSC_VER @@ -130,8 +148,23 @@ MUSIALIZER_PLUG void *plug_load_resource(const char *file_path, size_t *size) typedef struct { char *file_path; Music music; + unsigned char *music_data; + Texture2D cover; + bool has_cover; } Track; +typedef enum { + REPEAT_NONE, + REPEAT_ALL, +} Repeat_Mode; + +typedef enum { + VIZ_BARS, + VIZ_CIRCULAR, + VIZ_WAVEFORM, + COUNT_VIZ_MODES, +} Viz_Mode; + typedef struct { Track *items; size_t count; @@ -140,8 +173,15 @@ typedef struct { typedef struct { float lifetime; + char message[64]; + bool success; } Popup; +typedef struct { + float min; + float max; +} Waveform_Peak; + #define PT_GET(pt, index) (assert(index < (pt)->count), &(pt)->items[((pt)->begin + index)%POPUP_TRAY_CAPACITY]) #define PT_FIRST(pt) PT_GET((pt), 0) #define PT_LAST(pt) PT_GET((pt), (pt)->count - 1) @@ -187,6 +227,8 @@ typedef struct { // Visualizer Tracks tracks; int current_track; + Repeat_Mode repeat_mode; + bool shuffle; Font font; Shader circle; int circle_radius_location; @@ -202,8 +244,14 @@ typedef struct { FFMPEG *ffmpeg; bool cancel_rendering; + // Waveform Preview + char *preview_waveform_path; + Waveform_Peak *preview_waveform; + size_t preview_waveform_count; + // FFT Analyzer float in_raw[FFT_SIZE]; + size_t fft_write_cursor; float in_win[FFT_SIZE]; Float_Complex out_raw[FFT_SIZE]; float out_log[FFT_SIZE]; @@ -214,6 +262,44 @@ typedef struct { uint64_t active_button_id; + // Equalizer + float eq_low; + float eq_mid; + float eq_high; + bool eq_low_drag; + bool eq_mid_drag; + bool eq_high_drag; + + // Audio EQ (1-pole filter states for 3-band splitter) + float eq_low_lp[2]; + float eq_high_lp[2]; + + // Crossfade + bool crossfading; + float crossfade_timer; + float crossfade_duration; + Music crossfade_music; + + // Playback tracking + bool track_was_playing; + + // Beat Detection + float beat_energy_history[43]; + size_t beat_history_index; + float beat_intensity; + bool beat_detected; + + // Visualization + Viz_Mode viz_mode; + float repeat_mode_label_timer; + + // Now-playing banner + int now_playing_track; + float now_playing_timer; + + // Keyboard shortcut overlay + bool show_help; + Popup_Tray pt; bool tooltip_show; @@ -230,11 +316,84 @@ typedef struct { } Plug; static Plug *p = NULL; +static Platform_Mutex *fft_mutex = NULL; +static float fft_hann_window[FFT_SIZE]; +static size_t fft_bit_reverse[FFT_SIZE]; +static Float_Complex fft_twiddles[FFT_SIZE/2]; +static struct { + size_t begin; + size_t end; +} fft_log_bins[FFT_SIZE/2]; +static size_t fft_log_bin_count; +static Color fft_colors[FFT_SIZE/2]; +static Color fft_colors_dim[FFT_SIZE/2]; +static float fft_circle_x[FFT_SIZE/2]; +static float fft_circle_y[FFT_SIZE/2]; +static float eq_alpha_low; +static float eq_alpha_high; + +static void fft_buffer_init(void) +{ + fft_mutex = platform_mutex_create(); + assert(fft_mutex != NULL && "Could not create FFT mutex"); + eq_alpha_low = 1.0f - expf(-2.0f*PI*EQ_LOW_FC/EQ_SAMPLE_RATE); + eq_alpha_high = 1.0f - expf(-2.0f*PI*EQ_HIGH_FC/EQ_SAMPLE_RATE); + + for (size_t i = 0; i < FFT_SIZE; ++i) { + float t = (float)i/(FFT_SIZE - 1); + fft_hann_window[i] = 0.5f - 0.5f*cosf(2*PI*t); + } + + size_t reversed = 0; + for (size_t i = 0; i < FFT_SIZE; ++i) { + fft_bit_reverse[i] = reversed; + if (i + 1 < FFT_SIZE) { + size_t bit = FFT_SIZE >> 1; + while (reversed & bit) { + reversed ^= bit; + bit >>= 1; + } + reversed ^= bit; + } + } + + for (size_t i = 0; i < FFT_SIZE/2; ++i) { + float angle = 2.0f*PI*(float)i/FFT_SIZE; + fft_twiddles[i] = cbuild(cosf(angle), sinf(angle)); + } + + fft_log_bin_count = 0; + for (float f = 1.0f; (size_t)f < FFT_SIZE/2; f = ceilf(f*FFT_LOG_STEP)) { + size_t begin = (size_t)f; + size_t end = (size_t)ceilf(f*FFT_LOG_STEP); + if (end > FFT_SIZE/2) end = FFT_SIZE/2; + fft_log_bins[fft_log_bin_count].begin = begin; + fft_log_bins[fft_log_bin_count].end = end; + fft_log_bin_count++; + } + + // The exact bin count depends on FFT_SIZE and FFT_LOG_STEP. Build stable + // render data once so the frame loop does not repeat color and trig work. + for (size_t i = 0; i < fft_log_bin_count; ++i) { + float hue = 360.0f*(float)i/fft_log_bin_count; + float angle = 2.0f*PI*(float)i/fft_log_bin_count - PI/2.0f; + fft_colors[i] = ColorFromHSV(hue, 0.75f, 1.0f); + fft_colors_dim[i] = ColorFromHSV(hue, 0.75f, 0.5f); + fft_circle_x[i] = cosf(angle); + fft_circle_y[i] = sinf(angle); + } +} + +static void fft_buffer_shutdown(void) +{ + platform_mutex_destroy(fft_mutex); + fft_mutex = NULL; +} static bool fft_settled(void) { float eps = 1e-3; - for (size_t i = 0; i < FFT_SIZE; ++i) { + for (size_t i = 0; i < fft_log_bin_count; ++i) { if (p->out_smooth[i] > eps) return false; if (p->out_smear[i] > eps) return false; } @@ -243,7 +402,10 @@ static bool fft_settled(void) static void fft_clean(void) { + platform_mutex_lock(fft_mutex); memset(p->in_raw, 0, sizeof(p->in_raw)); + p->fft_write_cursor = 0; + platform_mutex_unlock(fft_mutex); memset(p->in_win, 0, sizeof(p->in_win)); memset(p->out_raw, 0, sizeof(p->out_raw)); memset(p->out_log, 0, sizeof(p->out_log)); @@ -252,71 +414,63 @@ static void fft_clean(void) } // Ported from https://cp-algorithms.com/algebra/fft.html -static void fft(float in[], Float_Complex out[], size_t n) +static void fft(const float in[], Float_Complex out[], size_t n) { + assert(n == FFT_SIZE); for(size_t i = 0; i < n; i++) { - out[i] = cfromreal(in[i]); - } - - for (size_t i = 1, j = 0; i < n; i++) { - int bit = n >> 1; - for (; j & bit; bit >>= 1) j ^= bit; - j ^= bit; - if (i < j) { - Float_Complex temp = out[i]; - out[i] = out[j]; - out[j] = temp; - } + out[fft_bit_reverse[i]] = cfromreal(in[i]); } for (size_t len = 2; len <= n; len <<= 1) { - float ang = 2 * PI / len; - Float_Complex wlen = cbuild(cosf(ang), sinf(ang)); + size_t twiddle_step = n/len; for (size_t i = 0; i < n; i += len) { - Float_Complex w = cfromreal(1); for (size_t j = 0; j < len / 2; j++) { + Float_Complex w = fft_twiddles[j*twiddle_step]; Float_Complex u = out[i+j], v = mulcc(out[i+j+len/2], w); out[i+j] = addcc(u, v); out[i+j+len/2] = subcc(u, v); - w = mulcc(w, wlen); } } } } -static inline float amp(Float_Complex z) +static inline float power(Float_Complex z) { float a = crealf(z); float b = cimagf(z); - return logf(a*a + b*b); + return a*a + b*b; } static size_t fft_analyze(float dt) { + // Snapshot the audio-thread input, then release it before doing analyzer work. + platform_mutex_lock(fft_mutex); + size_t cursor = p->fft_write_cursor; + size_t tail = FFT_SIZE - cursor; + memcpy(p->in_win, p->in_raw + cursor, tail*sizeof(p->in_win[0])); + memcpy(p->in_win + tail, p->in_raw, cursor*sizeof(p->in_win[0])); + platform_mutex_unlock(fft_mutex); + // Apply the Hann Window on the Input - https://en.wikipedia.org/wiki/Hann_function for (size_t i = 0; i < FFT_SIZE; ++i) { - float t = (float)i/(FFT_SIZE - 1); - float hann = 0.5 - 0.5*cosf(2*PI*t); - p->in_win[i] = p->in_raw[i]*hann; + p->in_win[i] *= fft_hann_window[i]; } // FFT fft(p->in_win, p->out_raw, FFT_SIZE); // "Squash" into the Logarithmic Scale - float step = 1.06; - float lowf = 1.0f; - size_t m = 0; + size_t m = fft_log_bin_count; float max_amp = 1.0f; - for (float f = lowf; (size_t) f < FFT_SIZE/2; f = ceilf(f*step)) { - float f1 = ceilf(f*step); - float a = 0.0f; - for (size_t q = (size_t) f; q < FFT_SIZE/2 && q < (size_t) f1; ++q) { - float b = amp(p->out_raw[q]); - if (b > a) a = b; + for (size_t i = 0; i < m; ++i) { + float max_power = 1.0f; + for (size_t q = fft_log_bins[i].begin; q < fft_log_bins[i].end; ++q) { + float value = power(p->out_raw[q]); + if (value > max_power) max_power = value; } + float a = logf(max_power); if (max_amp < a) max_amp = a; - p->out_log[m++] = a; + p->out_log[i] = a; } // Normalize Frequencies to 0..1 range @@ -324,31 +478,146 @@ static size_t fft_analyze(float dt) p->out_log[i] /= max_amp; } + // Apply EQ gains + { + size_t low_end = m / 6; + if (low_end < 1) low_end = 1; + size_t mid_end = m / 2; + if (mid_end <= low_end) mid_end = low_end + 1; + for (size_t i = 0; i < m; ++i) { + float gain; + if (i < low_end) gain = p->eq_low * 2.0f; + else if (i < mid_end) gain = p->eq_mid * 2.0f; + else gain = p->eq_high * 2.0f; + p->out_log[i] *= gain; + } + } + // Smooth out and smear the values + float fast_decay = expf(-8.0f*dt); + float smoothness = 1.0f - fast_decay; + float smearness = 1.0f - expf(-3.0f*dt); for (size_t i = 0; i < m; ++i) { - float smoothness = 8; - p->out_smooth[i] += (p->out_log[i] - p->out_smooth[i])*smoothness*dt; - float smearness = 3; - p->out_smear[i] += (p->out_smooth[i] - p->out_smear[i])*smearness*dt; + p->out_smooth[i] += (p->out_log[i] - p->out_smooth[i])*smoothness; + p->out_smear[i] += (p->out_smooth[i] - p->out_smear[i])*smearness; + } + + // Beat Detection + { + float avg_energy = 0; + for (size_t i = 0; i < m; i++) { + avg_energy += p->out_log[i]; + } + avg_energy /= m; + + size_t beat_hist_len = sizeof(p->beat_energy_history)/sizeof(p->beat_energy_history[0]); + p->beat_energy_history[p->beat_history_index % beat_hist_len] = avg_energy; + p->beat_history_index++; + + if (p->beat_history_index > beat_hist_len) { + float sum = 0; + for (size_t i = 0; i < beat_hist_len; i++) { + sum += p->beat_energy_history[i]; + } + float avg = sum / beat_hist_len; + p->beat_detected = avg_energy > avg * 1.5f; + } else { + p->beat_detected = false; + } + + p->beat_intensity *= fast_decay; + if (p->beat_detected) p->beat_intensity = 1.0f; } return m; } +static const char *viz_mode_name(Viz_Mode mode) +{ + switch (mode) { + case VIZ_BARS: return "Bars"; + case VIZ_CIRCULAR: return "Circular"; + case VIZ_WAVEFORM: return "Waveform"; + default: return ""; + } +} + +static const char *repeat_mode_name(Repeat_Mode mode) +{ + switch (mode) { + case REPEAT_NONE: return "Repeat: Off"; + case REPEAT_ALL: return "Repeat: All"; + default: return ""; + } +} + +static void fft_render_circular(Rectangle boundary, size_t m) +{ + float cx = boundary.x + boundary.width / 2; + float cy = boundary.y + boundary.height / 2; + float max_radius = (boundary.width < boundary.height ? boundary.width : boundary.height) * 0.4f; + for (size_t i = 0; i < m; ++i) { + float t = p->out_smooth[i]; + Color color = fft_colors[i]; + float r = max_radius * (0.3f + 0.7f * t); + float px = cx + fft_circle_x[i] * r; + float py = cy + fft_circle_y[i] * r; + DrawCircleV((Vector2){px, py}, max_radius * 0.03f + max_radius * 0.05f * t, color); + DrawLineEx((Vector2){cx, cy}, (Vector2){px, py}, max_radius * 0.02f * t, ColorAlpha(color, 0.3f)); + } +} + +static void fft_render_waveform(Rectangle boundary, size_t m) +{ + float mid_y = boundary.y + boundary.height / 2; + float amp = boundary.height * 0.4f; + + for (size_t i = 0; i + 1 < m; ++i) { + float t0 = p->out_smooth[i]; + float t1 = p->out_smooth[i + 1]; + Color color = fft_colors[i]; + float x0 = boundary.x + (float)i / (m - 1) * boundary.width; + float x1 = boundary.x + (float)(i + 1) / (m - 1) * boundary.width; + float y0 = mid_y - t0 * amp; + float y1 = mid_y - t1 * amp; + DrawLineEx((Vector2){x0, y0}, (Vector2){x1, y1}, boundary.height * 0.02f, color); + } + + // Mirror below + for (size_t i = 0; i + 1 < m; ++i) { + float t0 = p->out_smooth[i]; + float t1 = p->out_smooth[i + 1]; + Color color = fft_colors_dim[i]; + float x0 = boundary.x + (float)i / (m - 1) * boundary.width; + float x1 = boundary.x + (float)(i + 1) / (m - 1) * boundary.width; + float y0 = mid_y + t0 * amp * 0.5f; + float y1 = mid_y + t1 * amp * 0.5f; + DrawLineEx((Vector2){x0, y0}, (Vector2){x1, y1}, boundary.height * 0.01f, ColorAlpha(color, 0.3f)); + } +} + static void fft_render(Rectangle boundary, size_t m) { + if (m == 0) return; + + switch (p->viz_mode) { + case VIZ_CIRCULAR: + fft_render_circular(boundary, m); + goto beat_flash; + case VIZ_WAVEFORM: + fft_render_waveform(boundary, m); + goto beat_flash; + default: + break; + } + // The width of a single bar float cell_width = boundary.width/m; - // Global color parameters - float saturation = 0.75f; - float value = 1.0f; - // Display the Bars for (size_t i = 0; i < m; ++i) { float t = p->out_smooth[i]; - float hue = (float)i/m; - Color color = ColorFromHSV(hue*360, saturation, value); + Color color = fft_colors[i]; Vector2 startPos = { boundary.x + i*cell_width + cell_width/2, boundary.y + boundary.height - boundary.height*2/3*t, @@ -370,8 +639,7 @@ static void fft_render(Rectangle boundary, size_t m) for (size_t i = 0; i < m; ++i) { float start = p->out_smear[i]; float end = p->out_smooth[i]; - float hue = (float)i/m; - Color color = ColorFromHSV(hue*360, saturation, value); + Color color = fft_colors[i]; Vector2 startPos = { boundary.x + i*cell_width + cell_width/2, boundary.y + boundary.height - boundary.height*2/3*start, @@ -410,8 +678,7 @@ static void fft_render(Rectangle boundary, size_t m) BeginShaderMode(p->circle); for (size_t i = 0; i < m; ++i) { float t = p->out_smooth[i]; - float hue = (float)i/m; - Color color = ColorFromHSV(hue*360, saturation, value); + Color color = fft_colors[i]; Vector2 center = { boundary.x + i*cell_width + cell_width/2, boundary.y + boundary.height - boundary.height*2/3*t, @@ -424,12 +691,115 @@ static void fft_render(Rectangle boundary, size_t m) DrawTextureEx(texture, position, 0, 2*radius, color); } EndShaderMode(); + + // Beat flash overlay +beat_flash: + if (p->beat_intensity > 0.01f) { + DrawRectangleRec(boundary, ColorAlpha(WHITE, p->beat_intensity * 0.15f)); + } + + // Viz mode label (top-right corner) + { + float t = GetTime(); + static float mode_switch_time = 0; + if (IsKeyPressed(KEY_V)) mode_switch_time = t; + if (t - mode_switch_time < 1.5f) { + const char *name = viz_mode_name(p->viz_mode); + float fs = 24; + Vector2 sz = MeasureTextEx(p->font, name, fs, 0); + Vector2 pos = { boundary.x + boundary.width - sz.x - 20, boundary.y + 10 }; + DrawRectangleRec((Rectangle){pos.x - 5, pos.y - 5, sz.x + 10, sz.y + 10}, ColorAlpha(COLOR_BACKGROUND, 0.7f)); + DrawTextEx(p->font, name, pos, fs, 0, WHITE); + } + } + + // Repeat mode label (below viz mode label) + if (p->repeat_mode_label_timer > 0) { + p->repeat_mode_label_timer -= GetFrameTime(); + const char *name = repeat_mode_name(p->repeat_mode); + float fs = 20; + Vector2 sz = MeasureTextEx(p->font, name, fs, 0); + Vector2 pos = { boundary.x + boundary.width - sz.x - 20, boundary.y + 10 + 30 }; + DrawRectangleRec((Rectangle){pos.x - 5, pos.y - 5, sz.x + 10, sz.y + 10}, ColorAlpha(COLOR_BACKGROUND, 0.7f)); + DrawTextEx(p->font, name, pos, fs, 0, ColorAlpha(WHITE, 0.8f)); + } } -static void fft_push(float frame) +static bool fft_push_frames(const float *samples, size_t frame_count, size_t channels, bool wait) { - memmove(p->in_raw, p->in_raw + 1, (FFT_SIZE - 1)*sizeof(p->in_raw[0])); - p->in_raw[FFT_SIZE-1] = frame; + if (frame_count == 0) return true; + assert(samples == NULL || channels > 0); + + if (wait) { + platform_mutex_lock(fft_mutex); + } else if (!platform_mutex_try_lock(fft_mutex)) { + // Visualization data is best-effort. Never make the real-time audio + // thread wait for the UI thread to finish taking an FFT snapshot. + return false; + } + + if (frame_count >= FFT_SIZE) { + size_t first_frame = frame_count - FFT_SIZE; + if (samples == NULL) { + memset(p->in_raw, 0, sizeof(p->in_raw)); + } else if (channels == 1) { + memcpy(p->in_raw, samples + first_frame, sizeof(p->in_raw)); + } else { + for (size_t i = 0; i < FFT_SIZE; ++i) { + p->in_raw[i] = samples[(first_frame + i)*channels]; + } + } + p->fft_write_cursor = 0; + } else { + size_t cursor = p->fft_write_cursor; + size_t first_count = FFT_SIZE - cursor; + if (first_count > frame_count) first_count = frame_count; + size_t second_count = frame_count - first_count; + + if (samples == NULL) { + memset(p->in_raw + cursor, 0, first_count*sizeof(p->in_raw[0])); + memset(p->in_raw, 0, second_count*sizeof(p->in_raw[0])); + } else if (channels == 1) { + memcpy(p->in_raw + cursor, samples, first_count*sizeof(p->in_raw[0])); + memcpy(p->in_raw, samples + first_count, second_count*sizeof(p->in_raw[0])); + } else { + for (size_t i = 0; i < first_count; ++i) { + p->in_raw[cursor + i] = samples[i*channels]; + } + for (size_t i = 0; i < second_count; ++i) { + p->in_raw[i] = samples[(first_count + i)*channels]; + } + } + + p->fft_write_cursor = (cursor + frame_count) & (FFT_SIZE - 1); + } + + platform_mutex_unlock(fft_mutex); + return true; +} + +static void apply_audio_eq(float (*buffer)[2], unsigned int frames) +{ + float gain_low = p->eq_low * 2.0f; + float gain_mid = p->eq_mid * 2.0f; + float gain_high = p->eq_high * 2.0f; + + for (unsigned int i = 0; i < frames; ++i) { + for (int ch = 0; ch < 2; ++ch) { + float input = buffer[i][ch]; + + p->eq_low_lp[ch] += eq_alpha_low * (input - p->eq_low_lp[ch]); + float low = p->eq_low_lp[ch]; + + p->eq_high_lp[ch] += eq_alpha_high * (input - p->eq_high_lp[ch]); + float low_mid = p->eq_high_lp[ch]; + + float mid = low_mid - low; + float high = input - low_mid; + + buffer[i][ch] = low * gain_low + mid * gain_mid + high * gain_high; + } + } } // TODO: make sure the audio callback is thread-safe @@ -438,9 +808,9 @@ static void callback(void *bufferData, unsigned int frames) // https://cdecl.org/?q=float+%28*fs%29%5B2%5D float (*fs)[2] = bufferData; - for (size_t i = 0; i < frames; ++i) { - fft_push(fs[i][0]); - } + apply_audio_eq(fs, frames); + + (void)fft_push_frames((float *)fs, frames, 2, false); #ifdef MUSIALIZER_MICROPHONE if (p->capturing) { @@ -469,7 +839,7 @@ static Track *current_track(void) } -static void popup_tray_push(Popup_Tray *pt) +static void popup_tray_push(Popup_Tray *pt, const char *message, bool success) { if (pt->count < POPUP_TRAY_CAPACITY) { if (pt->begin == 0) { @@ -481,16 +851,12 @@ static void popup_tray_push(Popup_Tray *pt) pt->slide += HUD_POPUP_SLIDEIN_SECS; PT_FIRST(pt)->lifetime = HUD_POPUP_LIFETIME_SECS + pt->slide; + PT_FIRST(pt)->success = success; + strncpy(PT_FIRST(pt)->message, message, sizeof(PT_FIRST(pt)->message) - 1); + PT_FIRST(pt)->message[sizeof(PT_FIRST(pt)->message) - 1] = '\0'; } } -static inline float signf(float x) -{ - if (x < 0.0) return -1; - if (x > 0.0) return 1; - return 0.0; -} - static void snap_segment_inside_other_segment(float ls, float rs, float *lt, float *rt) { float dt = *rt - *lt; @@ -603,10 +969,677 @@ static void tooltip(Rectangle boundary, const char *text, Side align, bool persi p->tooltip_element_boundary = boundary; } +static void unload_preview_waveform(void) +{ + free(p->preview_waveform); + p->preview_waveform = NULL; + p->preview_waveform_count = 0; + free(p->preview_waveform_path); + p->preview_waveform_path = NULL; +} + +#if defined(_WIN32) +#define FFMPEG_EXECUTABLE "ffmpeg.exe" +#else +#define FFMPEG_EXECUTABLE "ffmpeg" +#endif + +static bool convert_audio_with_ffmpeg(const char *source_path, const char *wav_path) +{ + const char *const argv[] = { + FFMPEG_EXECUTABLE, + "-nostdin", "-loglevel", "error", "-y", + "-i", source_path, + "-f", "wav", wav_path, + NULL, + }; + return platform_run_command(argv, true); +} + +static bool extract_cover_with_ffmpeg(const char *source_path, const char *cover_path) +{ + const char *const argv[] = { + FFMPEG_EXECUTABLE, + "-nostdin", "-loglevel", "error", "-y", + "-i", source_path, + "-an", "-frames:v", "1", cover_path, + NULL, + }; + return platform_run_command(argv, true); +} + +static Music load_music_from_memory_file(const char *file_path, unsigned char **music_data) +{ + Music music = {0}; + unsigned char *data = NULL; + size_t size = 0; + if (!platform_read_entire_file(file_path, &data, &size) || size > INT_MAX) { + free(data); + return music; + } + + music = LoadMusicStreamFromMemory(GetFileExtension(file_path), data, (int)size); + if (!IsMusicValid(music)) { + free(data); + return music; + } + + *music_data = data; + return music; +} + +static Music load_music_from_utf8_path(const char *file_path, unsigned char **music_data) +{ + *music_data = NULL; + Music music = LoadMusicStream(file_path); + if (IsMusicValid(music)) return music; + return load_music_from_memory_file(file_path, music_data); +} + +static Wave load_wave_from_utf8_path(const char *file_path) +{ + Wave wave = LoadWave(file_path); + if (wave.frameCount > 0) return wave; + + unsigned char *data = NULL; + size_t size = 0; + if (!platform_read_entire_file(file_path, &data, &size) || size > INT_MAX) { + free(data); + return wave; + } + wave = LoadWaveFromMemory(GetFileExtension(file_path), data, (int)size); + free(data); + return wave; +} + +static Wave load_wave_with_ffmpeg_fallback(const char *file_path) +{ + Wave wave = load_wave_from_utf8_path(file_path); + if (wave.frameCount > 0) return wave; + + char wav_path[4096] = {0}; + if (platform_make_temp_file(wav_path, sizeof(wav_path), "musializer", ".wav")) { + if (convert_audio_with_ffmpeg(file_path, wav_path)) { + wave = load_wave_from_utf8_path(wav_path); + } + platform_remove_file(wav_path); + } + return wave; +} + +static Waveform_Peak *create_waveform_cache(const char *file_path, size_t *peak_count_out) +{ + *peak_count_out = 0; + Wave wave = load_wave_with_ffmpeg_fallback(file_path); + if (wave.frameCount == 0 || wave.channels == 0) return NULL; + + float *samples = LoadWaveSamples(wave); + if (samples == NULL) { + UnloadWave(wave); + return NULL; + } + + size_t frame_count = wave.frameCount; + size_t channel_count = wave.channels; + size_t peak_count = frame_count < WAVEFORM_CACHE_BINS ? frame_count : WAVEFORM_CACHE_BINS; + Waveform_Peak *peaks = malloc(peak_count*sizeof(*peaks)); + if (peaks != NULL) { + for (size_t i = 0; i < peak_count; ++i) { + size_t begin = i*frame_count/peak_count; + size_t end = (i + 1)*frame_count/peak_count; + float min_value = 0.0f; + float max_value = 0.0f; + for (size_t frame = begin; frame < end; ++frame) { + float value = 0.0f; + for (size_t channel = 0; channel < channel_count; ++channel) { + value += samples[frame*channel_count + channel]; + } + value /= (float)channel_count; + if (value < min_value) min_value = value; + if (value > max_value) max_value = value; + } + peaks[i] = (Waveform_Peak){ .min = min_value, .max = max_value }; + } + *peak_count_out = peak_count; + } + + UnloadWaveSamples(samples); + UnloadWave(wave); + return peaks; +} + +static Image load_image_from_utf8_path(const char *file_path) +{ + Image image = LoadImage(file_path); + if (image.data != NULL) return image; + + unsigned char *data = NULL; + size_t size = 0; + if (!platform_read_entire_file(file_path, &data, &size) || size > INT_MAX) { + free(data); + return image; + } + image = LoadImageFromMemory(GetFileExtension(file_path), data, (int)size); + free(data); + return image; +} + +static void play_track(int index) +{ + if (index < 0 || (size_t)index >= p->tracks.count) return; + Track *old = current_track(); + if (old && old != &p->tracks.items[index]) { + p->crossfade_music = old->music; + p->crossfading = true; + p->crossfade_timer = 0.0f; + } + SeekMusicStream(p->tracks.items[index].music, 0); + PlayMusicStream(p->tracks.items[index].music); + if (old && old != &p->tracks.items[index]) { + SetMusicVolume(p->tracks.items[index].music, 0.0f); + } else { + SetMusicVolume(p->tracks.items[index].music, GetMasterVolume()); + } + p->current_track = index; + p->now_playing_track = index; + p->now_playing_timer = 2.5f; + fft_clean(); + { + char title[2048]; + snprintf(title, sizeof(title), "Musializer - %s", GetFileName(p->tracks.items[index].file_path)); + SetWindowTitle(title); + } +} + +static void next_track(void) +{ + if (p->tracks.count == 0) return; + if (p->shuffle && p->tracks.count > 1) { + int next; + do { + next = rand() % (int)p->tracks.count; + } while (next == p->current_track); + play_track(next); + } else { + int next = p->current_track + 1; + if (next >= (int)p->tracks.count) { + if (p->repeat_mode == REPEAT_ALL) next = 0; + else return; + } + play_track(next); + } +} + +static void prev_track(void) +{ + if (p->tracks.count == 0) return; + int prev = p->current_track - 1; + if (prev < 0) { + if (p->repeat_mode == REPEAT_ALL) prev = (int)p->tracks.count - 1; + else prev = 0; + } + play_track(prev); +} + +static bool is_audio_extension(const char *ext) +{ + if (!ext || ext[0] != '.') return false; + const char *e = ext + 1; + // raylib native + if ((e[0] == 'w' || e[0] == 'W') && (e[1] == 'a' || e[1] == 'A') && (e[2] == 'v' || e[2] == 'V') && !e[3]) return true; + if ((e[0] == 'o' || e[0] == 'O') && (e[1] == 'g' || e[1] == 'G') && (e[2] == 'g' || e[2] == 'G') && !e[3]) return true; + if ((e[0] == 'm' || e[0] == 'M') && (e[1] == 'p' || e[1] == 'P') && (e[2] == '3') && !e[3]) return true; + if ((e[0] == 'f' || e[0] == 'F') && (e[1] == 'l' || e[1] == 'L') && (e[2] == 'a' || e[2] == 'A') && (e[3] == 'c' || e[3] == 'C') && !e[4]) return true; + if ((e[0] == 'q' || e[0] == 'Q') && (e[1] == 'o' || e[1] == 'O') && (e[2] == 'a' || e[2] == 'A') && !e[3]) return true; + if ((e[0] == 'x' || e[0] == 'X') && (e[1] == 'm' || e[1] == 'M') && !e[2]) return true; + if ((e[0] == 'm' || e[0] == 'M') && (e[1] == 'o' || e[1] == 'O') && (e[2] == 'd' || e[2] == 'D') && !e[3]) return true; + // FFmpeg fallback + if ((e[0] == 'm' || e[0] == 'M') && (e[1] == '4' || e[1] == '4') && (e[2] == 'a' || e[2] == 'A') && !e[3]) return true; + if ((e[0] == 'a' || e[0] == 'A') && (e[1] == 'a' || e[1] == 'A') && (e[2] == 'c' || e[2] == 'C') && !e[3]) return true; + if ((e[0] == 'w' || e[0] == 'W') && (e[1] == 'm' || e[1] == 'M') && (e[2] == 'a' || e[2] == 'A') && !e[3]) return true; + if ((e[0] == 'a' || e[0] == 'A') && (e[1] == 'i' || e[1] == 'I') && (e[2] == 'f' || e[2] == 'F') && (e[3] == 'f' || e[3] == 'F') && !e[4]) return true; + if ((e[0] == 'a' || e[0] == 'A') && (e[1] == 'p' || e[1] == 'P') && (e[2] == 'e' || e[2] == 'E') && !e[3]) return true; + if ((e[0] == 'o' || e[0] == 'O') && (e[1] == 'p' || e[1] == 'P') && (e[2] == 'u' || e[2] == 'U') && (e[3] == 's' || e[3] == 'S') && !e[4]) return true; + return false; +} + +// ---------------------------------------------------------------------------- +// Threaded loader for FFmpeg conversion, cover extraction, and waveform caches +// ---------------------------------------------------------------------------- +typedef struct { + char source_path[4096]; + char wav_path[4096]; + char cover_path[4096]; + bool has_cover; + bool failed; + int target_track; // -1 = create new track, >=0 = apply cover to existing track + bool need_conversion; + bool need_cover; + bool need_waveform; + Waveform_Peak *waveform; + size_t waveform_count; +} Load_Job; + +static struct { + Platform_Thread *thread; + Platform_Mutex *mutex; + Platform_Condition *condition; + bool running; + Load_Job *pending; + size_t pending_count; + size_t pending_cap; + Load_Job *completed; + size_t completed_count; + size_t completed_cap; +} loader = {0}; + +static void *loader_thread(void *arg) +{ + (void)arg; + while (1) { + Load_Job job; + platform_mutex_lock(loader.mutex); + while (loader.pending_count == 0 && loader.running) { + platform_condition_wait(loader.condition, loader.mutex); + } + if (!loader.running) { + platform_mutex_unlock(loader.mutex); + return NULL; + } + job = loader.pending[0]; + memmove(loader.pending, loader.pending + 1, (loader.pending_count - 1) * sizeof(Load_Job)); + loader.pending_count--; + platform_mutex_unlock(loader.mutex); + + if (job.need_waveform) { + job.waveform = create_waveform_cache(job.source_path, &job.waveform_count); + job.failed = job.waveform == NULL; + goto done; + } + + // Process: FFmpeg conversion + if (job.need_conversion) { + if (!platform_make_temp_file(job.wav_path, sizeof(job.wav_path), "musializer", ".wav")) { + job.failed = true; + goto done; + } + if (!convert_audio_with_ffmpeg(job.source_path, job.wav_path)) { + platform_remove_file(job.wav_path); + job.wav_path[0] = '\0'; + job.failed = true; + goto done; + } + } + + // Process: cover extraction + if (job.need_cover && !job.failed) { + if (platform_make_temp_file(job.cover_path, sizeof(job.cover_path), "musializer_cover", ".jpg")) { + if (extract_cover_with_ffmpeg(job.source_path, job.cover_path)) { + job.has_cover = true; + } else { + platform_remove_file(job.cover_path); + job.cover_path[0] = '\0'; + } + } + } + + done: + platform_mutex_lock(loader.mutex); + if (loader.completed_count >= loader.completed_cap) { + loader.completed_cap = loader.completed_cap ? loader.completed_cap * 2 : 8; + loader.completed = realloc(loader.completed, loader.completed_cap * sizeof(Load_Job)); + assert(loader.completed != NULL); + } + loader.completed[loader.completed_count++] = job; + platform_mutex_unlock(loader.mutex); + } +} + +static bool loader_init(void) +{ + loader.mutex = platform_mutex_create(); + loader.condition = platform_condition_create(); + if (loader.mutex == NULL || loader.condition == NULL) goto fail; + + loader.running = true; + loader.thread = platform_thread_start(loader_thread, NULL); + if (loader.thread == NULL) goto fail; + return true; + +fail: + loader.running = false; + platform_condition_destroy(loader.condition); + platform_mutex_destroy(loader.mutex); + loader.condition = NULL; + loader.mutex = NULL; + return false; +} + +static void enqueue_load_job(const char *file_path, bool need_conversion) +{ + if (!loader.running && !loader_init()) { + popup_tray_push(&p->pt, "Could not start file loader", false); + return; + } + + Load_Job job = {0}; + strncpy(job.source_path, file_path, sizeof(job.source_path) - 1); + job.need_conversion = need_conversion; + job.need_cover = true; + job.target_track = -1; + + platform_mutex_lock(loader.mutex); + if (loader.pending_count >= loader.pending_cap) { + loader.pending_cap = loader.pending_cap ? loader.pending_cap * 2 : 8; + loader.pending = realloc(loader.pending, loader.pending_cap * sizeof(Load_Job)); + assert(loader.pending != NULL); + } + loader.pending[loader.pending_count++] = job; + platform_mutex_unlock(loader.mutex); + platform_condition_signal(loader.condition); +} + +static void enqueue_cover_job(const char *file_path, int track_index) +{ + if (!loader.running && !loader_init()) return; + + Load_Job job = {0}; + strncpy(job.source_path, file_path, sizeof(job.source_path) - 1); + job.need_conversion = false; + job.need_cover = true; + job.target_track = track_index; + + platform_mutex_lock(loader.mutex); + if (loader.pending_count >= loader.pending_cap) { + loader.pending_cap = loader.pending_cap ? loader.pending_cap * 2 : 8; + loader.pending = realloc(loader.pending, loader.pending_cap * sizeof(Load_Job)); + assert(loader.pending != NULL); + } + loader.pending[loader.pending_count++] = job; + platform_mutex_unlock(loader.mutex); + platform_condition_signal(loader.condition); +} + +static void enqueue_waveform_job(const char *file_path) +{ + if (!loader.running && !loader_init()) return; + + Load_Job job = {0}; + strncpy(job.source_path, file_path, sizeof(job.source_path) - 1); + job.need_waveform = true; + + platform_mutex_lock(loader.mutex); + // Only the current track's preview matters. Drop stale queued previews and + // put this one ahead of cover-art work so it appears promptly. + for (size_t i = 0; i < loader.pending_count; ) { + if (loader.pending[i].need_waveform) { + memmove(loader.pending + i, loader.pending + i + 1, + (loader.pending_count - i - 1)*sizeof(*loader.pending)); + loader.pending_count--; + } else { + i++; + } + } + if (loader.pending_count >= loader.pending_cap) { + loader.pending_cap = loader.pending_cap ? loader.pending_cap * 2 : 8; + loader.pending = realloc(loader.pending, loader.pending_cap * sizeof(Load_Job)); + assert(loader.pending != NULL); + } + memmove(loader.pending + 1, loader.pending, loader.pending_count*sizeof(*loader.pending)); + loader.pending[0] = job; + loader.pending_count++; + platform_mutex_unlock(loader.mutex); + platform_condition_signal(loader.condition); +} + +static void process_completed_loads(void) +{ + if (!loader.running) return; + + platform_mutex_lock(loader.mutex); + size_t n = loader.completed_count; + Load_Job *completed = loader.completed; + loader.completed = NULL; + loader.completed_count = 0; + loader.completed_cap = 0; + platform_mutex_unlock(loader.mutex); + + for (size_t i = 0; i < n; i++) { + Load_Job *job = &completed[i]; + if (job->need_waveform) { + if (!job->failed && p->preview_waveform_path != NULL && + strcmp(p->preview_waveform_path, job->source_path) == 0) { + free(p->preview_waveform); + p->preview_waveform = job->waveform; + p->preview_waveform_count = job->waveform_count; + job->waveform = NULL; + } + free(job->waveform); + } else if (job->target_track >= 0) { + // Cover-only job: apply cover to existing track + ptrdiff_t target_track = -1; + if ((size_t)job->target_track < p->tracks.count && + strcmp(p->tracks.items[job->target_track].file_path, job->source_path) == 0) { + target_track = job->target_track; + } else { + // A track can be reordered or removed while FFmpeg is working. + for (size_t track_index = 0; track_index < p->tracks.count; ++track_index) { + if (strcmp(p->tracks.items[track_index].file_path, job->source_path) == 0) { + target_track = (ptrdiff_t)track_index; + break; + } + } + } + if (!job->failed && job->has_cover && target_track >= 0) { + Image img = load_image_from_utf8_path(job->cover_path); + if (img.data != NULL) { + p->tracks.items[target_track].cover = LoadTextureFromImage(img); + p->tracks.items[target_track].has_cover = true; + UnloadImage(img); + } + } + if (job->cover_path[0]) platform_remove_file(job->cover_path); + } else if (!job->failed) { + // Full job: create new track + Music music = {0}; + unsigned char *music_data = NULL; + if (job->wav_path[0]) { + music = load_music_from_memory_file(job->wav_path, &music_data); + } + if (IsMusicValid(music)) { + music.looping = false; + AttachAudioStreamProcessor(music.stream, callback); + char *path = duplicate_string(job->source_path); + assert(path != NULL); + Track track = { .file_path = path, .music = music, .music_data = music_data, .has_cover = false }; + nob_da_append(&p->tracks, track); + if (job->has_cover && job->cover_path[0]) { + Image img = load_image_from_utf8_path(job->cover_path); + if (img.data != NULL) { + p->tracks.items[p->tracks.count - 1].cover = LoadTextureFromImage(img); + p->tracks.items[p->tracks.count - 1].has_cover = true; + UnloadImage(img); + } + } + // Auto-play first track if nothing is playing + if (current_track() == NULL) { + play_track((int)(p->tracks.count - 1)); + } + popup_tray_push(&p->pt, "Track loaded", true); + } else { + free(music_data); + popup_tray_push(&p->pt, "Could not load track", false); + } + if (job->wav_path[0]) platform_remove_file(job->wav_path); + if (job->cover_path[0]) platform_remove_file(job->cover_path); + } else { + popup_tray_push(&p->pt, "Failed to convert file", false); + } + } + free(completed); +} + +static void loader_stop(void) +{ + if (!loader.running) return; + platform_mutex_lock(loader.mutex); + loader.running = false; + platform_condition_broadcast(loader.condition); + platform_mutex_unlock(loader.mutex); + platform_thread_join(loader.thread); + + for (size_t i = 0; i < loader.pending_count; ++i) { + platform_remove_file(loader.pending[i].wav_path); + platform_remove_file(loader.pending[i].cover_path); + free(loader.pending[i].waveform); + } + for (size_t i = 0; i < loader.completed_count; ++i) { + platform_remove_file(loader.completed[i].wav_path); + platform_remove_file(loader.completed[i].cover_path); + free(loader.completed[i].waveform); + } + free(loader.pending); + free(loader.completed); + platform_condition_destroy(loader.condition); + platform_mutex_destroy(loader.mutex); + memset(&loader, 0, sizeof(loader)); +} +// ---------------------------------------------------------------------------- + +static void load_track_from_path(const char *file_path) +{ + if (!is_audio_extension(GetFileExtension(file_path))) { + popup_tray_push(&p->pt, "Unsupported file format", false); + return; + } + + // Try native raylib load first (fast, on main thread) + unsigned char *music_data = NULL; + Music music = load_music_from_utf8_path(file_path, &music_data); + if (IsMusicValid(music)) { + music.looping = false; + AttachAudioStreamProcessor(music.stream, callback); + char *path = duplicate_string(file_path); + assert(path != NULL); + nob_da_append(&p->tracks, (CLITERAL(Track){ + .file_path = path, + .music = music, + .music_data = music_data, + .has_cover = false, + })); + // Extract cover art in background thread + enqueue_cover_job(file_path, (int)(p->tracks.count - 1)); + return; + } + + // Need FFmpeg conversion — enqueue to background thread + enqueue_load_job(file_path, true); +} + +static void load_m3u_playlist(const char *file_path) +{ + FILE *f = platform_fopen(file_path, "r"); + if (!f) { + popup_tray_push(&p->pt, "Could not open playlist", false); + return; + } + char line[4096]; + while (fgets(line, sizeof(line), f)) { + size_t len = strlen(line); + while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r')) line[--len] = '\0'; + if (len == 0 || line[0] == '#') continue; + load_track_from_path(line); + } + fclose(f); + + if (current_track() == NULL && p->tracks.count > 0) { + p->current_track = 0; + PlayMusicStream(p->tracks.items[0].music); + } +} + +static void startup_autoplay(void) +{ + if (current_track() == NULL && p->tracks.count > 0) { + play_track(0); + } +} + +static void open_files_dialog(void) +{ + char const *filter_patterns[] = {"*.wav", "*.ogg", "*.mp3", "*.qoa", "*.xm", "*.mod", "*.flac", "*.m4a", "*.aac", "*.wma", "*.aiff", "*.ape", "*.opus", "*.m3u", "*.m3u8"}; + char *result = tinyfd_openFileDialog( + "Select music files", + "./", + NOB_ARRAY_LEN(filter_patterns), + filter_patterns, + "audio files", + 1); + if (!result) return; + while (*result) { + char *next = strchr(result, '|'); + if (next) *next = '\0'; + const char *ext = GetFileExtension(result); + if (ext && (strcmp(ext, ".m3u") == 0 || strcmp(ext, ".m3u8") == 0)) { + load_m3u_playlist(result); + } else { + load_track_from_path(result); + } + if (!next) break; + result = next + 1; + } + startup_autoplay(); +} + +static void format_time(char *buf, size_t buf_sz, float secs) +{ + if (secs < 0) secs = 0; + int m = (int)(secs / 60); + int s = (int)secs % 60; + snprintf(buf, buf_sz, "%d:%02d", m, s); +} + static void timeline(Rectangle timeline_boundary, Track *track) { DrawRectangleRec(timeline_boundary, COLOR_TIMELINE_BACKGROUND); + // Decode and downsample the waveform on the loader thread. Decoding a long + // track here used to stall both rendering and calls to UpdateMusicStream(). + if (p->preview_waveform_path == NULL || strcmp(p->preview_waveform_path, track->file_path) != 0) { + unload_preview_waveform(); + p->preview_waveform_path = duplicate_string(track->file_path); + if (p->preview_waveform_path != NULL) enqueue_waveform_job(track->file_path); + } + + // Draw the fixed-size peak cache instead of rescanning every sample in the + // decoded track on every frame. + if (p->preview_waveform != NULL && p->preview_waveform_count > 0) { + size_t peak_count = p->preview_waveform_count; + int width = (int)timeline_boundary.width; + float h = timeline_boundary.height; + float mid_y = timeline_boundary.y + h / 2; + Color wave_color = ColorAlpha(WHITE, 0.25); + + for (int x = 0; x < width; ++x) { + size_t start = (size_t)x*peak_count/(size_t)width; + size_t end = (size_t)(x + 1)*peak_count/(size_t)width; + if (start >= peak_count) continue; + if (end <= start) end = start + 1; + if (end > peak_count) end = peak_count; + + float min_val = 0.0f, max_val = 0.0f; + for (size_t i = start; i < end; ++i) { + if (p->preview_waveform[i].min < min_val) min_val = p->preview_waveform[i].min; + if (p->preview_waveform[i].max > max_val) max_val = p->preview_waveform[i].max; + } + + float y0 = mid_y - max_val * h / 2; + float y1 = mid_y - min_val * h / 2; + if (y0 > y1) { float tmp = y0; y0 = y1; y1 = tmp; } + DrawRectangle((int)timeline_boundary.x + x, (int)y0, 1, (int)(y1 - y0 + 1), wave_color); + } + } + float played = GetMusicTimePlayed(track->music); float len = GetMusicTimeLength(track->music); float x = played/len*GetScreenWidth(); @@ -620,17 +1653,51 @@ static void timeline(Rectangle timeline_boundary, Track *track) }; DrawLineEx(startPos, endPos, 10, COLOR_TIMELINE_CURSOR); + // Time labels + { + float fs = 18; + char buf[32]; + + // Current time at cursor + format_time(buf, sizeof(buf), played); + Vector2 cur_sz = MeasureTextEx(p->font, buf, fs, 0); + float cur_x = x - cur_sz.x/2; + if (cur_x < timeline_boundary.x) cur_x = timeline_boundary.x; + if (cur_x + cur_sz.x > timeline_boundary.x + timeline_boundary.width) + cur_x = timeline_boundary.x + timeline_boundary.width - cur_sz.x; + float cur_y = timeline_boundary.y + 5; + DrawRectangleRec((Rectangle){cur_x - 3, cur_y - 2, cur_sz.x + 6, cur_sz.y + 4}, ColorAlpha(COLOR_TIMELINE_BACKGROUND, 0.8f)); + DrawTextEx(p->font, buf, (Vector2){cur_x, cur_y}, fs, 0, COLOR_TIMELINE_CURSOR); + + // Remaining time at right edge + format_time(buf, sizeof(buf), len - played); + Vector2 rem_sz = MeasureTextEx(p->font, buf, fs, 0); + float rem_x = timeline_boundary.x + timeline_boundary.width - rem_sz.x - 5; + float rem_y = timeline_boundary.y + timeline_boundary.height - rem_sz.y - 5; + DrawRectangleRec((Rectangle){rem_x - 3, rem_y - 2, rem_sz.x + 6, rem_sz.y + 4}, ColorAlpha(COLOR_TIMELINE_BACKGROUND, 0.8f)); + DrawTextEx(p->font, buf, (Vector2){rem_x, rem_y}, fs, 0, ColorAlpha(WHITE, 0.6f)); + } + + static bool dragging = false; Vector2 mouse = GetMousePosition(); - if (CheckCollisionPointRec(mouse, timeline_boundary)) { + if (dragging) { + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { + dragging = false; + } else { + float t = (mouse.x - timeline_boundary.x)/timeline_boundary.width; + if (t < 0) t = 0; + if (t > 1) t = 1; + SeekMusicStream(track->music, t*len); + } + } else if (CheckCollisionPointRec(mouse, timeline_boundary)) { if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + dragging = true; float t = (mouse.x - timeline_boundary.x)/timeline_boundary.width; SeekMusicStream(track->music, t*len); } - } // TODO: enable the user to render a specific region instead of the whole song. - // TODO: visualize sound wave on the timeline } typedef enum { @@ -723,6 +1790,7 @@ static void tracks_panel_with_location(const char *file, int line, Rectangle pan DrawRectangleRec(panel_boundary, COLOR_TRACK_PANEL_BACKGROUND); Vector2 mouse = GetMousePosition(); + bool any_mouse_press = IsMouseButtonPressed(MOUSE_BUTTON_LEFT); float scroll_bar_width = panel_boundary.width*0.03; float item_size = panel_boundary.width*0.2; @@ -743,6 +1811,49 @@ static void tracks_panel_with_location(const char *file, int line, Rectangle pan panel_scroll = (mouse.y - panel_boundary.y - scrolling_mouse_offset)/visible_area_size*entire_scrollable_area; } + // Drag-and-drop state + static ptrdiff_t drag_from = -1; + static float drag_start_mouse_y = 0; + static bool is_dragging = false; + + // End drag on mouse release + if (drag_from >= 0 && IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { + if (is_dragging) { + size_t from = (size_t)drag_from; + float list_y = mouse.y - panel_boundary.y + panel_scroll - item_size*0.1f; + ptrdiff_t raw_target = (ptrdiff_t)(list_y / item_size); + if (raw_target < 0) raw_target = 0; + if ((size_t)raw_target > p->tracks.count) raw_target = (ptrdiff_t)p->tracks.count; + size_t to = (size_t)raw_target; + + if (from < to) to--; + if (to != from && to < p->tracks.count) { + Track tmp = p->tracks.items[from]; + if (from < to) { + memmove(&p->tracks.items[from], &p->tracks.items[from + 1], + (to - from) * sizeof(Track)); + } else { + memmove(&p->tracks.items[to + 1], &p->tracks.items[to], + (from - to) * sizeof(Track)); + } + p->tracks.items[to] = tmp; + if (p->current_track == (int)from) { + p->current_track = (int)to; + } else if (from < to) { + if (p->current_track > (int)from && p->current_track <= (int)to) { + p->current_track--; + } + } else { + if (p->current_track >= (int)to && p->current_track < (int)from) { + p->current_track++; + } + } + } + } + drag_from = -1; + is_dragging = false; + } + float min_scroll = 0; if (panel_scroll < min_scroll) panel_scroll = min_scroll; float max_scroll = entire_scrollable_area - visible_area_size; @@ -754,6 +1865,9 @@ static void tracks_panel_with_location(const char *file, int line, Rectangle pan id = djb2(id, file, strlen(file)); id = djb2(id, &line, sizeof(line)); + ptrdiff_t remove_index = -1; + ptrdiff_t move_from = -1; + for (size_t i = 0; i < p->tracks.count; ++i) { Rectangle item_boundary = { .x = panel_boundary.x + panel_padding, @@ -761,73 +1875,320 @@ static void tracks_panel_with_location(const char *file, int line, Rectangle pan .width = panel_boundary.width - panel_padding*2 - scroll_bar_width, .height = item_size - panel_padding*2, }; - Color color; - if (((int) i != p->current_track)) { - uint64_t item_id = djb2(id, &i, sizeof(i)); - int state = button_with_id(item_id, GetCollisionRec(panel_boundary, item_boundary)); - if (state & BS_HOVEROVER) { - color = COLOR_TRACK_BUTTON_HOVEROVER; - } else { - color = COLOR_TRACK_BUTTON_BACKGROUND; + if (item_boundary.y + item_boundary.height < panel_boundary.y || + item_boundary.y > panel_boundary.y + panel_boundary.height) + continue; + + bool is_current = ((int)i == p->current_track); + uint64_t item_id = djb2(id, &i, sizeof(i)); + + // Manually compute hover (doesn't consume active_button_id) + Rectangle clipped_item = GetCollisionRec(panel_boundary, item_boundary); + bool item_hover = CheckCollisionPointRec(mouse, clipped_item); + + // Action button dimensions (needed for layout, before item row check) + float cover_size = item_boundary.height * 0.75; + float cover_pad = (item_boundary.height - cover_size) / 2; + float text_x = item_boundary.x + cover_size + cover_pad * 3; + float btn_w = item_hover && p->tracks.count > 1 ? item_boundary.height * 0.4 * 3 : 0; + + // Action button STATE detection (BEFORE item row, so they claim active_button_id) + int action_bs_up = 0, action_bs_dn = 0, action_bs_rm = 0; + if (item_hover && p->tracks.count > 1) { + float bsize = item_boundary.height * 0.4; + float bx = item_boundary.x + item_boundary.width - bsize * 3 - 5; + float by = item_boundary.y + (item_boundary.height - bsize) / 2; + + if (i > 0) { + Rectangle btn = { bx, by, bsize, bsize }; + uint64_t bid = djb2(item_id, "up", 2); + action_bs_up = button_with_id(bid, btn); + if (action_bs_up & BS_CLICKED) move_from = (ptrdiff_t)i; } - if (state & BS_CLICKED) { - Track *track = current_track(); - if (track) StopMusicStream(track->music); - PlayMusicStream(p->tracks.items[i].music); - p->current_track = i; + if (i + 1 < p->tracks.count) { + Rectangle btn = { bx + bsize, by, bsize, bsize }; + uint64_t bid = djb2(item_id, "dn", 2); + action_bs_dn = button_with_id(bid, btn); + if (action_bs_dn & BS_CLICKED) move_from = (ptrdiff_t)i + 1; } - } else { + { + Rectangle btn = { bx + bsize * 2, by, bsize, bsize }; + uint64_t bid = djb2(item_id, "rm", 2); + action_bs_rm = button_with_id(bid, btn); + if (action_bs_rm & BS_CLICKED) remove_index = (ptrdiff_t)i; + } + } + + // Drag start: left-click on item body (not on action buttons) + if (drag_from < 0 && item_hover && any_mouse_press) { + bool on_action_btn = false; + if (p->tracks.count > 1) { + float dbsize = item_boundary.height * 0.4f; + float dbx = item_boundary.x + item_boundary.width - dbsize * 3.f - 5.f; + float dby = item_boundary.y + (item_boundary.height - dbsize) / 2.f; + if (i > 0) on_action_btn = on_action_btn || CheckCollisionPointRec(mouse, (Rectangle){dbx, dby, dbsize, dbsize}); + if (i + 1 < p->tracks.count) on_action_btn = on_action_btn || CheckCollisionPointRec(mouse, (Rectangle){dbx + dbsize, dby, dbsize, dbsize}); + on_action_btn = on_action_btn || CheckCollisionPointRec(mouse, (Rectangle){dbx + dbsize * 2.f, dby, dbsize, dbsize}); + } + if (!on_action_btn) { + drag_from = (ptrdiff_t)i; + drag_start_mouse_y = mouse.y; + } + } + + // Item row button (won't steal click from action buttons) + int state = button_with_id(item_id, clipped_item); + + Color color; + if (is_current) { color = COLOR_TRACK_BUTTON_SELECTED; + } else if (state & BS_HOVEROVER) { + color = COLOR_TRACK_BUTTON_HOVEROVER; + } else { + color = COLOR_TRACK_BUTTON_BACKGROUND; + } + + if (state & BS_CLICKED && !is_current) { + play_track((int)i); } - // TODO: enable MSAA so the rounded rectangles look better - // That triggers an old raylib bug with circles tho, so we will have to look into that + + if (is_dragging && drag_from == (ptrdiff_t)i) continue; + DrawRectangleRounded(item_boundary, 0.2, 20, color); + // Cover art thumbnail + if (p->tracks.items[i].has_cover) { + Rectangle dest = { + .x = item_boundary.x + cover_pad, + .y = item_boundary.y + cover_pad, + .width = cover_size, + .height = cover_size, + }; + Rectangle source = { 0, 0, (float)p->tracks.items[i].cover.width, (float)p->tracks.items[i].cover.height }; + DrawTexturePro(p->tracks.items[i].cover, source, dest, (Vector2){0}, 0, WHITE); + } else { + Rectangle dest = { + .x = item_boundary.x + cover_pad, + .y = item_boundary.y + cover_pad, + .width = cover_size, + .height = cover_size, + }; + DrawRectangleRounded(dest, 0.2, 10, ColorAlpha(WHITE, 0.05)); + } + + // Draw action buttons (AFTER item background) + if (item_hover && p->tracks.count > 1) { + float bsize = item_boundary.height * 0.4; + float bx = item_boundary.x + item_boundary.width - bsize * 3 - 5; + float by = item_boundary.y + (item_boundary.height - bsize) / 2; + + if (i > 0) { + Rectangle btn = { bx, by, bsize, bsize }; + Color bc = (action_bs_up & BS_HOVEROVER) ? COLOR_ACCENT : ColorAlpha(WHITE, 0.4f); + DrawRectangleRounded(btn, 0.3, 10, bc); + float lw = bsize * 0.15f, cx = btn.x + btn.width / 2, cy = btn.y + btn.height / 2, ht = bsize * 0.25f; + DrawLineEx((Vector2){cx, cy - ht}, (Vector2){cx - ht, cy}, lw, WHITE); + DrawLineEx((Vector2){cx, cy - ht}, (Vector2){cx + ht, cy}, lw, WHITE); + } + if (i + 1 < p->tracks.count) { + Rectangle btn = { bx + bsize, by, bsize, bsize }; + Color bc = (action_bs_dn & BS_HOVEROVER) ? COLOR_ACCENT : ColorAlpha(WHITE, 0.4f); + DrawRectangleRounded(btn, 0.3, 10, bc); + float lw = bsize * 0.15f, cx = btn.x + btn.width / 2, cy = btn.y + btn.height / 2, ht = bsize * 0.25f; + DrawLineEx((Vector2){cx, cy + ht}, (Vector2){cx - ht, cy}, lw, WHITE); + DrawLineEx((Vector2){cx, cy + ht}, (Vector2){cx + ht, cy}, lw, WHITE); + } + { + Rectangle btn = { bx + bsize * 2, by, bsize, bsize }; + Color bc = (action_bs_rm & BS_HOVEROVER) ? (Color){255, 60, 60, 255} : ColorAlpha(WHITE, 0.4f); + DrawRectangleRounded(btn, 0.3, 10, bc); + float lw = bsize * 0.15f, cx = btn.x + btn.width / 2, cy = btn.y + btn.height / 2, ht = bsize * 0.25f; + DrawLineEx((Vector2){cx - ht, cy - ht}, (Vector2){cx + ht, cy + ht}, lw, WHITE); + DrawLineEx((Vector2){cx + ht, cy - ht}, (Vector2){cx - ht, cy + ht}, lw, WHITE); + } + } + + float text_padding = 5; + float max_width = item_boundary.x + item_boundary.width - text_x - text_padding - btn_w; + if (max_width < 10) max_width = 10; + const char *text = GetFileName(p->tracks.items[i].file_path); - float fontSize = item_boundary.height*0.5; - float text_padding = item_boundary.width*0.05; + float fontSize = item_boundary.height * 0.45; Vector2 size = MeasureTextEx(p->font, text, fontSize, 0); - Vector2 position = { - .x = item_boundary.x + text_padding, - .y = item_boundary.y + item_boundary.height*0.5 - size.y*0.5, - }; - // TODO: use SDF fonts - // Label overflow scroll handler - float max_width = item_boundary.width - text_padding*2; - uint64_t item_id = djb2(id, &i, sizeof(i)); - int state = button_with_id(item_id, GetCollisionRec(panel_boundary, item_boundary)); - - if ((size.x > max_width)) { // <-- Item needs ScissorMode - BeginScissorMode(position.x, position.y, max_width, item_boundary.height); - - if (state & BS_HOVEROVER) { // <-- Current item is being hovered on and needs scrolling - static float dt = 0; - static uint64_t hovered_label_id = 0; - static int px_shift = 0; - static bool scroll_left = true; - - dt += GetFrameTime(); - if (item_id != hovered_label_id) { // <-- But it is not same as the last hovered item, so reset the shift - px_shift = 0; - scroll_left = true; - hovered_label_id = item_id; - } else { // <-- it is same as the last hovered item, so count the shift - if (dt > TRACKLABEL_SCROLL_SECS) { - dt = 0.0f; - if ((abs(px_shift) >= size.x - max_width + 10) || (px_shift == 10)) { // <-- End of scroll (with 10 padding) - scroll_left = !scroll_left; // <-- flip direction + + // Track label + { + Vector2 position = { + .x = text_x, + .y = item_boundary.y + item_boundary.height*0.5 - size.y*0.5, + }; + if (size.x > max_width) { + BeginScissorMode(position.x, position.y, max_width, item_boundary.height); + if (state & BS_HOVEROVER) { + static float dt = 0; + static uint64_t hovered_label_id = 0; + static int px_shift = 0; + static bool scroll_left = true; + dt += GetFrameTime(); + if (item_id != hovered_label_id) { + px_shift = 0; + scroll_left = true; + hovered_label_id = item_id; + } else { + if (dt > TRACKLABEL_SCROLL_SECS) { + dt = 0.0f; + if ((abs(px_shift) >= (int)(size.x - max_width + 10)) || (px_shift == 10)) { + scroll_left = !scroll_left; + } + scroll_left ? --px_shift : ++px_shift; } - scroll_left ? --px_shift : ++px_shift; } + position.x += (float)px_shift; } - position.x += px_shift; // <-- Apply the shift + track_label(p->font, text, position, fontSize, WHITE); + EndScissorMode(); + } else { + track_label(p->font, text, position, fontSize, WHITE); } - track_label(p->font, text, position, fontSize, WHITE); + } + + // Duration + { + float len = GetMusicTimeLength(p->tracks.items[i].music); + if (len > 0) { + char buf[32]; + format_time(buf, sizeof(buf), len); + float fs = item_boundary.height * 0.35; + Vector2 sz = MeasureTextEx(p->font, buf, fs, 0); + float dx = item_boundary.x + item_boundary.width - text_padding - btn_w - sz.x - 5; + if (dx >= text_x + 10) { + DrawTextEx(p->font, buf, (Vector2){dx, item_boundary.y + item_boundary.height*0.5 - sz.y*0.5}, fs, 0, ColorAlpha(WHITE, 0.4f)); + } + } + } + } + + // Drag visuals + if (drag_from >= 0 && IsMouseButtonDown(MOUSE_BUTTON_LEFT)) { + if (!is_dragging && fabsf(mouse.y - drag_start_mouse_y) > item_size * 0.2f) { + is_dragging = true; + } + if (is_dragging) { + float list_y = mouse.y - (panel_boundary.y + panel_padding - panel_scroll); + ptrdiff_t zone = (ptrdiff_t)(list_y / item_size + 0.5f); + if (zone < 0) zone = 0; + if ((size_t)zone > p->tracks.count) zone = (ptrdiff_t)p->tracks.count; + if (zone == drag_from || zone == drag_from + 1) zone = -1; + + // Auto-scroll when near panel edges + float edge_margin = item_size * 0.5f; + if (mouse.y < panel_boundary.y + edge_margin) { + panel_velocity = -item_size * 10; + } else if (mouse.y > panel_boundary.y + panel_boundary.height - edge_margin) { + panel_velocity = item_size * 10; + } + + // Drop indicator line + if (zone >= 0) { + float line_y = (float)zone * item_size + panel_boundary.y + panel_padding - panel_scroll; + Rectangle line = { + panel_boundary.x + panel_padding, + line_y - 2, + panel_boundary.width - panel_padding * 2 - scroll_bar_width, + 3, + }; + BeginScissorMode(panel_boundary.x, panel_boundary.y, panel_boundary.width - scroll_bar_width, panel_boundary.height); + DrawRectangleRec(line, COLOR_ACCENT); + EndScissorMode(); + } + + // Dragged item at cursor + Track *dt = &p->tracks.items[drag_from]; + float drag_item_h = item_size - panel_padding * 2; + float drag_item_w = panel_boundary.width - panel_padding * 2 - scroll_bar_width; + Rectangle drag_boundary = { + mouse.x - drag_item_w * 0.3f, + mouse.y - drag_item_h * 0.1f, + drag_item_w, + drag_item_h, + }; + if (drag_boundary.x < panel_boundary.x + panel_padding) drag_boundary.x = panel_boundary.x + panel_padding; + if (drag_boundary.x + drag_boundary.width > panel_boundary.x + panel_boundary.width - panel_padding - scroll_bar_width) + drag_boundary.x = panel_boundary.x + panel_boundary.width - panel_padding - scroll_bar_width - drag_boundary.width; + BeginScissorMode(panel_boundary.x, panel_boundary.y, panel_boundary.width - scroll_bar_width, panel_boundary.height); + DrawRectangleRounded(drag_boundary, 0.2, 20, ColorAlpha(COLOR_ACCENT, 0.7f)); + float dcover = drag_boundary.height * 0.75f; + float dpad = (drag_boundary.height - dcover) / 2; + if (dt->has_cover) { + Rectangle dsrc = {0, 0, (float)dt->cover.width, (float)dt->cover.height}; + Rectangle ddest = {drag_boundary.x + dpad, drag_boundary.y + dpad, dcover, dcover}; + DrawTexturePro(dt->cover, dsrc, ddest, (Vector2){0}, 0, ColorAlpha(WHITE, 0.8f)); + } + const char *dtext = GetFileName(dt->file_path); + float dfs = drag_boundary.height * 0.45f; + Vector2 dtext_sz = MeasureTextEx(p->font, dtext, dfs, 0); + float dtx = drag_boundary.x + dcover + dpad * 3; + float dty = drag_boundary.y + drag_boundary.height * 0.5f - dtext_sz.y * 0.5f; + track_label(p->font, dtext, (Vector2){dtx, dty}, dfs, ColorAlpha(WHITE, 0.8f)); EndScissorMode(); + } + } + + // Deferred actions + if (remove_index >= 0) { + bool was_current = p->current_track == (int)remove_index; + Track *t = &p->tracks.items[remove_index]; + if (t->has_cover) UnloadTexture(t->cover); + DetachAudioStreamProcessor(t->music.stream, callback); + + // Stop crossfade if the removed track is involved + if (p->crossfading) { + StopMusicStream(p->crossfade_music); + p->crossfading = false; + } - } else { // <-- No need for ScissorMode - track_label(p->font, text, position, fontSize, WHITE); + if (was_current) StopMusicStream(t->music); + UnloadMusicStream(t->music); + free(t->music_data); + free(t->file_path); + memmove(&p->tracks.items[remove_index], &p->tracks.items[remove_index + 1], + (p->tracks.count - (size_t)remove_index - 1) * sizeof(Track)); + p->tracks.count--; + if (was_current) { + if ((size_t)remove_index < p->tracks.count) { + SeekMusicStream(p->tracks.items[remove_index].music, 0); + PlayMusicStream(p->tracks.items[remove_index].music); + p->current_track = (int)remove_index; + } else if (p->tracks.count > 0) { + p->current_track = (int)p->tracks.count - 1; + SeekMusicStream(p->tracks.items[p->current_track].music, 0); + PlayMusicStream(p->tracks.items[p->current_track].music); + } else { + p->current_track = -1; + } + } else if (p->current_track > (int)remove_index) { + p->current_track--; + } + } + if (move_from >= 0) { + size_t from = (size_t)move_from; + size_t to = (from > 0 && from < p->tracks.count) ? from - 1 : (from + 1 < p->tracks.count ? from + 1 : from); + if (to != from) { + Track tmp = p->tracks.items[from]; + if (from < to) { + memmove(&p->tracks.items[from], &p->tracks.items[from + 1], + (to - from) * sizeof(Track)); + } else { + memmove(&p->tracks.items[to + 1], &p->tracks.items[to], + (from - to) * sizeof(Track)); + } + p->tracks.items[to] = tmp; + if (p->current_track == (int)from) { + p->current_track = (int)to; + } else if (p->current_track == (int)to) { + p->current_track = (int)from; + } } } @@ -1107,8 +2468,8 @@ static void popup_tray(Popup_Tray *pt, Rectangle preview_boundary) .width = popup_width, .height = popup_height, }; - DrawRectangleRounded(popup_boundary, 0.3, 20, ColorAlpha(COLOR_POPUP_BACKGROUND, alpha)); - const char *text = "Could not load file"; + DrawRectangleRounded(popup_boundary, 0.3, 20, ColorAlpha(it->success ? COLOR_POPUP_SUCCESS : COLOR_POPUP_BACKGROUND, alpha)); + const char *text = it->message; float fontSize = popup_boundary.width*0.15; Vector2 size = MeasureTextEx(p->font, text, fontSize, 0); Vector2 position = { @@ -1272,13 +2633,24 @@ static void start_rendering_track(Track *track) char *output_path = tinyfd_saveFileDialog("Path to rendered video", "./", NOB_ARRAY_LEN(filter_params), filter_params, "mp4 video file"); if (output_path == NULL) return; - StopMusicStream(track->music); + // TODO: LoadWave is pretty slow on big files + Wave wave = load_wave_with_ffmpeg_fallback(track->file_path); + if (wave.frameCount == 0) { + popup_tray_push(&p->pt, "Could not decode track for rendering", false); + return; + } + float *wave_samples = LoadWaveSamples(wave); + if (wave_samples == NULL) { + UnloadWave(wave); + popup_tray_push(&p->pt, "Could not decode track for rendering", false); + return; + } + StopMusicStream(track->music); fft_clean(); - // TODO: LoadWave is pretty slow on big files - p->wave = LoadWave(track->file_path); + p->wave = wave; p->wave_cursor = 0; - p->wave_samples = LoadWaveSamples(p->wave); + p->wave_samples = wave_samples; // TODO: set the rendering output path based on the input path // Basically output into the same folder p->ffmpeg = ffmpeg_start_rendering(output_path, p->screen.texture.width, p->screen.texture.height, RENDER_FPS, track->file_path); @@ -1292,7 +2664,9 @@ static void finish_rendering_track(Track *track) { SetTraceLogLevel(LOG_INFO); UnloadWave(p->wave); + memset(&p->wave, 0, sizeof(p->wave)); UnloadWaveSamples(p->wave_samples); + p->wave_samples = NULL; SetTargetFPS(PREVIEW_FPS); p->rendering = false; fft_clean(); @@ -1355,9 +2729,9 @@ static bool toolbar(Track *track, Rectangle boundary) int state = 0; #ifdef MUSIALIZER_MICROPHONE - size_t buttons_count = 5; + size_t buttons_count = 6; #else - size_t buttons_count = 4; + size_t buttons_count = 5; #endif // MUSIALIZER_MICROPHONE if (boundary.width < HUD_BUTTON_SIZE*buttons_count) return interacted; @@ -1404,7 +2778,66 @@ static bool toolbar(Track *track, Rectangle boundary) } #endif // MUSIALIZER_MICROPHONE - // TODO: implement "add new track" button that uses tinyfiledialogs + state = button((CLITERAL(Rectangle) { + x, + boundary.y, + HUD_BUTTON_SIZE, + HUD_BUTTON_SIZE, + })); + { + Color color = (state & BS_HOVEROVER) ? COLOR_HUD_BUTTON_HOVEROVER : COLOR_HUD_BUTTON_BACKGROUND; + DrawRectangleRec((Rectangle){x, boundary.y, HUD_BUTTON_SIZE, HUD_BUTTON_SIZE}, color); + + float icon_size = 512; + float scale = HUD_BUTTON_SIZE/icon_size*HUD_ICON_SCALE; + float s = icon_size*scale; + float t = s*0.25f; + float cx = x + HUD_BUTTON_SIZE/2; + float cy = boundary.y + HUD_BUTTON_SIZE/2; + DrawLineEx((Vector2){cx - t, cy}, (Vector2){cx + t, cy}, s*0.1f, ColorBrightness(WHITE, -0.10)); + DrawLineEx((Vector2){cx, cy - t}, (Vector2){cx, cy + t}, s*0.1f, ColorBrightness(WHITE, -0.10)); + tooltip((Rectangle){x, boundary.y, HUD_BUTTON_SIZE, HUD_BUTTON_SIZE}, "Add Track", SIDE_TOP, false); + } + x += HUD_BUTTON_SIZE; + if (state & BS_CLICKED) { + interacted = true; + open_files_dialog(); + } + + // Save playlist button + state = button((CLITERAL(Rectangle) { + x, + boundary.y, + HUD_BUTTON_SIZE, + HUD_BUTTON_SIZE, + })); + { + Color color = (state & BS_HOVEROVER) ? COLOR_HUD_BUTTON_HOVEROVER : COLOR_HUD_BUTTON_BACKGROUND; + DrawRectangleRec((Rectangle){x, boundary.y, HUD_BUTTON_SIZE, HUD_BUTTON_SIZE}, color); + float s = HUD_BUTTON_SIZE * 0.5f; + float cx = x + HUD_BUTTON_SIZE/2; + float cy = boundary.y + HUD_BUTTON_SIZE/2; + float lw = s * 0.12f; + // Diskette shape + DrawRectangleLinesEx((Rectangle){cx - s/2, cy - s/2, s, s}, lw, ColorBrightness(WHITE, -0.10)); + DrawRectangle((int)(cx - s/3), (int)(cy - s/3), (int)(s*2/3), (int)(s*2/3), ColorBrightness(WHITE, -0.10)); + DrawRectangle((int)(cx - s/4), (int)(cy + s/6), (int)(s/2), (int)(s/3), ColorBrightness(WHITE, -0.20)); + tooltip((Rectangle){x, boundary.y, HUD_BUTTON_SIZE, HUD_BUTTON_SIZE}, "Save Playlist", SIDE_TOP, false); + } + x += HUD_BUTTON_SIZE; + if (state & BS_CLICKED) { + interacted = true; + const char *save_path = tinyfd_saveFileDialog("Save Playlist", "playlist.m3u", 1, (const char *[]){ "*.m3u", "*.m3u8" }, "M3U Playlist"); + if (save_path) { + FILE *f = platform_fopen(save_path, "w"); + if (f) { + for (size_t i = 0; i < p->tracks.count; i++) { + fprintf(f, "%s\n", p->tracks.items[i].file_path); + } + fclose(f); + } + } + } bool volume_slider_interacted = volume_slider((CLITERAL(Rectangle) { x, @@ -1436,28 +2869,16 @@ static void preview_screen(void) if (IsFileDropped()) { FilePathList droppedFiles = LoadDroppedFiles(); - // TODO: loading files synchronously like that actually blocks the UI thread - // Maybe we should do that in a separate thread. for (size_t i = 0; i < droppedFiles.count; ++i) { - Music music = LoadMusicStream(droppedFiles.paths[i]); - if (IsMusicValid(music)) { - AttachAudioStreamProcessor(music.stream, callback); - char *file_path = strdup(droppedFiles.paths[i]); - assert(file_path != NULL); - nob_da_append(&p->tracks, (CLITERAL(Track) { - .file_path = file_path, - .music = music, - })); + const char *ext = GetFileExtension(droppedFiles.paths[i]); + if (ext && (strcmp(ext, ".m3u") == 0 || strcmp(ext, ".m3u8") == 0)) { + load_m3u_playlist(droppedFiles.paths[i]); } else { - popup_tray_push(&p->pt); + load_track_from_path(droppedFiles.paths[i]); } } UnloadDroppedFiles(droppedFiles); - - if (current_track() == NULL && p->tracks.count > 0) { - p->current_track = 0; - PlayMusicStream(p->tracks.items[0].music); - } + startup_autoplay(); } #ifdef MUSIALIZER_MICROPHONE @@ -1468,18 +2889,83 @@ static void preview_screen(void) if (track) { // The music is loaded and ready UpdateMusicStream(track->music); - if (IsKeyPressed(KEY_TOGGLE_PLAY)) { - toggle_track_playing(track); + // Now-playing banner timer + if (p->now_playing_timer > 0) { + p->now_playing_timer -= GetFrameTime(); } - if (IsKeyPressed(KEY_RENDER)) { - start_rendering_track(track); + // Crossfade + if (p->crossfading) { + UpdateMusicStream(p->crossfade_music); + p->crossfade_timer += GetFrameTime(); + float t = p->crossfade_timer / p->crossfade_duration; + if (t >= 1.0f) { + t = 1.0f; + StopMusicStream(p->crossfade_music); + p->crossfading = false; + } + SetMusicVolume(p->crossfade_music, GetMasterVolume() * (1.0f - t)); + SetMusicVolume(track->music, GetMasterVolume() * t); } - if (IsKeyPressed(KEY_FULLSCREEN)) { - p->fullscreen = !p->fullscreen; + // Auto-advance when track ends + if (p->track_was_playing && !IsMusicStreamPlaying(track->music)) { + float len = GetMusicTimeLength(track->music); + float played = GetMusicTimePlayed(track->music); + if (len > 0 && played < 0.1f) { + if (p->repeat_mode == REPEAT_ALL) { + SeekMusicStream(track->music, 0); + PlayMusicStream(track->music); + } else if (p->current_track + 1 < (int)p->tracks.count) { + play_track(p->current_track + 1); + } + track = current_track(); + } } + p->track_was_playing = IsMusicStreamPlaying(track->music); + + if (track) { + if (IsKeyPressed(KEY_TOGGLE_PLAY)) { + toggle_track_playing(track); + } + + if (IsKeyPressed(KEY_RENDER) && IS_CTRL_DOWN) { + start_rendering_track(track); + } + if (IsKeyPressed(KEY_FULLSCREEN)) { + p->fullscreen = !p->fullscreen; + } + + if (IsKeyPressed(KEY_RIGHT)) { + next_track(); + track = current_track(); + } + + if (IsKeyPressed(KEY_LEFT)) { + prev_track(); + track = current_track(); + } + + if (IsKeyPressed(KEY_R)) { + p->repeat_mode = (p->repeat_mode + 1) % 2; + p->repeat_mode_label_timer = 1.5f; + } + + if (IsKeyPressed(KEY_S)) { + p->shuffle = !p->shuffle; + } + + if (IsKeyPressed(KEY_V)) { + p->viz_mode = (p->viz_mode + 1) % COUNT_VIZ_MODES; + } + + if (IsKeyPressed(KEY_H)) { + p->show_help = !p->show_help; + } + } + + if (track == NULL) return; size_t m = fft_analyze(GetFrameTime()); float toolbar_height = HUD_BUTTON_SIZE; @@ -1524,6 +3010,19 @@ static void preview_screen(void) #endif popup_tray(&p->pt, preview_boundary); + + // Now-playing banner + if (p->now_playing_timer > 0 && track) { + float fs = 22; + const char *name = GetFileName(track->file_path); + Vector2 sz = MeasureTextEx(p->font, name, fs, 0); + float banner_h = sz.y + 20; + float banner_y = preview_boundary.y + 10; + float alpha = p->now_playing_timer > 0.5f ? 1.0f : p->now_playing_timer / 0.5f; + Rectangle bg = { preview_boundary.x + preview_boundary.width/2 - sz.x/2 - 15, banner_y, sz.x + 30, banner_h }; + DrawRectangleRounded(bg, 0.3, 10, ColorAlpha(COLOR_BACKGROUND, 0.85f * alpha)); + DrawTextEx(p->font, name, (Vector2){bg.x + 15, bg.y + 10}, fs, 0, ColorAlpha(WHITE, alpha)); + } } else { float tracks_panel_width = 320.0f; float timeline_height = 150.0f; @@ -1548,12 +3047,77 @@ static void preview_screen(void) popup_tray(&p->pt, preview_boundary); EndScissorMode(); + // Now-playing banner + if (p->now_playing_timer > 0 && track) { + float fs = 22; + const char *name = GetFileName(track->file_path); + Vector2 sz = MeasureTextEx(p->font, name, fs, 0); + float banner_h = sz.y + 20; + float banner_y = preview_boundary.y + 10; + float alpha = p->now_playing_timer > 0.5f ? 1.0f : p->now_playing_timer / 0.5f; + Rectangle bg = { preview_boundary.x + preview_boundary.width/2 - sz.x/2 - 15, banner_y, sz.x + 30, banner_h }; + DrawRectangleRounded(bg, 0.3, 10, ColorAlpha(COLOR_BACKGROUND, 0.85f * alpha)); + DrawTextEx(p->font, name, (Vector2){bg.x + 15, bg.y + 10}, fs, 0, ColorAlpha(WHITE, alpha)); + } + + float eq_section_height = 160.0f; tracks_panel((CLITERAL(Rectangle) { .x = 0, .y = 0, .width = tracks_panel_width, - .height = h - timeline_height, + .height = h - timeline_height - eq_section_height, })); + track = current_track(); + if (track == NULL) return; + + // EQ Section + { + float eq_y = h - timeline_height - eq_section_height; + DrawRectangle(0, eq_y, tracks_panel_width, eq_section_height, COLOR_TRACK_PANEL_BACKGROUND); + + float title_size = 20; + DrawTextEx(p->font, "Equalizer", (Vector2){10, eq_y + 5}, title_size, 0, WHITE); + + float reset_size = 16; + Vector2 reset_txt = MeasureTextEx(p->font, "Reset", reset_size, 0); + float reset_pad = 10; + Rectangle reset_boundary = { + tracks_panel_width - reset_txt.x - reset_pad, + eq_y + 5, + reset_txt.x + reset_pad, + reset_txt.y, + }; + int reset_state = button(reset_boundary); + Color reset_color = (reset_state & BS_HOVEROVER) ? COLOR_ACCENT : ColorAlpha(WHITE, 0.5f); + DrawTextEx(p->font, "Reset", (Vector2){reset_boundary.x + reset_pad/2, reset_boundary.y}, reset_size, 0, reset_color); + if (reset_state & BS_CLICKED) { + p->eq_low = 0.5f; + p->eq_mid = 0.5f; + p->eq_high = 0.5f; + } + + float slider_x = 10; + float slider_w = tracks_panel_width - 20; + float slider_h = 25; + float label_size = 14; + float row_h = label_size + 2 + slider_h + 4; + float y0 = eq_y + 30; + + float y = y0; + DrawTextEx(p->font, "Low", (Vector2){slider_x, y}, label_size, 0, ColorAlpha(WHITE, 0.6f)); + y += label_size + 2; + horz_slider((Rectangle){slider_x, y, slider_w, slider_h}, &p->eq_low, &p->eq_low_drag); + + y = y0 + row_h; + DrawTextEx(p->font, "Mid", (Vector2){slider_x, y}, label_size, 0, ColorAlpha(WHITE, 0.6f)); + y += label_size + 2; + horz_slider((Rectangle){slider_x, y, slider_w, slider_h}, &p->eq_mid, &p->eq_mid_drag); + + y = y0 + row_h * 2; + DrawTextEx(p->font, "High", (Vector2){slider_x, y}, label_size, 0, ColorAlpha(WHITE, 0.6f)); + y += label_size + 2; + horz_slider((Rectangle){slider_x, y, slider_w, slider_h}, &p->eq_high, &p->eq_high_drag); + } timeline(CLITERAL(Rectangle) { .x = 0, @@ -1596,28 +3160,7 @@ static void preview_screen(void) }); if (button(((Rectangle) {0, 0, w, h})) & BS_CLICKED) { - int allow_multiple_selects = 0; // TODO: enable multiple selects - char const *filter_params[] = {"*.wav", "*.ogg", "*.mp3", "*.qoa", "*.xm", "*.mod", "*.flac"}; - char *input_path = tinyfd_openFileDialog("Path to music file", "./", NOB_ARRAY_LEN(filter_params), filter_params, "music file", allow_multiple_selects); - if (input_path) { - Music music = LoadMusicStream(input_path); - if (IsMusicValid(music)) { - AttachAudioStreamProcessor(music.stream, callback); - char *file_path = strdup(input_path); - assert(file_path != NULL); - nob_da_append(&p->tracks, (CLITERAL(Track) { - .file_path = file_path, - .music = music, - })); - } else { - popup_tray_push(&p->pt); - } - - if (current_track() == NULL && p->tracks.count > 0) { - p->current_track = 0; - PlayMusicStream(p->tracks.items[0].music); - } - } + open_files_dialog(); } } } @@ -1639,15 +3182,17 @@ static void capture_screen(void) const char *recording_file_path = "recording.wav"; Music music = LoadMusicStream(recording_file_path); if (IsMusicValid(music)) { + music.looping = false; AttachAudioStreamProcessor(music.stream, callback); - char *file_path = strdup(recording_file_path); + char *file_path = duplicate_string(recording_file_path); assert(file_path != NULL); nob_da_append(&p->tracks, (CLITERAL(Track) { .file_path = file_path, .music = music, + .music_data = NULL, })); } else { - popup_tray_push(&p->pt); + popup_tray_push(&p->pt, "Could not load capture", false); } if (current_track() == NULL && p->tracks.count > 0) { @@ -1785,14 +3330,16 @@ static void rendering_screen(void) { size_t chunk_size = p->wave.sampleRate/RENDER_FPS; float *fs = (float*)p->wave_samples; - for (size_t i = 0; i < chunk_size; ++i) { - if (p->wave_cursor < p->wave.frameCount) { - fft_push(fs[p->wave_cursor*p->wave.channels + 0]); - } else { - fft_push(0); - } - p->wave_cursor += 1; - } + size_t remaining = p->wave_cursor < p->wave.frameCount + ? p->wave.frameCount - p->wave_cursor + : 0; + size_t available = remaining < chunk_size ? remaining : chunk_size; + const float *samples = available > 0 + ? fs + p->wave_cursor*p->wave.channels + : NULL; + fft_push_frames(samples, available, p->wave.channels, true); + fft_push_frames(NULL, chunk_size - available, 1, true); + p->wave_cursor += chunk_size; } size_t m = fft_analyze(1.0f/RENDER_FPS); @@ -1823,11 +3370,34 @@ static void load_assets(void) size_t data_size = 0; void *data = NULL; - const char *alegreya_path = "./resources/fonts/Alegreya-Regular.ttf"; - data = plug_load_resource(alegreya_path, &data_size); - p->font = LoadFontFromMemory(GetFileExtension(alegreya_path), data, data_size, FONT_SIZE, NULL, 0); + const char *freesans_path = "./resources/fonts/FreeSans.ttf"; + data = plug_load_resource(freesans_path, &data_size); + { + // Codepoints covering Latin, Cyrillic, Greek, Armenian, and CJK + int cp[25000]; + int cp_count = 0; + // Basic Latin (0x20-0x7E) + for (int i = 0x20; i <= 0x7E; i++) cp[cp_count++] = i; + // Latin-1 Supplement (0xA0-0xFF) + for (int i = 0xA0; i <= 0xFF; i++) cp[cp_count++] = i; + // Latin Extended-A (0x100-0x17F) + for (int i = 0x100; i <= 0x17F; i++) cp[cp_count++] = i; + // Latin Extended-B (0x180-0x24F) + for (int i = 0x180; i <= 0x24F; i++) cp[cp_count++] = i; + // Cyrillic (0x400-0x4FF) + for (int i = 0x400; i <= 0x4FF; i++) cp[cp_count++] = i; + // Greek (0x370-0x3FF) + for (int i = 0x370; i <= 0x3FF; i++) cp[cp_count++] = i; + // Latin Extended Additional (0x1E00-0x1EFF) + for (int i = 0x1E00; i <= 0x1EFF; i++) cp[cp_count++] = i; + // Armenian (0x530-0x58F) + for (int i = 0x530; i <= 0x58F; i++) cp[cp_count++] = i; + // CJK Unified Ideographs (0x4E00-0x9FFF) + for (int i = 0x4E00; i <= 0x9FFF; i++) cp[cp_count++] = i; + p->font = LoadFontFromMemory(GetFileExtension(freesans_path), data, data_size, FONT_SIZE, cp, cp_count); GenTextureMipmaps(&p->font.texture); SetTextureFilter(p->font.texture, TEXTURE_FILTER_BILINEAR); + } plug_free_resource(data); // TODO: Maybe we should try to keep compiling different versions of shaders @@ -1868,21 +3438,76 @@ MUSIALIZER_PLUG void plug_init(void) assert(p != NULL && "Buy more RAM lol"); memset(p, 0, sizeof(*p)); + fft_buffer_init(); + load_assets(); p->screen = LoadRenderTexture(RENDER_WIDTH, RENDER_HEIGHT); p->current_track = -1; // TODO: restore master volume between sessions SetMasterVolume(0.5); + p->eq_low = 0.5f; + p->eq_mid = 0.5f; + p->eq_high = 0.5f; + p->crossfade_duration = 3.0f; + p->track_was_playing = false; SetTargetFPS(PREVIEW_FPS); } +MUSIALIZER_PLUG void plug_shutdown(void) +{ + if (p == NULL) return; + + loader_stop(); + unload_preview_waveform(); + + if (p->ffmpeg != NULL) { + ffmpeg_end_rendering(p->ffmpeg, true); + p->ffmpeg = NULL; + } + if (p->wave_samples != NULL) { + UnloadWaveSamples(p->wave_samples); + p->wave_samples = NULL; + } + if (p->wave.frameCount > 0) { + UnloadWave(p->wave); + memset(&p->wave, 0, sizeof(p->wave)); + } + +#ifdef MUSIALIZER_MICROPHONE + if (p->microphone_working) { + ma_device_uninit(&p->microphone); + drwav_uninit(&p->wav); + p->microphone_working = false; + } +#endif + + for (size_t i = 0; i < p->tracks.count; ++i) { + Track *track = &p->tracks.items[i]; + DetachAudioStreamProcessor(track->music.stream, callback); + UnloadMusicStream(track->music); + free(track->music_data); + if (track->has_cover) UnloadTexture(track->cover); + free(track->file_path); + } + free(p->tracks.items); + + UnloadRenderTexture(p->screen); + unload_assets(); + fft_buffer_shutdown(); + free(p); + p = NULL; +} + MUSIALIZER_PLUG void *plug_pre_reload(void) { + unload_preview_waveform(); + loader_stop(); for (size_t i = 0; i < p->tracks.count; ++i) { Track *it = &p->tracks.items[i]; DetachAudioStreamProcessor(it->music.stream, callback); } + fft_buffer_shutdown(); unload_assets(); return p; } @@ -1890,6 +3515,8 @@ MUSIALIZER_PLUG void *plug_pre_reload(void) MUSIALIZER_PLUG void plug_post_reload(void *pp) { p = pp; + fft_buffer_init(); + memset(&loader, 0, sizeof(loader)); for (size_t i = 0; i < p->tracks.count; ++i) { Track *it = &p->tracks.items[i]; AttachAudioStreamProcessor(it->music.stream, callback); @@ -1899,6 +3526,8 @@ MUSIALIZER_PLUG void plug_post_reload(void *pp) MUSIALIZER_PLUG void plug_update(void) { + process_completed_loads(); + BeginDrawing(); ClearBackground(COLOR_BACKGROUND); @@ -1918,6 +3547,41 @@ MUSIALIZER_PLUG void plug_update(void) rendering_screen(); } + // Keyboard shortcut overlay + if (p->show_help) { + const char *lines[] = { + "Space - Play / Pause", + "Left - Previous Track", + "Right - Next Track", + "V - Cycle Visualization", + "R - Cycle Repeat Mode", + "Ctrl+R - Render to Video", + "S - Toggle Shuffle", + "F - Toggle Fullscreen", + "H - Toggle Help", + "", + "Click / Drag on Timeline to Seek", + }; + int n = sizeof(lines)/sizeof(lines[0]); + float fs = 20; + float line_h = fs + 6; + float total_h = n * line_h + 20; + float max_w = 0; + for (int i = 0; i < n; i++) { + Vector2 sz = MeasureTextEx(GetFontDefault(), lines[i], fs, 0); + if (sz.x > max_w) max_w = sz.x; + } + float total_w = max_w + 40; + float ox = GetScreenWidth()/2 - total_w/2; + float oy = GetScreenHeight()/2 - total_h/2; + DrawRectangleRounded((Rectangle){ox, oy, total_w, total_h}, 0.3, 10, ColorAlpha(COLOR_BACKGROUND, 0.92f)); + DrawRectangleRoundedLines((Rectangle){ox, oy, total_w, total_h}, 0.3, 10, ColorAlpha(WHITE, 0.1f)); + for (int i = 0; i < n; i++) { + float y = oy + 10 + i * line_h; + DrawTextEx(GetFontDefault(), lines[i], (Vector2){ox + 20, y}, fs, 0, ColorAlpha(WHITE, 0.85f)); + } + } + end_tooltip_frame(); EndDrawing(); diff --git a/src/plug.h b/src/plug.h index 89bcfe7..a6d0cdd 100644 --- a/src/plug.h +++ b/src/plug.h @@ -3,6 +3,7 @@ #define LIST_OF_PLUGS \ PLUG(plug_init, void, void) \ + PLUG(plug_shutdown, void, void) \ PLUG(plug_pre_reload, void*, void) \ PLUG(plug_post_reload, void, void*) \ PLUG(plug_load_resource, void*, const char*, size_t*) \ diff --git a/src/win32_utf8.h b/src/win32_utf8.h new file mode 100644 index 0000000..9dc1876 --- /dev/null +++ b/src/win32_utf8.h @@ -0,0 +1,142 @@ +#ifndef MUSIALIZER_WIN32_UTF8_H_ +#define MUSIALIZER_WIN32_UTF8_H_ + +#include +#include +#include +#include +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +static inline wchar_t *win32_utf8_to_utf16(const char *text) +{ + if (text == NULL) return NULL; + + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, -1, NULL, 0); + if (length <= 0) return NULL; + + wchar_t *result = malloc((size_t)length*sizeof(*result)); + if (result == NULL) return NULL; + + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text, -1, result, length) <= 0) { + free(result); + return NULL; + } + return result; +} + +static inline bool win32_utf16_to_utf8(const wchar_t *text, char *result, size_t result_capacity) +{ + if (text == NULL || result == NULL || result_capacity == 0 || result_capacity > INT_MAX) return false; + int length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, -1, + result, (int)result_capacity, NULL, NULL); + return length > 0; +} + +typedef struct { + wchar_t *items; + size_t count; + size_t capacity; +} Win32_Wide_String; + +static inline bool win32_wide_string_reserve(Win32_Wide_String *string, size_t extra) +{ + if (extra > SIZE_MAX - string->count) return false; + size_t needed = string->count + extra; + if (needed <= string->capacity) return true; + + size_t capacity = string->capacity ? string->capacity : 256; + while (capacity < needed) { + if (capacity > SIZE_MAX/2) { + capacity = needed; + break; + } + capacity *= 2; + } + + wchar_t *items = realloc(string->items, capacity*sizeof(*items)); + if (items == NULL) return false; + string->items = items; + string->capacity = capacity; + return true; +} + +static inline bool win32_wide_string_append_char(Win32_Wide_String *string, wchar_t value) +{ + if (!win32_wide_string_reserve(string, 1)) return false; + string->items[string->count++] = value; + return true; +} + +static inline bool win32_wide_string_append_repeat(Win32_Wide_String *string, wchar_t value, size_t count) +{ + if (!win32_wide_string_reserve(string, count)) return false; + for (size_t i = 0; i < count; ++i) string->items[string->count++] = value; + return true; +} + +// Quotes arguments according to CommandLineToArgvW's parsing rules. Windows +// CreateProcess receives one command-line string rather than an argv array. +static inline wchar_t *win32_command_line_from_utf8_argv(const char *const argv[]) +{ + if (argv == NULL || argv[0] == NULL) return NULL; + + Win32_Wide_String command = {0}; + for (size_t arg_index = 0; argv[arg_index] != NULL; ++arg_index) { + wchar_t *arg = win32_utf8_to_utf16(argv[arg_index]); + if (arg == NULL) goto fail; + + if (arg_index > 0 && !win32_wide_string_append_char(&command, L' ')) { + free(arg); + goto fail; + } + if (!win32_wide_string_append_char(&command, L'\"')) { + free(arg); + goto fail; + } + + const wchar_t *cursor = arg; + while (*cursor != L'\0') { + size_t backslashes = 0; + while (*cursor == L'\\') { + ++backslashes; + ++cursor; + } + + if (*cursor == L'\"') { + if (!win32_wide_string_append_repeat(&command, L'\\', backslashes*2 + 1) || + !win32_wide_string_append_char(&command, *cursor++)) { + free(arg); + goto fail; + } + } else if (*cursor == L'\0') { + if (!win32_wide_string_append_repeat(&command, L'\\', backslashes*2)) { + free(arg); + goto fail; + } + } else { + if (!win32_wide_string_append_repeat(&command, L'\\', backslashes) || + !win32_wide_string_append_char(&command, *cursor++)) { + free(arg); + goto fail; + } + } + } + + free(arg); + if (!win32_wide_string_append_char(&command, L'\"')) goto fail; + } + + if (!win32_wide_string_append_char(&command, L'\0')) goto fail; + return command.items; + +fail: + free(command.items); + return NULL; +} + +#endif // MUSIALIZER_WIN32_UTF8_H_ diff --git a/src_build/nob_linux.c b/src_build/nob_linux.c index d7c1963..9e42eca 100644 --- a/src_build/nob_linux.c +++ b/src_build/nob_linux.c @@ -14,10 +14,10 @@ bool build_musializer(void) "-I.", "-I"RAYLIB_SRC_FOLDER, "-fPIC", "-shared", "-o", "./build/libplug.so", - "./src/plug.c", "./src/ffmpeg_posix.c", "./thirdparty/tinyfiledialogs.c", + "./src/plug.c", "./src/platform_posix.c", "./src/ffmpeg_posix.c", "./thirdparty/tinyfiledialogs.c", temp_sprintf("-L./build/raylib/%s", MUSIALIZER_TARGET_NAME), "-l:libraylib.so", "-O3", "-march=native", "-ffast-math", - "-lm", "-ldl", "-flto=auto", "-lpthread"); + "-lm", "-ldl", "-flto=auto", "-pthread"); if (!cmd_run(&cmd)) return_defer(false); cmd_append(&cmd, "cc", @@ -32,7 +32,7 @@ bool build_musializer(void) temp_sprintf("-Wl,-rpath=./raylib/%s", MUSIALIZER_TARGET_NAME), temp_sprintf("-L./build/raylib/%s", MUSIALIZER_TARGET_NAME), "-O3", "-march=native", "-ffast-math", - "-l:libraylib.so", "-lm", "-ldl", "-flto=auto", "-lpthread"); + "-l:libraylib.so", "-lm", "-ldl", "-flto=auto", "-pthread"); if (!cmd_run(&cmd)) return_defer(false); if (!procs_flush(&procs)) return_defer(false); @@ -42,10 +42,10 @@ bool build_musializer(void) "-I.", "-I"RAYLIB_SRC_FOLDER, "-o", "./build/musializer", - "./src/plug.c", "./src/ffmpeg_posix.c", "./src/musializer.c", "./thirdparty/tinyfiledialogs.c", + "./src/plug.c", "./src/platform_posix.c", "./src/ffmpeg_posix.c", "./src/musializer.c", "./thirdparty/tinyfiledialogs.c", temp_sprintf("-L./build/raylib/%s", MUSIALIZER_TARGET_NAME), "-l:libraylib.a", "-O3", "-march=native", "-ffast-math", - "-lm", "-ldl", "-flto=auto", "-lpthread"); + "-lm", "-ldl", "-flto=auto", "-pthread"); if (!cmd_run(&cmd)) return_defer(false); #endif // MUSIALIZER_HOTRELOAD @@ -76,14 +76,15 @@ bool build_raylib(void) for (size_t i = 0; i < ARRAY_LEN(raylib_modules); ++i) { const char *input_path = temp_sprintf(RAYLIB_SRC_FOLDER"%s.c", raylib_modules[i]); const char *output_path = temp_sprintf("%s/%s.o", build_path, raylib_modules[i]); - output_path = temp_sprintf("%s/%s.o", build_path, raylib_modules[i]); da_append(&object_files, output_path); - if (needs_rebuild(output_path, &input_path, 1)) { + const char *inputs[] = { input_path, "./src_build/nob_linux.c" }; + if (needs_rebuild(output_path, inputs, ARRAY_LEN(inputs))) { cmd_append(&cmd, "cc", "-ggdb", "-DPLATFORM_DESKTOP", "-D_GLFW_X11", "-fPIC", "-DSUPPORT_FILEFORMAT_FLAC=1", "-I"RAYLIB_SRC_FOLDER"external/glfw/include", + "-O3", "-march=native", "-ffast-math", "-flto=auto", "-pthread", "-c", input_path, "-o", output_path); if (!cmd_run(&cmd, .async = &procs)) return_defer(false); @@ -107,7 +108,7 @@ bool build_raylib(void) const char *libraylib_path = temp_sprintf("%s/libraylib.so", build_path); if (needs_rebuild(libraylib_path, object_files.items, object_files.count)) { - cmd_append(&cmd, "cc", "-shared", "-o", libraylib_path); + cmd_append(&cmd, "cc", "-shared", "-flto=auto", "-pthread", "-o", libraylib_path); for (size_t i = 0; i < ARRAY_LEN(raylib_modules); ++i) { const char *input_path = temp_sprintf("%s/%s.o", build_path, raylib_modules[i]); cmd_append(&cmd, input_path); diff --git a/src_build/nob_macos.c b/src_build/nob_macos.c index 0032a33..2b65ecd 100644 --- a/src_build/nob_macos.c +++ b/src_build/nob_macos.c @@ -20,6 +20,7 @@ bool build_musializer(void) nob_cmd_append(&cmd, "-o", "./build/libplug.dylib"); nob_cmd_append(&cmd, "./src/plug.c", + "./src/platform_posix.c", "./src/ffmpeg_posix.c", "./thirdparty/tinyfiledialogs.c"); nob_cmd_append(&cmd, "./build/raylib/macos/libraylib.dylib"); @@ -52,6 +53,7 @@ bool build_musializer(void) nob_cmd_append(&cmd, "-o", "./build/musializer"); nob_cmd_append(&cmd, "./src/plug.c", + "./src/platform_posix.c", "./src/ffmpeg_posix.c", "./src/musializer.c", "./thirdparty/tinyfiledialogs.c"); diff --git a/src_build/nob_openbsd.c b/src_build/nob_openbsd.c index df1eb01..917be4e 100644 --- a/src_build/nob_openbsd.c +++ b/src_build/nob_openbsd.c @@ -20,6 +20,7 @@ bool build_musializer(void) nob_cmd_append(&cmd, "./thirdparty/tinyfiledialogs.c"); nob_cmd_append(&cmd, "./src/plug.c", + "./src/platform_posix.c", "./src/ffmpeg_posix.c"); nob_cmd_append(&cmd, nob_temp_sprintf("-L./build/raylib/%s", MUSIALIZER_TARGET_NAME), @@ -58,6 +59,7 @@ bool build_musializer(void) nob_cmd_append(&cmd, "./thirdparty/tinyfiledialogs.c"); nob_cmd_append(&cmd, "./src/plug.c", + "./src/platform_posix.c", "./src/ffmpeg_posix.c", "./src/musializer.c"); nob_cmd_append(&cmd, diff --git a/src_build/nob_stage2.c b/src_build/nob_stage2.c index 16700e2..f56c035 100644 --- a/src_build/nob_stage2.c +++ b/src_build/nob_stage2.c @@ -64,7 +64,7 @@ Resource resources[] = { { .file_path = "./resources/icons/render.png" }, { .file_path = "./resources/icons/fullscreen.png" }, { .file_path = "./resources/icons/microphone.png" }, - { .file_path = "./resources/fonts/Alegreya-Regular.ttf" }, + { .file_path = "./resources/fonts/FreeSans.ttf" }, }; bool generate_resource_bundle(void) diff --git a/src_build/nob_win64_mingw.c b/src_build/nob_win64_mingw.c index 49c5e0a..6e95e66 100644 --- a/src_build/nob_win64_mingw.c +++ b/src_build/nob_win64_mingw.c @@ -21,8 +21,9 @@ bool build_musializer(void) if (!cmd_run(&cmd)) return_defer(false); #ifdef MUSIALIZER_HOTRELOAD - cmd_append(&cmd, "x86_64-w64-mingw32-gcc"); + cmd_append(&cmd, MAYBE_PREFIXED("gcc")); cmd_append(&cmd, "-mwindows", "-Wall", "-Wextra", "-ggdb"); + cmd_append(&cmd, "-O3", "-ffast-math", "-flto=auto"); cmd_append(&cmd, "-I."); cmd_append(&cmd, "-I"RAYLIB_SRC_FOLDER); cmd_append(&cmd, "-fPIC", "-shared"); @@ -30,6 +31,7 @@ bool build_musializer(void) cmd_append(&cmd, "-o", "./build/libplug.dll"); cmd_append(&cmd, "./src/plug.c", + "./src/platform_windows.c", "./src/ffmpeg_windows.c", "./thirdparty/tinyfiledialogs.c"); cmd_append(&cmd, @@ -38,8 +40,9 @@ bool build_musializer(void) cmd_append(&cmd, "-lwinmm", "-lgdi32", "-lole32"); if (!cmd_run(&cmd, .async = &procs)) return_defer(false); - cmd_append(&cmd, "x86_64-w64-mingw32-gcc"); + cmd_append(&cmd, MAYBE_PREFIXED("gcc")); cmd_append(&cmd, "-mwindows", "-Wall", "-Wextra", "-ggdb"); + cmd_append(&cmd, "-O3", "-ffast-math", "-flto=auto"); cmd_append(&cmd, "-I."); cmd_append(&cmd, "-I"RAYLIB_SRC_FOLDER); cmd_append(&cmd, "-o", "./build/musializer"); @@ -60,13 +63,15 @@ bool build_musializer(void) if (!procs_flush(&procs)) return_defer(false); #else - cmd_append(&cmd, "x86_64-w64-mingw32-gcc"); + cmd_append(&cmd, MAYBE_PREFIXED("gcc")); cmd_append(&cmd, "-mwindows", "-Wall", "-Wextra", "-ggdb"); + cmd_append(&cmd, "-O3", "-ffast-math", "-flto=auto"); cmd_append(&cmd, "-I."); cmd_append(&cmd, "-I"RAYLIB_SRC_FOLDER); cmd_append(&cmd, "-o", "./build/musializer"); cmd_append(&cmd, "./src/plug.c", + "./src/platform_windows.c", "./src/ffmpeg_windows.c", "./src/musializer.c", "./thirdparty/tinyfiledialogs.c", @@ -111,9 +116,11 @@ bool build_raylib() da_append(&object_files, output_path); - if (needs_rebuild(output_path, &input_path, 1)) { - cmd_append(&cmd, "x86_64-w64-mingw32-gcc"); + const char *inputs[] = {input_path, "./src_build/nob_win64_mingw.c"}; + if (needs_rebuild(output_path, inputs, ARRAY_LEN(inputs))) { + cmd_append(&cmd, MAYBE_PREFIXED("gcc")); cmd_append(&cmd, "-ggdb", "-DPLATFORM_DESKTOP", "-fPIC", "-DSUPPORT_FILEFORMAT_FLAC=1"); + cmd_append(&cmd, "-O3", "-ffast-math", "-flto=auto"); cmd_append(&cmd, "-DPLATFORM_DESKTOP"); cmd_append(&cmd, "-fPIC"); cmd_append(&cmd, "-I"RAYLIB_SRC_FOLDER"external/glfw/include"); @@ -143,7 +150,7 @@ bool build_raylib() const char *libraylib_path = "./build/raylib.dll"; if (needs_rebuild(libraylib_path, object_files.items, object_files.count)) { - cmd_append(&cmd, "x86_64-w64-mingw32-gcc"); + cmd_append(&cmd, MAYBE_PREFIXED("gcc")); cmd_append(&cmd, "-shared"); cmd_append(&cmd, "-o", libraylib_path); for (size_t i = 0; i < ARRAY_LEN(raylib_modules); ++i) { diff --git a/src_build/nob_win64_msvc.c b/src_build/nob_win64_msvc.c index d3d62df..5b3cddc 100644 --- a/src_build/nob_win64_msvc.c +++ b/src_build/nob_win64_msvc.c @@ -16,16 +16,19 @@ bool build_musializer(void) procs.count = 0; cmd.count = 0; nob_cmd_append(&cmd, "cl.exe"); + nob_cmd_append(&cmd, "/O2", "/GL", "/fp:fast"); nob_cmd_append(&cmd, "/LD"); nob_cmd_append(&cmd, "/Fobuild\\", "/Fe./build/libplug.dll"); nob_cmd_append(&cmd, "/I", "./"); nob_cmd_append(&cmd, "/I", RAYLIB_SRC_FOLDER); nob_cmd_append(&cmd, "src/plug.c", + "src/platform_windows.c", "src/ffmpeg_windows.c", "./thirdparty/tinyfiledialogs.c"); nob_cmd_append(&cmd, "/link", + "/LTCG", nob_temp_sprintf("/LIBPATH:build/raylib/%s", MUSIALIZER_TARGET_NAME), "raylib.lib"); nob_cmd_append(&cmd, "Winmm.lib", "gdi32.lib", "User32.lib", "Shell32.lib", "Ole32.lib", "comdlg32.lib"); @@ -33,6 +36,7 @@ bool build_musializer(void) cmd.count = 0; nob_cmd_append(&cmd, "cl.exe"); + nob_cmd_append(&cmd, "/O2", "/GL", "/fp:fast"); nob_cmd_append(&cmd, "/I", "./"); nob_cmd_append(&cmd, "/I", RAYLIB_SRC_FOLDER); nob_cmd_append(&cmd, "/Fobuild\\", "/Febuild\\musializer.exe"); @@ -42,6 +46,7 @@ bool build_musializer(void) ); nob_cmd_append(&cmd, "/link", + "/LTCG", "/SUBSYSTEM:WINDOWS", "/entry:mainCRTStartup", nob_temp_sprintf("/LIBPATH:build/raylib/%s", MUSIALIZER_TARGET_NAME), @@ -52,16 +57,19 @@ bool build_musializer(void) #else cmd.count = 0; nob_cmd_append(&cmd, "cl.exe"); + nob_cmd_append(&cmd, "/O2", "/GL", "/fp:fast"); nob_cmd_append(&cmd, "/I", "./"); nob_cmd_append(&cmd, "/I", RAYLIB_SRC_FOLDER); nob_cmd_append(&cmd, "/Fobuild\\", "/Febuild\\musializer.exe"); nob_cmd_append(&cmd, "./src/musializer.c", "./src/plug.c", + "./src/platform_windows.c", "./src/ffmpeg_windows.c", "./thirdparty/tinyfiledialogs.c"); nob_cmd_append(&cmd, "/link", + "/LTCG", "/SUBSYSTEM:WINDOWS", "/entry:mainCRTStartup", nob_temp_sprintf("/LIBPATH:build/raylib/%s", MUSIALIZER_TARGET_NAME), @@ -102,9 +110,11 @@ bool build_raylib(void) nob_da_append(&object_files, output_path); - if (nob_needs_rebuild(output_path, &input_path, 1)) { + const char *inputs[] = {input_path, "./src_build/nob_win64_msvc.c"}; + if (nob_needs_rebuild(output_path, inputs, NOB_ARRAY_LEN(inputs))) { cmd.count = 0; nob_cmd_append(&cmd, "cl.exe", "/DPLATFORM_DESKTOP", "/DSUPPORT_FILEFORMAT_FLAC=1"); + nob_cmd_append(&cmd, "/O2", "/GL", "/fp:fast"); #ifdef MUSIALIZER_HOTRELOAD nob_cmd_append(&cmd, "/DBUILD_LIBTYPE_SHARED"); #endif @@ -122,6 +132,7 @@ bool build_raylib(void) const char *libraylib_path = nob_temp_sprintf("%s/raylib.lib", build_path); if (nob_needs_rebuild(libraylib_path, object_files.items, object_files.count)) { nob_cmd_append(&cmd, "lib"); + nob_cmd_append(&cmd, "/LTCG"); for (size_t i = 0; i < NOB_ARRAY_LEN(raylib_modules); ++i) { const char *input_path = nob_temp_sprintf("%s/%s.obj", build_path, raylib_modules[i]); nob_cmd_append(&cmd, input_path); @@ -132,6 +143,7 @@ bool build_raylib(void) #else if (nob_needs_rebuild("./build/raylib.dll", object_files.items, object_files.count)) { nob_cmd_append(&cmd, "link.exe", "/DLL"); + nob_cmd_append(&cmd, "/LTCG"); for (size_t i = 0; i < NOB_ARRAY_LEN(raylib_modules); ++i) { const char *input_path = nob_temp_sprintf("%s/%s.obj", build_path, raylib_modules[i]); nob_cmd_append(&cmd, input_path); diff --git a/tests/platform_test.c b/tests/platform_test.c new file mode 100644 index 0000000..28a0377 --- /dev/null +++ b/tests/platform_test.c @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include + +#include "src/platform.h" + +typedef struct { + Platform_Mutex *mutex; + Platform_Condition *condition; + bool ready; +} Thread_State; + +static void *test_thread(void *arg) +{ + Thread_State *state = arg; + platform_mutex_lock(state->mutex); + state->ready = true; + platform_condition_signal(state->condition); + platform_mutex_unlock(state->mutex); + return NULL; +} + +int main(int argc, char **argv) +{ + const char *tricky_argument = "spaces, \"quotes\", and a trailing slash\\"; + if (argc == 3 && strcmp(argv[1], "--child") == 0) { + return strcmp(argv[2], tricky_argument) == 0 ? 0 : 1; + } + + Thread_State state = {0}; + state.mutex = platform_mutex_create(); + state.condition = platform_condition_create(); + assert(state.mutex != NULL); + assert(state.condition != NULL); + + platform_mutex_lock(state.mutex); + Platform_Thread *thread = platform_thread_start(test_thread, &state); + assert(thread != NULL); + while (!state.ready) platform_condition_wait(state.condition, state.mutex); + platform_mutex_unlock(state.mutex); + platform_thread_join(thread); + + assert(platform_mutex_try_lock(state.mutex)); + platform_mutex_unlock(state.mutex); + + platform_condition_destroy(state.condition); + platform_mutex_destroy(state.mutex); + + char temp_path[4096]; + assert(platform_make_temp_file(temp_path, sizeof(temp_path), "musializer_test", ".wav")); + size_t path_length = strlen(temp_path); + assert(path_length >= 4); + assert(strcmp(temp_path + path_length - 4, ".wav") == 0); + + FILE *temp_file = platform_fopen(temp_path, "wb"); + assert(temp_file != NULL); + const char contents[] = "temporary file contents"; + size_t written = fwrite(contents, 1, sizeof(contents), temp_file); + assert(written == sizeof(contents)); + fclose(temp_file); + + unsigned char *loaded_contents = NULL; + size_t loaded_size = 0; + assert(platform_read_entire_file(temp_path, &loaded_contents, &loaded_size)); + assert(loaded_size == sizeof(contents)); + assert(memcmp(loaded_contents, contents, sizeof(contents)) == 0); + free(loaded_contents); + assert(platform_remove_file(temp_path)); + + const char *const child_argv[] = {argv[0], "--child", tricky_argument, NULL}; + assert(platform_run_command(child_argv, true)); + + puts("platform tests passed"); + return 0; +} diff --git a/thirdparty/raylib-5.5/src/external/jar_mod.h b/thirdparty/raylib-5.5/src/external/jar_mod.h index eacd3b7..da275f2 100644 --- a/thirdparty/raylib-5.5/src/external/jar_mod.h +++ b/thirdparty/raylib-5.5/src/external/jar_mod.h @@ -1130,7 +1130,7 @@ static bool jar_mod_load( jar_mod_context_t * modctx, void * mod_data, int mod_d { if( modctx ) { - memcopy(&(modctx->song.title),modmemory,1084); + memcopy(&(modctx->song),modmemory,1084); i = 0; modctx->number_of_channels = 0; @@ -1593,4 +1593,4 @@ void jar_mod_seek_start(jar_mod_context_t * ctx) //------------------------------------------------------------------------------- -#endif //end of header file \ No newline at end of file +#endif //end of header file