From f127dbbe60c16ee45424b4ae802c2dc6d4178b1a Mon Sep 17 00:00:00 2001 From: jiPku <175839862+jiPku@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:34:11 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=B8=BA=20iOS=20=E6=B7=BB=E5=8A=A0=20AVAu?= =?UTF-8?q?dioEngineOutput=20=E7=B1=BB=E4=BB=A5=E6=94=AF=E6=8C=81=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E6=92=AD=E6=94=BE=E5=92=8C=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../iOS/Utils/Audio/AVAudioEngineOutput.cs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 OpenUtauMobile/Platforms/iOS/Utils/Audio/AVAudioEngineOutput.cs diff --git a/OpenUtauMobile/Platforms/iOS/Utils/Audio/AVAudioEngineOutput.cs b/OpenUtauMobile/Platforms/iOS/Utils/Audio/AVAudioEngineOutput.cs new file mode 100644 index 0000000..417af66 --- /dev/null +++ b/OpenUtauMobile/Platforms/iOS/Utils/Audio/AVAudioEngineOutput.cs @@ -0,0 +1,173 @@ +#if IOS +using System; +using System.Collections.Generic; +using System.Threading; +using Foundation; +using AVFoundation; +using OpenUtau.Audio; +using NAudio.Wave; +using Serilog; +using System.Runtime.InteropServices; +using System.IO; + +namespace OpenUtauMobile.Platforms.iOS.Utils.Audio { + public class AVAudioEngineOutput : IAudioOutput, IDisposable { + private ISampleProvider sampleProvider = null!; + private AVAudioPlayer? player; + private string? tmpFilePath; + private volatile bool running = false; + private int sampleRate = 44100; + private int channels = 1; + + public PlaybackState PlaybackState { get; private set; } = PlaybackState.Stopped; + public int DeviceNumber { get; private set; } = 0; + + public AVAudioEngineOutput() { + // noop + } + + public void SelectDevice(Guid guid, int deviceNumber) { + DeviceNumber = deviceNumber; + } + + public void Init(ISampleProvider sampleProvider) { + this.sampleProvider = sampleProvider; + sampleRate = sampleProvider.WaveFormat.SampleRate; + channels = sampleProvider.WaveFormat.Channels; + + Log.Information("AVAudioEngineOutput.Init: sampleRate={SampleRate}, channels={Channels}", sampleRate, channels); + + // Configure AVAudioSession + try { + var session = AVAudioSession.SharedInstance(); + session.SetCategory(AVAudioSessionCategory.Playback); + session.SetActive(true); + Log.Information("AVAudioSession activated for AVAudioEngineOutput"); + } catch (Exception ex) { + Log.Warning(ex, "Failed to activate AVAudioSession in AVAudioEngineOutput"); + } + + // Render entire sampleProvider to a temporary WAV file for AVAudioPlayer playback + try { + string tmpDir = Path.GetTempPath(); + string file = Path.Combine(tmpDir, $"openutaumobile_render_{Guid.NewGuid()}.wav"); + using (var fs = System.IO.File.Create(file)) { + // write WAV header later; use WaveFileWriter from NAudio to simplify + using (var writer = new NAudio.Wave.WaveFileWriter(fs, NAudio.Wave.WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channels))) { + float[] buffer = new float[4096 * Math.Max(1, channels)]; + int read; + while ((read = sampleProvider.Read(buffer, 0, buffer.Length)) > 0) { + writer.WriteSamples(buffer, 0, read); + } + } + } + tmpFilePath = file; + Log.Information("Rendered temp WAV to {Path}", tmpFilePath); + } catch (Exception ex) { + Log.Error(ex, "Failed to render temp WAV for AVAudioEngineOutput"); + } + } + + public void Play() { + if (PlaybackState == PlaybackState.Playing) return; + // Ensure Init rendered a temp file + // if no tmpFilePath available, warn + if (tmpFilePath == null) { + Log.Warning("AVAudioEngineOutput.Play called before Init or render failed"); + return; + } + running = true; + if (tmpFilePath != null && System.IO.File.Exists(tmpFilePath)) { + try { + NSError err; + var url = NSUrl.FromFilename(tmpFilePath); + player = AVAudioPlayer.FromUrl(url, out err); + if (err != null) Log.Error("AVAudioPlayer init error: {Err}", err); + player.NumberOfLoops = 0; + player.PrepareToPlay(); + + // Attach finished handler to cleanup temp file when playback ends + player.FinishedPlaying += (s, e) => { + try { + PlaybackState = PlaybackState.Stopped; + running = false; + Log.Information("AVAudioPlayer finished playing, cleaning up temp file"); + if (!string.IsNullOrEmpty(tmpFilePath) && System.IO.File.Exists(tmpFilePath)) { + try { System.IO.File.Delete(tmpFilePath); Log.Information("Deleted temp WAV {Path}", tmpFilePath); } catch (Exception ex) { Log.Warning(ex, "Failed to delete temp WAV {Path}", tmpFilePath); } + tmpFilePath = null; + } + } catch (Exception ex) { + Log.Warning(ex, "Exception in FinishedPlaying handler"); + } + }; + + player.Play(); + } catch (Exception ex) { + Log.Error(ex, "Failed to start AVAudioPlayer"); + } + } else { + Log.Warning("No rendered WAV available for playback"); + } + PlaybackState = PlaybackState.Playing; + } + + public void Pause() { + if (PlaybackState != PlaybackState.Playing) return; + try { player?.Pause(); } catch (Exception ex) { Log.Error(ex, "Pause failed"); } + PlaybackState = PlaybackState.Paused; + running = false; + } + + public void Stop() { + if (PlaybackState == PlaybackState.Stopped) return; + running = false; + try { + player?.Stop(); + } catch (Exception ex) { Log.Error(ex, "Stop failed"); } + // try to delete tmp file after stop + try { + if (!string.IsNullOrEmpty(tmpFilePath) && System.IO.File.Exists(tmpFilePath)) { + try { System.IO.File.Delete(tmpFilePath); Log.Information("Deleted temp WAV {Path} on Stop", tmpFilePath); } catch (Exception ex) { Log.Warning(ex, "Failed to delete temp WAV {Path} on Stop", tmpFilePath); } + tmpFilePath = null; + } + } catch (Exception) { } + PlaybackState = PlaybackState.Stopped; + } + + private void FillLoop() { + // no-op: rendering performed in Init for AVAudioPlayer-based playback + return; + } + + public long GetPosition() { + try { + if (player != null && player.Playing) { + // Return bytes-per-channel to match other outputs: samples * sizeof(float) + double seconds = player.CurrentTime; + long samples = (long)(seconds * sampleRate); + long bytesPerChannel = samples * sizeof(float); + return bytesPerChannel; + } + } catch (Exception ex) { + Log.Warning(ex, "GetPosition() failed"); + } + return 0; + } + + public List GetOutputDevices() { + return new List { new AudioOutputDevice { name = "iOS AVAudioEngine", api = "AVAudioEngine", deviceNumber = 0, guid = Guid.Empty } }; + } + + public void Dispose() { + try { Stop(); } catch { } + try { player?.Dispose(); } catch { } + try { + if (!string.IsNullOrEmpty(tmpFilePath) && System.IO.File.Exists(tmpFilePath)) { + try { System.IO.File.Delete(tmpFilePath); Log.Information("Deleted temp WAV {Path} on Dispose", tmpFilePath); } catch (Exception ex) { Log.Warning(ex, "Failed to delete temp WAV {Path} on Dispose", tmpFilePath); } + tmpFilePath = null; + } + } catch { } + } + } +} +#endif From 15a04e8aa3eea1c29f18ca19bd4ed293e362699e Mon Sep 17 00:00:00 2001 From: jiPku <175839862+jiPku@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:48:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?iOS:=20=E5=B0=86=20AudioQueueOutput=20?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E4=B8=BA=20AVAudioEngineOutput=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=88=9D=E5=A7=8B=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OpenUtauMobile/Utils/ObjectProvider.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/OpenUtauMobile/Utils/ObjectProvider.cs b/OpenUtauMobile/Utils/ObjectProvider.cs index 5a52de4..aa2b7cd 100644 --- a/OpenUtauMobile/Utils/ObjectProvider.cs +++ b/OpenUtauMobile/Utils/ObjectProvider.cs @@ -32,7 +32,13 @@ public static void Initialize() AppLifeCycleHelper = new OpenUtauMobile.Platforms.Android.Utils.AndroidAppLifeCycleHelper(); #elif IOS ExternalStorageService = new OpenUtauMobile.Platforms.iOS.Utils.Permission.ExternalStorageService(); - AudioOutput = new OpenUtau.Audio.DummyAudioOutput(); // iOS平台使用DummyAudioOutput作为占位符 + try { + AudioOutput = new OpenUtauMobile.Platforms.iOS.Utils.Audio.AVAudioEngineOutput(); + } catch (Exception ex) { + Log.Error(ex, "Failed to initialize AVAudioEngineOutput, falling back to DummyAudioOutput"); + AudioOutput = new OpenUtau.Audio.DummyAudioOutput(); + } + Log.Information("ObjectProvider initialized AudioOutput: {AudioOutput}", AudioOutput?.GetType().FullName); AppLifeCycleHelper = new OpenUtauMobile.Platforms.iOS.Utils.iOSAppLifeCycleHelper(); #elif WINDOWS ExternalStorageService = new OpenUtauMobile.Platforms.Windows.Utils.Permission.ExternalStorageService(); From c9bb8ba807e63af04d68b9d2d98beec2f405ead6 Mon Sep 17 00:00:00 2001 From: vocoder712 <100213316+vocoder712@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:02:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OpenUtauMobile/OpenUtauMobile.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenUtauMobile/OpenUtauMobile.csproj b/OpenUtauMobile/OpenUtauMobile.csproj index 29c6373..f19a7fd 100644 --- a/OpenUtauMobile/OpenUtauMobile.csproj +++ b/OpenUtauMobile/OpenUtauMobile.csproj @@ -92,7 +92,8 @@ - + + @@ -115,7 +116,6 @@ - From 38fe9417555a2030946b5b3d933a316ad5e548cc Mon Sep 17 00:00:00 2001 From: jiPku <175839862+jiPku@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:57:21 +0800 Subject: [PATCH 4/4] =?UTF-8?q?iOS=E4=BF=AE=E5=A4=8D:=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E5=8F=A0=E5=8A=A0=E5=B1=82,=E5=B9=B6?= =?UTF-8?q?=E4=B8=BA=E8=A3=85=E9=A5=B0=E6=80=A7=E5=9B=BE=E5=B1=82=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E8=A7=A6=E6=91=B8=E7=A9=BF=E9=80=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OpenUtauMobile/Views/EditPage.xaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/OpenUtauMobile/Views/EditPage.xaml b/OpenUtauMobile/Views/EditPage.xaml index 563fd3f..a298be3 100644 --- a/OpenUtauMobile/Views/EditPage.xaml +++ b/OpenUtauMobile/Views/EditPage.xaml @@ -300,14 +300,14 @@ - - - - + + + + - - + +