316 lines
12 KiB
C#
316 lines
12 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Net.Http;
|
|
using System.Threading.Tasks;
|
|
using SplashEdit.RuntimeCode;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using Debug = UnityEngine.Debug;
|
|
|
|
namespace SplashEdit.EditorCode
|
|
{
|
|
/// <summary>
|
|
/// Downloads psxavenc and converts WAV audio to PS1 SPU ADPCM format.
|
|
/// psxavenc is the standard tool for PS1 audio encoding from the
|
|
/// WonderfulToolchain project.
|
|
/// </summary>
|
|
[InitializeOnLoad]
|
|
public static class PSXAudioConverter
|
|
{
|
|
static PSXAudioConverter()
|
|
{
|
|
// Register the converter delegate so Runtime code can call it
|
|
// without directly referencing this Editor assembly.
|
|
PSXSceneExporter.AudioConvertDelegate = ConvertToADPCM;
|
|
}
|
|
|
|
private const string PSXAVENC_VERSION = "v0.3.1";
|
|
private const string PSXAVENC_RELEASE_BASE =
|
|
"https://github.com/WonderfulToolchain/psxavenc/releases/download/";
|
|
|
|
private static readonly HttpClient _http = new HttpClient();
|
|
|
|
/// <summary>
|
|
/// Path to the psxavenc binary inside .tools/
|
|
/// </summary>
|
|
public static string PsxavencBinary
|
|
{
|
|
get
|
|
{
|
|
string dir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc");
|
|
if (Application.platform == RuntimePlatform.WindowsEditor)
|
|
return Path.Combine(dir, "psxavenc.exe");
|
|
return Path.Combine(dir, "psxavenc");
|
|
}
|
|
}
|
|
|
|
public static bool IsInstalled() => File.Exists(PsxavencBinary);
|
|
|
|
/// <summary>
|
|
/// Downloads and installs psxavenc from the official GitHub releases.
|
|
/// </summary>
|
|
public static async Task<bool> DownloadAndInstall(Action<string> log = null)
|
|
{
|
|
string archiveName;
|
|
switch (Application.platform)
|
|
{
|
|
case RuntimePlatform.WindowsEditor:
|
|
archiveName = $"psxavenc-windows.zip";
|
|
break;
|
|
case RuntimePlatform.LinuxEditor:
|
|
archiveName = $"psxavenc-linux.zip";
|
|
break;
|
|
default:
|
|
log?.Invoke("Only Windows and Linux are supported.");
|
|
return false;
|
|
}
|
|
|
|
string downloadUrl = $"{PSXAVENC_RELEASE_BASE}{PSXAVENC_VERSION}/{archiveName}";
|
|
log?.Invoke($"Downloading psxavenc: {downloadUrl}");
|
|
|
|
try
|
|
{
|
|
string tempFile = Path.Combine(Path.GetTempPath(), archiveName);
|
|
EditorUtility.DisplayProgressBar("Downloading psxavenc", "Downloading...", 0.1f);
|
|
|
|
using (var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead))
|
|
{
|
|
response.EnsureSuccessStatusCode();
|
|
long? totalBytes = response.Content.Headers.ContentLength;
|
|
long downloaded = 0;
|
|
|
|
using (var fs = File.Create(tempFile))
|
|
using (var stream = await response.Content.ReadAsStreamAsync())
|
|
{
|
|
byte[] buffer = new byte[81920];
|
|
int bytesRead;
|
|
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
|
{
|
|
await fs.WriteAsync(buffer, 0, bytesRead);
|
|
downloaded += bytesRead;
|
|
if (totalBytes.HasValue)
|
|
{
|
|
float progress = (float)downloaded / totalBytes.Value;
|
|
EditorUtility.DisplayProgressBar("Downloading psxavenc",
|
|
$"{downloaded / 1024}/{totalBytes.Value / 1024} KB", progress);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log?.Invoke("Extracting...");
|
|
EditorUtility.DisplayProgressBar("Installing psxavenc", "Extracting...", 0.9f);
|
|
|
|
string installDir = Path.Combine(SplashBuildPaths.ToolsDir, "psxavenc");
|
|
if (Directory.Exists(installDir))
|
|
Directory.Delete(installDir, true);
|
|
Directory.CreateDirectory(installDir);
|
|
|
|
if (tempFile.EndsWith(".zip"))
|
|
{
|
|
System.IO.Compression.ZipFile.ExtractToDirectory(tempFile, installDir);
|
|
}
|
|
else
|
|
{
|
|
// tar.gz extraction — use system tar
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = "tar",
|
|
Arguments = $"xzf \"{tempFile}\" -C \"{installDir}\" --strip-components=1",
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true
|
|
};
|
|
var proc = Process.Start(psi);
|
|
proc.WaitForExit();
|
|
}
|
|
|
|
// Fix nested directory (sometimes archives have one extra level)
|
|
FixNestedDirectory(installDir);
|
|
|
|
try { File.Delete(tempFile); } catch { }
|
|
|
|
EditorUtility.ClearProgressBar();
|
|
|
|
if (IsInstalled())
|
|
{
|
|
// Make executable on Linux
|
|
if (Application.platform == RuntimePlatform.LinuxEditor)
|
|
{
|
|
var chmod = Process.Start("chmod", $"+x \"{PsxavencBinary}\"");
|
|
chmod?.WaitForExit();
|
|
}
|
|
log?.Invoke("psxavenc installed successfully!");
|
|
return true;
|
|
}
|
|
|
|
log?.Invoke($"psxavenc binary not found at: {PsxavencBinary}");
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
log?.Invoke($"psxavenc download failed: {ex.Message}");
|
|
EditorUtility.ClearProgressBar();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static void FixNestedDirectory(string dir)
|
|
{
|
|
// If extraction created exactly one subdirectory, flatten it
|
|
var subdirs = Directory.GetDirectories(dir);
|
|
if (subdirs.Length == 1)
|
|
{
|
|
string nested = subdirs[0];
|
|
foreach (string file in Directory.GetFiles(nested))
|
|
{
|
|
string dest = Path.Combine(dir, Path.GetFileName(file));
|
|
if (!File.Exists(dest)) File.Move(file, dest);
|
|
}
|
|
foreach (string sub in Directory.GetDirectories(nested))
|
|
{
|
|
string dest = Path.Combine(dir, Path.GetFileName(sub));
|
|
if (!Directory.Exists(dest)) Directory.Move(sub, dest);
|
|
}
|
|
try { Directory.Delete(nested, true); } catch { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a Unity AudioClip to PS1 SPU ADPCM format using psxavenc.
|
|
/// Returns the ADPCM byte array, or null on failure.
|
|
/// </summary>
|
|
public static byte[] ConvertToADPCM(AudioClip clip, int targetSampleRate, bool loop)
|
|
{
|
|
if (!IsInstalled())
|
|
{
|
|
Debug.LogError("[SplashEdit] psxavenc not installed. Install it from the Setup tab.");
|
|
return null;
|
|
}
|
|
|
|
if (clip == null)
|
|
{
|
|
Debug.LogError("[SplashEdit] AudioClip is null.");
|
|
return null;
|
|
}
|
|
|
|
// Export Unity AudioClip to a temporary WAV file
|
|
string tempWav = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.wav");
|
|
string tempVag = Path.Combine(Path.GetTempPath(), $"psx_audio_{clip.name}.vag");
|
|
|
|
try
|
|
{
|
|
ExportWav(clip, tempWav);
|
|
|
|
// Run psxavenc: convert WAV to SPU ADPCM
|
|
// -t spu: raw SPU ADPCM output (no header, ready for DMA upload)
|
|
// -f <rate>: target sample rate
|
|
// -L: enable looping flag in the last ADPCM block
|
|
string loopFlag = loop ? "-L" : "";
|
|
string args = $"-t spu -f {targetSampleRate} {loopFlag} \"{tempWav}\" \"{tempVag}\"";
|
|
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = PsxavencBinary,
|
|
Arguments = args,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true
|
|
};
|
|
|
|
var process = Process.Start(psi);
|
|
string stderr = process.StandardError.ReadToEnd();
|
|
process.WaitForExit();
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
Debug.LogError($"[SplashEdit] psxavenc failed: {stderr}");
|
|
return null;
|
|
}
|
|
|
|
if (!File.Exists(tempVag))
|
|
{
|
|
Debug.LogError("[SplashEdit] psxavenc produced no output file.");
|
|
return null;
|
|
}
|
|
|
|
// -t spu outputs raw SPU ADPCM blocks (no header) — use directly.
|
|
byte[] adpcm = File.ReadAllBytes(tempVag);
|
|
if (adpcm.Length == 0)
|
|
{
|
|
Debug.LogError("[SplashEdit] psxavenc produced empty output.");
|
|
return null;
|
|
}
|
|
return adpcm;
|
|
}
|
|
finally
|
|
{
|
|
try { if (File.Exists(tempWav)) File.Delete(tempWav); } catch { }
|
|
try { if (File.Exists(tempVag)) File.Delete(tempVag); } catch { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports a Unity AudioClip to a 16-bit mono WAV file.
|
|
/// </summary>
|
|
private static void ExportWav(AudioClip clip, string path)
|
|
{
|
|
float[] samples = new float[clip.samples * clip.channels];
|
|
clip.GetData(samples, 0);
|
|
|
|
// Downmix to mono if stereo
|
|
float[] mono;
|
|
if (clip.channels > 1)
|
|
{
|
|
mono = new float[clip.samples];
|
|
for (int i = 0; i < clip.samples; i++)
|
|
{
|
|
float sum = 0;
|
|
for (int ch = 0; ch < clip.channels; ch++)
|
|
sum += samples[i * clip.channels + ch];
|
|
mono[i] = sum / clip.channels;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
mono = samples;
|
|
}
|
|
|
|
// Write WAV
|
|
using (var fs = new FileStream(path, FileMode.Create))
|
|
using (var writer = new BinaryWriter(fs))
|
|
{
|
|
int sampleCount = mono.Length;
|
|
int dataSize = sampleCount * 2; // 16-bit
|
|
int fileSize = 44 + dataSize;
|
|
|
|
// RIFF header
|
|
writer.Write(new char[] { 'R', 'I', 'F', 'F' });
|
|
writer.Write(fileSize - 8);
|
|
writer.Write(new char[] { 'W', 'A', 'V', 'E' });
|
|
|
|
// fmt chunk
|
|
writer.Write(new char[] { 'f', 'm', 't', ' ' });
|
|
writer.Write(16); // chunk size
|
|
writer.Write((short)1); // PCM
|
|
writer.Write((short)1); // mono
|
|
writer.Write(clip.frequency);
|
|
writer.Write(clip.frequency * 2); // byte rate
|
|
writer.Write((short)2); // block align
|
|
writer.Write((short)16); // bits per sample
|
|
|
|
// data chunk
|
|
writer.Write(new char[] { 'd', 'a', 't', 'a' });
|
|
writer.Write(dataSize);
|
|
|
|
for (int i = 0; i < sampleCount; i++)
|
|
{
|
|
short sample = (short)(Mathf.Clamp(mono[i], -1f, 1f) * 32767f);
|
|
writer.Write(sample);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|