fixed gpu
fixed a bunch of other stuff over the last few weeks.
This commit is contained in:
parent
b107d8c6a6
commit
05ba2789ba
|
|
@ -86,6 +86,12 @@ void viewport_set_capture_metadata(struct ViewportHandle *handle,
|
|||
uint64_t position_ms,
|
||||
uint64_t duration_ms);
|
||||
|
||||
/// drains the pending Picture-in-Picture entry-request flag set by tapping the PiP chip.
|
||||
bool viewport_take_pending_pip_request(struct ViewportHandle *handle);
|
||||
|
||||
/// writes the latest left-channel bin dB values into a caller-provided float buffer. returns the number of values actually written.
|
||||
size_t viewport_get_pip_snapshot(struct ViewportHandle *handle, float *out, size_t max_len);
|
||||
|
||||
/// serializes both settings slots as a JSON string. caller frees via viewport_free_string.
|
||||
char *viewport_get_settings_json(struct ViewportHandle *handle);
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>picture-in-picture</string>
|
||||
</array>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,21 @@ import AVFoundation
|
|||
/// gates mic capture on AVCaptureDevice audio permission and exposes start/stop to the iced viewport.
|
||||
class CaptureController {
|
||||
|
||||
private let session = CaptureSession()
|
||||
let session = CaptureSession()
|
||||
private var pipController: AnyObject?
|
||||
weak var view: IcedViewportView?
|
||||
|
||||
/// true while AVAudioEngine is running through CaptureSession.
|
||||
var isCapturing: Bool { session.isCapturing }
|
||||
|
||||
/// true when the PipController reports an active PiP session. used by CaptureSession to skip teardown on resignActive.
|
||||
var isPictureInPictureActive: Bool {
|
||||
if #available(iOS 15.0, *), let p = pipController as? PipController {
|
||||
return p.isPictureInPictureActive
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// requests AVCaptureDevice audio permission if missing; starts the mic session on grant or when already granted.
|
||||
func start() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
|
|
@ -28,19 +37,44 @@ class CaptureController {
|
|||
}
|
||||
}
|
||||
|
||||
/// stops the mic session.
|
||||
/// forwards a PiP-entry request from the rust drain to the PipController.
|
||||
func enterPip() {
|
||||
if #available(iOS 15.0, *) {
|
||||
(pipController as? PipController)?.enterPip()
|
||||
}
|
||||
}
|
||||
|
||||
/// stops the mic session and tears down the PiP feed.
|
||||
func stop() {
|
||||
session.stop()
|
||||
if #available(iOS 15.0, *), let p = pipController as? PipController {
|
||||
p.stopFeeding()
|
||||
}
|
||||
pipController = nil
|
||||
}
|
||||
|
||||
private func startSession() {
|
||||
session.viewportHandleProvider = { [weak view = self.view] in
|
||||
view?.viewportHandle
|
||||
}
|
||||
session.pipActiveProvider = { [weak self] in
|
||||
self?.isPictureInPictureActive ?? false
|
||||
}
|
||||
do {
|
||||
try session.start()
|
||||
} catch {
|
||||
print("[YrXtals] CaptureSession start failed: \(error)")
|
||||
}
|
||||
startPipFeedIfPossible()
|
||||
}
|
||||
|
||||
/// lazily creates the PipController (iOS 15+) and starts feeding it visualizer snapshots so PiP entry is eligible the moment the user taps the chip.
|
||||
private func startPipFeedIfPossible() {
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
guard let v = view else { return }
|
||||
if pipController == nil {
|
||||
pipController = PipController(view: v)
|
||||
}
|
||||
(pipController as? PipController)?.startFeeding()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ class CaptureSession {
|
|||
/// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime.
|
||||
var viewportHandleProvider: (() -> OpaquePointer?)?
|
||||
|
||||
/// reports whether Picture-in-Picture is currently active. when true, we skip teardown on resignActive so the engine keeps running in the PiP window.
|
||||
var pipActiveProvider: (() -> Bool)?
|
||||
|
||||
/// stashes the capture-on intent, runs the full configure + start sequence, and registers lifecycle observers.
|
||||
func start() throws {
|
||||
if isCapturing { return }
|
||||
|
|
@ -120,8 +123,12 @@ class CaptureSession {
|
|||
observers.removeAll()
|
||||
}
|
||||
|
||||
/// fully tears down on transition out of active state. preserves isCapturing so the resume path knows to restart.
|
||||
/// fully tears down on transition out of active state — unless PiP is active, in which case iOS keeps us alive in the floating window and we must keep the engine running.
|
||||
private func handleResignActive() {
|
||||
if pipActiveProvider?() == true {
|
||||
print("[YrXtals] resignActive but PiP active, keeping engine alive")
|
||||
return
|
||||
}
|
||||
teardownEngineAndSession()
|
||||
print("[YrXtals] CaptureSession torn down on resignActive")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,10 @@ class IcedViewportView: UIView {
|
|||
default: break
|
||||
}
|
||||
|
||||
if viewport_take_pending_pip_request(handle) {
|
||||
captureController?.enterPip()
|
||||
}
|
||||
|
||||
if viewport_take_pending_persist_settings(handle) {
|
||||
if let cstr = viewport_get_settings_json(handle) {
|
||||
let json = String(cString: cstr)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
import AVKit
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
|
||||
/// drives an AVPictureInPictureController backed by an AVSampleBufferDisplayLayer that we feed with a simplified bar visualization sampled from the rust analyzer.
|
||||
@available(iOS 15.0, *)
|
||||
class PipController: NSObject {
|
||||
|
||||
weak var view: IcedViewportView?
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private var displayLink: CADisplayLink?
|
||||
private var binsBuffer: [Float] = Array(repeating: 0, count: 64)
|
||||
|
||||
private let renderSize = CGSize(width: 320, height: 180)
|
||||
private static let displayTimescale: Int32 = 600
|
||||
|
||||
init(view: IcedViewportView) {
|
||||
super.init()
|
||||
self.view = view
|
||||
attachDisplayLayer()
|
||||
setupPipController()
|
||||
}
|
||||
|
||||
/// returns true when AVPictureInPictureController reports an active PiP session.
|
||||
var isPictureInPictureActive: Bool {
|
||||
pipController?.isPictureInPictureActive ?? false
|
||||
}
|
||||
|
||||
/// begins the CADisplayLink that pulls bin magnitudes and enqueues sample buffers at ~30 fps.
|
||||
func startFeeding() {
|
||||
if displayLink != nil { return }
|
||||
let link = CADisplayLink(target: self, selector: #selector(tick))
|
||||
link.preferredFramesPerSecond = 30
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
}
|
||||
|
||||
/// stops the feed and flushes the display layer.
|
||||
func stopFeeding() {
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
displayLayer.flushAndRemoveImage()
|
||||
}
|
||||
|
||||
/// requests AVPictureInPictureController to enter PiP. no-op when already active or unsupported.
|
||||
func enterPip() {
|
||||
guard let pipController = pipController else { return }
|
||||
if pipController.isPictureInPictureActive { return }
|
||||
if !pipController.isPictureInPicturePossible {
|
||||
print("[YrXtals] PiP not yet possible (no samples enqueued?)")
|
||||
return
|
||||
}
|
||||
pipController.startPictureInPicture()
|
||||
}
|
||||
|
||||
/// places the AVSampleBufferDisplayLayer in the view hierarchy off-screen so it's a valid PiP source without obscuring the metal layer.
|
||||
private func attachDisplayLayer() {
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
displayLayer.frame = CGRect(origin: .zero, size: renderSize)
|
||||
displayLayer.bounds = CGRect(origin: .zero, size: renderSize)
|
||||
displayLayer.opacity = 0
|
||||
view?.layer.addSublayer(displayLayer)
|
||||
}
|
||||
|
||||
/// wires up AVPictureInPictureController with the sample buffer display layer content source.
|
||||
private func setupPipController() {
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
||||
print("[YrXtals] PiP not supported on this device")
|
||||
return
|
||||
}
|
||||
let source = AVPictureInPictureController.ContentSource(
|
||||
sampleBufferDisplayLayer: displayLayer,
|
||||
playbackDelegate: self,
|
||||
)
|
||||
let controller = AVPictureInPictureController(contentSource: source)
|
||||
controller.delegate = self
|
||||
pipController = controller
|
||||
}
|
||||
|
||||
/// pulls bin magnitudes from rust, renders a simplified bar visualization into a CMSampleBuffer, enqueues it.
|
||||
@objc private func tick() {
|
||||
guard let view = view, let handle = view.viewportHandle else { return }
|
||||
let n = binsBuffer.withUnsafeMutableBufferPointer { buf in
|
||||
viewport_get_pip_snapshot(handle, buf.baseAddress, buf.count)
|
||||
}
|
||||
if n == 0 {
|
||||
// no frames yet, but iOS expects samples — enqueue a blank one so PiP eligibility flips on
|
||||
enqueueRenderedFrame(bins: [])
|
||||
return
|
||||
}
|
||||
let bins = Array(binsBuffer.prefix(n))
|
||||
enqueueRenderedFrame(bins: bins)
|
||||
}
|
||||
|
||||
/// renders bins as vertical bars onto a CVPixelBuffer, wraps in CMSampleBuffer, enqueues to the display layer.
|
||||
private func enqueueRenderedFrame(bins: [Float]) {
|
||||
guard let pixelBuffer = makePixelBuffer() else { return }
|
||||
|
||||
CVPixelBufferLockBaseAddress(pixelBuffer, [])
|
||||
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
|
||||
|
||||
let width = CVPixelBufferGetWidth(pixelBuffer)
|
||||
let height = CVPixelBufferGetHeight(pixelBuffer)
|
||||
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
||||
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo: UInt32 = CGBitmapInfo.byteOrder32Little.rawValue
|
||||
| CGImageAlphaInfo.premultipliedFirst.rawValue
|
||||
guard let ctx = CGContext(
|
||||
data: base, width: width, height: height,
|
||||
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
|
||||
space: colorSpace, bitmapInfo: bitmapInfo,
|
||||
) else { return }
|
||||
|
||||
drawBars(into: ctx, bins: bins, width: width, height: height)
|
||||
|
||||
var formatDescription: CMFormatDescription?
|
||||
CMVideoFormatDescriptionCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
formatDescriptionOut: &formatDescription,
|
||||
)
|
||||
guard let formatDesc = formatDescription else { return }
|
||||
|
||||
let timescale = Self.displayTimescale
|
||||
let presentationTime = CMTime(
|
||||
value: CMTimeValue(CACurrentMediaTime() * Double(timescale)),
|
||||
timescale: timescale,
|
||||
)
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime(value: CMTimeValue(timescale / 30), timescale: timescale),
|
||||
presentationTimeStamp: presentationTime,
|
||||
decodeTimeStamp: .invalid,
|
||||
)
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
CMSampleBufferCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
dataReady: true,
|
||||
makeDataReadyCallback: nil,
|
||||
refcon: nil,
|
||||
formatDescription: formatDesc,
|
||||
sampleTiming: &timing,
|
||||
sampleBufferOut: &sampleBuffer,
|
||||
)
|
||||
guard let sb = sampleBuffer else { return }
|
||||
|
||||
if displayLayer.status == .failed {
|
||||
displayLayer.flush()
|
||||
}
|
||||
displayLayer.enqueue(sb)
|
||||
}
|
||||
|
||||
/// paints the bars onto the CG context. dB values normalized into 0..1 by the standard -60dB..0dB window.
|
||||
private func drawBars(into ctx: CGContext, bins: [Float], width: Int, height: Int) {
|
||||
ctx.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
let count = max(bins.count, 1)
|
||||
let barWidth = CGFloat(width) / CGFloat(count)
|
||||
let h = CGFloat(height)
|
||||
|
||||
for (i, db) in bins.enumerated() {
|
||||
let normalized = max(0, min(1, (CGFloat(db) + 60) / 60))
|
||||
let barHeight = normalized * h * 0.9
|
||||
let x = CGFloat(i) * barWidth
|
||||
let y = (h - barHeight) / 2
|
||||
let hue = CGFloat(i) / CGFloat(count)
|
||||
let color = UIColor(hue: hue, saturation: 0.85, brightness: 1.0, alpha: 1.0).cgColor
|
||||
ctx.setFillColor(color)
|
||||
ctx.fill(CGRect(x: x + 1, y: y, width: max(barWidth - 2, 1), height: barHeight))
|
||||
}
|
||||
}
|
||||
|
||||
/// allocates a BGRA CVPixelBuffer at the render size, IOSurface-backed for AVSampleBufferDisplayLayer compatibility.
|
||||
private func makePixelBuffer() -> CVPixelBuffer? {
|
||||
let attrs: [CFString: Any] = [
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
|
||||
kCVPixelBufferCGImageCompatibilityKey: true,
|
||||
kCVPixelBufferCGBitmapContextCompatibilityKey: true,
|
||||
]
|
||||
var pb: CVPixelBuffer?
|
||||
let status = CVPixelBufferCreate(
|
||||
kCFAllocatorDefault,
|
||||
Int(renderSize.width),
|
||||
Int(renderSize.height),
|
||||
kCVPixelFormatType_32BGRA,
|
||||
attrs as CFDictionary,
|
||||
&pb,
|
||||
)
|
||||
return status == kCVReturnSuccess ? pb : nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
extension PipController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
setPlaying playing: Bool,
|
||||
) {
|
||||
// the visualizer "plays" continuously while in PiP; the toggle is ignored.
|
||||
}
|
||||
|
||||
func pictureInPictureControllerTimeRangeForPlayback(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
) -> CMTimeRange {
|
||||
return CMTimeRange(start: .negativeInfinity, end: .positiveInfinity)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerIsPlaybackPaused(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
didTransitionToRenderSize newRenderSize: CMVideoDimensions,
|
||||
) {
|
||||
// ignored; we render at a fixed internal size and let AVKit scale.
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
skipByInterval skipInterval: CMTime,
|
||||
completion completionHandler: @escaping () -> Void,
|
||||
) {
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
extension PipController: AVPictureInPictureControllerDelegate {
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
) {
|
||||
print("[YrXtals] PiP will start")
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
) {
|
||||
print("[YrXtals] PiP did start")
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
) {
|
||||
print("[YrXtals] PiP will stop")
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
) {
|
||||
print("[YrXtals] PiP did stop")
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error,
|
||||
) {
|
||||
print("[YrXtals] PiP failed to start: \(error)")
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,6 @@
|
|||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Yr Xtals does not record audio. This entry is here only because the audio framework asks for it on some macOS versions.</string>
|
||||
<string>Yr Xtals listens through the microphone in Playback capture mode so the visualizer reacts to whatever audio is playing around you.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ pub struct Analyzer {
|
|||
|
||||
/// smoothed AGC gain applied to incoming live PCM before it enters the analyzer buffer.
|
||||
live_gain: f64,
|
||||
|
||||
/// dB threshold below which incoming live PCM chunks are replaced with silence.
|
||||
noise_gate_db: f32,
|
||||
}
|
||||
|
||||
impl Analyzer {
|
||||
|
|
@ -104,9 +107,15 @@ impl Analyzer {
|
|||
live_buffer: VecDeque::with_capacity(192_000),
|
||||
live_buffer_max: 192_000,
|
||||
live_gain: 1.0,
|
||||
noise_gate_db: -100.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// stores the noise-gate threshold in dB applied to live-mode PCM chunks.
|
||||
pub fn set_noise_gate(&mut self, db: f32) {
|
||||
self.noise_gate_db = db;
|
||||
}
|
||||
|
||||
/// returns the loaded track's sample rate, falling back to 48 khz before any track loads.
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.track.as_ref().map(|t| t.sample_rate).unwrap_or(48_000)
|
||||
|
|
@ -128,26 +137,29 @@ impl Analyzer {
|
|||
}
|
||||
|
||||
/// retunes the three bands' transform lengths around a base frame size and routes the hilbert hop to match.
|
||||
/// always force-clears processor buffers, smoothing history, hilbert state, and the live buffer so the result of a given (fft, hop) pair doesn't depend on the path taken to get there.
|
||||
pub fn set_dsp_params(&mut self, frame_size: usize, hop_size: usize) {
|
||||
let trans_size = (frame_size / 4).max(64);
|
||||
let deep_size = if frame_size < 2048 { frame_size * 4 } else { frame_size * 2 };
|
||||
let hilbert_changed =
|
||||
frame_size != self.hilbert_fft_size || hop_size != self.hilbert_hop_size;
|
||||
if hilbert_changed {
|
||||
|
||||
self.hilbert_fft_size = frame_size;
|
||||
self.hilbert_hop_size = hop_size;
|
||||
self.hilbert_needs_reset = true;
|
||||
}
|
||||
self.live_buffer.clear();
|
||||
self.frame_size = frame_size;
|
||||
self.hop_size = hop_size;
|
||||
|
||||
for p in &mut self.main {
|
||||
p.set_frame_size(frame_size);
|
||||
p.clear_state();
|
||||
}
|
||||
for p in &mut self.transient {
|
||||
p.set_frame_size(trans_size);
|
||||
p.clear_state();
|
||||
}
|
||||
for p in &mut self.deep {
|
||||
p.set_frame_size(deep_size);
|
||||
p.clear_state();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +282,24 @@ impl Analyzer {
|
|||
sum_sq += (s as f64) * (s as f64);
|
||||
}
|
||||
let rms = (sum_sq / samples.len() as f64).sqrt();
|
||||
let rms_db = 20.0 * (rms.max(1e-12)).log10() as f32;
|
||||
|
||||
let ch = channels as usize;
|
||||
if rms_db < self.noise_gate_db {
|
||||
// chunk is below the gate — write silence at the same frame count to keep the hop clock alive.
|
||||
let frames = if ch > 0 { samples.len() / ch } else { 0 };
|
||||
for _ in 0..frames {
|
||||
self.live_buffer.push_back(0.0);
|
||||
self.live_buffer.push_back(0.0);
|
||||
}
|
||||
while self.live_buffer.len() > self.live_buffer_max {
|
||||
self.live_buffer.pop_front();
|
||||
self.live_buffer.pop_front();
|
||||
}
|
||||
self.trim_live_buffer();
|
||||
return;
|
||||
}
|
||||
|
||||
let target_gain = if rms > MIN_RMS {
|
||||
(TARGET_RMS / rms).clamp(MIN_GAIN, MAX_GAIN)
|
||||
} else {
|
||||
|
|
@ -279,7 +309,6 @@ impl Analyzer {
|
|||
self.live_gain += alpha * (target_gain - self.live_gain);
|
||||
let gain = self.live_gain as f32;
|
||||
|
||||
let ch = channels as usize;
|
||||
match ch {
|
||||
2 => {
|
||||
for &s in samples {
|
||||
|
|
@ -306,6 +335,39 @@ impl Analyzer {
|
|||
self.live_buffer.pop_front();
|
||||
self.live_buffer.pop_front();
|
||||
}
|
||||
|
||||
// real-time anchor every PCM push, not only when step_live happens to fire. without this, audio piles up between step_lives whenever the worker is busy and the visualizer slides further behind reality until the buffer hits its 2-second cap.
|
||||
self.trim_live_buffer();
|
||||
}
|
||||
|
||||
/// drops oldest live-buffer entries down to what the current (fft, hop) needs plus a small fixed real-time slack, so the visualizer always works against recent audio.
|
||||
fn trim_live_buffer(&mut self) {
|
||||
let hop = self.hilbert_hop_size;
|
||||
let fft = self.hilbert_fft_size;
|
||||
if hop == 0 || fft == 0 {
|
||||
return;
|
||||
}
|
||||
let warmup_blocks = fft / hop;
|
||||
let needed = if self.hilbert_needs_reset {
|
||||
warmup_blocks * hop * 2 + hop * 2
|
||||
} else {
|
||||
hop * 2
|
||||
};
|
||||
// fixed ~30ms slack across all configs — keeps lag bounded regardless of hop size.
|
||||
let slack_ms = 30usize;
|
||||
let slack_entries = if self.live_sample_rate == 0 {
|
||||
hop * 4
|
||||
} else {
|
||||
(self.live_sample_rate as usize * slack_ms / 1000) * 2
|
||||
};
|
||||
let max_keep = (needed + slack_entries).min(self.live_buffer_max);
|
||||
if self.live_buffer.len() > max_keep {
|
||||
let drop_pairs = (self.live_buffer.len() - max_keep) / 2;
|
||||
for _ in 0..drop_pairs {
|
||||
self.live_buffer.pop_front();
|
||||
self.live_buffer.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// consumes one hop's worth of interleaved stereo from the live buffer and publishes a frame pair.
|
||||
|
|
@ -315,6 +377,10 @@ impl Analyzer {
|
|||
if hop == 0 || fft == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// defense-in-depth trim in case PCM stopped arriving briefly (no push_live_pcm calls but old buffered audio still around).
|
||||
self.trim_live_buffer();
|
||||
|
||||
if self.hilbert_needs_reset {
|
||||
let warmup_blocks = fft / hop;
|
||||
let warmup_stereo = warmup_blocks * hop * 2;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ enum Cmd {
|
|||
SetNumBins(usize),
|
||||
SetSmoothing { granularity: i32, detail: i32, strength: f32 },
|
||||
SetGpuBlend(f32),
|
||||
SetNoiseGate(f32),
|
||||
SetMode(AnalyzerMode),
|
||||
PushLivePcm { samples: Vec<f32>, sample_rate: u32, channels: u32 },
|
||||
Shutdown,
|
||||
|
|
@ -109,6 +110,11 @@ impl AnalyzerWorker {
|
|||
let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend));
|
||||
}
|
||||
|
||||
/// queues a noise-gate threshold change in dB.
|
||||
pub fn set_noise_gate(&self, db: f32) {
|
||||
let _ = self.cmd_tx.send(Cmd::SetNoiseGate(db));
|
||||
}
|
||||
|
||||
/// queues a switch between Track and Live analyzer modes.
|
||||
pub fn set_mode(&self, mode: AnalyzerMode) {
|
||||
let _ = self.cmd_tx.send(Cmd::SetMode(mode));
|
||||
|
|
@ -209,9 +215,13 @@ fn run(
|
|||
}
|
||||
}
|
||||
AnalyzerMode::Live => {
|
||||
// drains all hops queued in the live buffer per tick so small hops (e.g. 64) don't fall behind the audio thread; publishes only the most recent frame pair.
|
||||
// drains queued hops with a wall-clock budget so heavy fft+small-hop configs don't burn a full tick on a single drain pass and starve the pcm channel.
|
||||
let drain_deadline = Instant::now() + Duration::from_millis(8);
|
||||
let mut latest_owned: Option<Vec<FrameData>> = None;
|
||||
for _ in 0..200 {
|
||||
if Instant::now() >= drain_deadline {
|
||||
break;
|
||||
}
|
||||
match analyzer.step_live() {
|
||||
Some(latest) => latest_owned = Some(latest.to_vec()),
|
||||
None => break,
|
||||
|
|
@ -244,6 +254,7 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) {
|
|||
strength,
|
||||
} => analyzer.set_smoothing_params(granularity, detail, strength),
|
||||
Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b),
|
||||
Cmd::SetNoiseGate(db) => analyzer.set_noise_gate(db),
|
||||
Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {}
|
||||
Cmd::Shutdown => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
use cpal::{SampleFormat, Stream};
|
||||
|
||||
use crate::analyzer_worker::PcmSender;
|
||||
use crate::devices;
|
||||
|
||||
/// owns the cpal input stream that pushes captured PCM into the analyzer worker.
|
||||
pub struct DesktopCapture {
|
||||
_stream: Stream,
|
||||
}
|
||||
|
||||
impl DesktopCapture {
|
||||
/// opens the default input device and starts an f32/i16/u16 input stream feeding the analyzer.
|
||||
pub fn start(pusher: PcmSender) -> Result<Self, String> {
|
||||
Self::start_with_device(pusher, None)
|
||||
}
|
||||
|
||||
/// opens an input device by name (system default when name is None) and starts the capture stream.
|
||||
pub fn start_with_device(pusher: PcmSender, name: Option<&str>) -> Result<Self, String> {
|
||||
let device = devices::input_device(name)
|
||||
.ok_or_else(|| "no input device available".to_string())?;
|
||||
let config = device
|
||||
.default_input_config()
|
||||
.map_err(|e| format!("default input config: {e}"))?;
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels() as u32;
|
||||
let sample_format = config.sample_format();
|
||||
let stream_config: cpal::StreamConfig = config.into();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!(
|
||||
"yr_crystals[dbg] capture device opened: sr={sample_rate} ch={channels} fmt={sample_format:?}",
|
||||
);
|
||||
|
||||
let err_fn = |e| eprintln!("yr_crystals: capture stream error: {e}");
|
||||
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => device.build_input_stream(
|
||||
&stream_config,
|
||||
move |data: &[f32], _: &_| {
|
||||
pusher.push(data.to_vec(), sample_rate, channels);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
SampleFormat::I16 => device.build_input_stream(
|
||||
&stream_config,
|
||||
move |data: &[i16], _: &_| {
|
||||
let samples: Vec<f32> =
|
||||
data.iter().map(|&s| s as f32 / i16::MAX as f32).collect();
|
||||
pusher.push(samples, sample_rate, channels);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
SampleFormat::U16 => device.build_input_stream(
|
||||
&stream_config,
|
||||
move |data: &[u16], _: &_| {
|
||||
let samples: Vec<f32> = data
|
||||
.iter()
|
||||
.map(|&s| (s as f32 - 32768.0) / 32768.0)
|
||||
.collect();
|
||||
pusher.push(samples, sample_rate, channels);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
),
|
||||
other => return Err(format!("unsupported capture sample format: {other:?}")),
|
||||
}
|
||||
.map_err(|e| format!("build input stream: {e}"))?;
|
||||
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| format!("start input stream: {e}"))?;
|
||||
|
||||
Ok(Self { _stream: stream })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
/// returns the names of every input device on the default host, with duplicates kept.
|
||||
pub fn list_inputs() -> Vec<String> {
|
||||
let host = cpal::default_host();
|
||||
host.input_devices()
|
||||
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// returns the names of every output device on the default host, with duplicates kept.
|
||||
pub fn list_outputs() -> Vec<String> {
|
||||
let host = cpal::default_host();
|
||||
host.output_devices()
|
||||
.map(|iter| iter.filter_map(|d| d.name().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// resolves an optional device name to a cpal input device, falling back to the system default.
|
||||
pub fn input_device(name: Option<&str>) -> Option<cpal::Device> {
|
||||
let host = cpal::default_host();
|
||||
if let Some(want) = name {
|
||||
if let Ok(mut iter) = host.input_devices() {
|
||||
if let Some(found) = iter.find(|d| d.name().ok().as_deref() == Some(want)) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
host.default_input_device()
|
||||
}
|
||||
|
||||
/// resolves an optional device name to a cpal output device, falling back to the system default.
|
||||
pub fn output_device(name: Option<&str>) -> Option<cpal::Device> {
|
||||
let host = cpal::default_host();
|
||||
if let Some(want) = name {
|
||||
if let Ok(mut iter) = host.output_devices() {
|
||||
if let Some(found) = iter.find(|d| d.name().ok().as_deref() == Some(want)) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
host.default_output_device()
|
||||
}
|
||||
|
|
@ -60,15 +60,27 @@ pub struct AudioEngine {
|
|||
}
|
||||
|
||||
impl AudioEngine {
|
||||
/// opens the default output device, builds the f32 stream, and starts the audio thread paused.
|
||||
/// opens the default output device.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Self::with_output_device(None)
|
||||
}
|
||||
|
||||
/// opens an output device by name (system default when name is None), builds the f32 stream, and starts the audio thread paused.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn with_output_device(name: Option<&str>) -> Result<Self, String> {
|
||||
#[cfg(debug_assertions)]
|
||||
let _t0 = std::time::Instant::now();
|
||||
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
let device = match name {
|
||||
Some(want) => host
|
||||
.output_devices()
|
||||
.ok()
|
||||
.and_then(|mut iter| iter.find(|d| d.name().ok().as_deref() == Some(want)))
|
||||
.or_else(|| host.default_output_device()),
|
||||
None => host.default_output_device(),
|
||||
}
|
||||
.ok_or_else(|| "no default output device".to_string())?;
|
||||
let config = device
|
||||
.default_output_config()
|
||||
|
|
|
|||
192
src/gpu_dsp.rs
192
src/gpu_dsp.rs
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use num_complex::Complex64;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const FFT_WGSL: &str = include_str!("../shaders/fft.wgsl");
|
||||
|
||||
|
|
@ -17,35 +18,39 @@ struct Args {
|
|||
|
||||
const ARGS_BYTES: u64 = std::mem::size_of::<Args>() as u64;
|
||||
|
||||
/// fixed-size 1D radix-2 FFT running on a wgpu compute queue with a single staged readback path.
|
||||
/// number of pipelined slots. 2 is enough for a one-frame lag (one in-flight + one available for new submit) — each slot's scratch buffer is free by the time we cycle back to it.
|
||||
const NUM_SLOTS: usize = 2;
|
||||
|
||||
/// per-slot gpu resources: each in-flight submission gets exclusive use of its slot, so the next submission can be queued onto a different slot without barriers serializing them on the shared scratch buffer.
|
||||
struct GpuSlot {
|
||||
data_buf: wgpu::Buffer,
|
||||
scratch_buf: wgpu::Buffer,
|
||||
args_buf: wgpu::Buffer,
|
||||
staging: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
staging_mapped: bool,
|
||||
}
|
||||
|
||||
/// fixed-size 1D radix-2 FFT with N pipelined slots. submit_forward queues work onto the next slot; try_collect_into drains the oldest pending submission specifically by its SubmissionIndex (no device-wide wait).
|
||||
pub struct GpuFft1D {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
n: u32,
|
||||
log2_n: u32,
|
||||
|
||||
pending: Option<wgpu::SubmissionIndex>,
|
||||
|
||||
staging_mapped: bool,
|
||||
|
||||
bit_reverse: wgpu::ComputePipeline,
|
||||
butterfly: wgpu::ComputePipeline,
|
||||
|
||||
args_buf: wgpu::Buffer,
|
||||
|
||||
all_args_buf: wgpu::Buffer,
|
||||
|
||||
cached_inverse: std::cell::Cell<Option<bool>>,
|
||||
|
||||
data_buf: wgpu::Buffer,
|
||||
scratch_buf: wgpu::Buffer,
|
||||
staging: wgpu::Buffer,
|
||||
|
||||
bind_group: wgpu::BindGroup,
|
||||
slots: Vec<GpuSlot>,
|
||||
next_submit: usize,
|
||||
pending: VecDeque<(usize, wgpu::SubmissionIndex)>,
|
||||
}
|
||||
|
||||
impl GpuFft1D {
|
||||
/// allocates pipelines, buffers, and bind groups for an N-point FFT with N a power of two.
|
||||
/// allocates pipelines, per-slot buffers and bind groups for an N-point FFT (N a power of two), then primes the first slot with a zero submission so the first try_collect_into never returns false.
|
||||
pub fn new(device: wgpu::Device, queue: wgpu::Queue, n: u32) -> Self {
|
||||
assert!(n.is_power_of_two() && n >= 2, "fft size must be a power of two ≥ 2");
|
||||
let log2_n = n.trailing_zeros();
|
||||
|
|
@ -98,7 +103,7 @@ impl GpuFft1D {
|
|||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
let make = |entry: &str, label: &str| {
|
||||
let make_pipeline = |entry: &'static str, label: &'static str| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
|
|
@ -108,15 +113,8 @@ impl GpuFft1D {
|
|||
cache: None,
|
||||
})
|
||||
};
|
||||
let bit_reverse = make("bit_reverse", "yr_crystals.fft.bit_reverse");
|
||||
let butterfly = make("butterfly", "yr_crystals.fft.butterfly");
|
||||
|
||||
let args_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yr_crystals.fft.args"),
|
||||
size: ARGS_BYTES,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bit_reverse = make_pipeline("bit_reverse", "yr_crystals.fft.bit_reverse");
|
||||
let butterfly = make_pipeline("butterfly", "yr_crystals.fft.butterfly");
|
||||
|
||||
let all_args_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yr_crystals.fft.all_args"),
|
||||
|
|
@ -126,26 +124,36 @@ impl GpuFft1D {
|
|||
});
|
||||
|
||||
let bytes = (n as u64) * 8;
|
||||
let make_storage = |label: &str| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
let mut slots: Vec<GpuSlot> = Vec::with_capacity(NUM_SLOTS);
|
||||
for i in 0..NUM_SLOTS {
|
||||
let data_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yr_crystals.fft.data"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let data_buf = make_storage("yr_crystals.fft.data");
|
||||
let scratch_buf = make_storage("yr_crystals.fft.scratch");
|
||||
|
||||
});
|
||||
let scratch_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yr_crystals.fft.scratch"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let args_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yr_crystals.fft.args"),
|
||||
size: ARGS_BYTES,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let staging = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("yr_crystals.fft.staging"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("yr_crystals.fft.bind_group"),
|
||||
layout: &layout,
|
||||
|
|
@ -164,24 +172,36 @@ impl GpuFft1D {
|
|||
},
|
||||
],
|
||||
});
|
||||
slots.push(GpuSlot {
|
||||
data_buf,
|
||||
scratch_buf,
|
||||
args_buf,
|
||||
staging,
|
||||
bind_group,
|
||||
staging_mapped: false,
|
||||
});
|
||||
let _ = i;
|
||||
}
|
||||
|
||||
Self {
|
||||
let mut this = Self {
|
||||
device,
|
||||
queue,
|
||||
n,
|
||||
log2_n,
|
||||
pending: None,
|
||||
staging_mapped: false,
|
||||
bit_reverse,
|
||||
butterfly,
|
||||
args_buf,
|
||||
all_args_buf,
|
||||
cached_inverse: std::cell::Cell::new(None),
|
||||
data_buf,
|
||||
scratch_buf,
|
||||
staging,
|
||||
bind_group,
|
||||
}
|
||||
slots,
|
||||
next_submit: 0,
|
||||
pending: VecDeque::with_capacity(NUM_SLOTS),
|
||||
};
|
||||
|
||||
// prime: queue one zero submission so the first real call's collect always returns something (the zero result) instead of None. only one prime is needed for the lag pattern; the other slot stays available for the first real submit.
|
||||
let zero = vec![Complex64::new(0.0, 0.0); n as usize];
|
||||
this.submit_forward(&zero);
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
/// transform length in complex samples.
|
||||
|
|
@ -189,7 +209,7 @@ impl GpuFft1D {
|
|||
self.n as usize
|
||||
}
|
||||
|
||||
/// queues a forward transform of the input buffer.
|
||||
/// queues a forward transform of the input buffer onto the next slot.
|
||||
pub fn submit_forward(&mut self, input: &[Complex64]) {
|
||||
self.submit(input, false);
|
||||
}
|
||||
|
|
@ -200,23 +220,29 @@ impl GpuFft1D {
|
|||
self.submit(input, true);
|
||||
}
|
||||
|
||||
/// drains the most recently submitted transform into the output slice, returning false if nothing pending.
|
||||
/// drains the oldest pending submission into the output slice. returns false only when nothing is pending (the prime guarantees this never happens in steady state).
|
||||
pub fn try_collect_into(&mut self, out: &mut [Complex64]) -> bool {
|
||||
debug_assert_eq!(out.len(), self.n as usize);
|
||||
let Some(_sub) = self.pending.take() else {
|
||||
let Some((slot_idx, sub_idx)) = self.pending.pop_front() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
// wait specifically for OUR submission, not the device's most recent globally. this keeps the wait deterministic when other rayon threads and the iced renderer are also submitting to the same device.
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: Some(sub_idx),
|
||||
timeout: None,
|
||||
});
|
||||
|
||||
let slice = self.staging.slice(..);
|
||||
let slot = &mut self.slots[slot_idx];
|
||||
let slice = slot.staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
// drive the map callback. submission is already complete, so this returns quickly.
|
||||
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
rx.recv().expect("map_async result").expect("map ok");
|
||||
self.staging_mapped = true;
|
||||
slot.staging_mapped = true;
|
||||
|
||||
{
|
||||
let view = slice.get_mapped_range();
|
||||
|
|
@ -225,25 +251,41 @@ impl GpuFft1D {
|
|||
out[i] = Complex64::new(c[0] as f64, c[1] as f64);
|
||||
}
|
||||
}
|
||||
self.staging.unmap();
|
||||
self.staging_mapped = false;
|
||||
slot.staging.unmap();
|
||||
slot.staging_mapped = false;
|
||||
true
|
||||
}
|
||||
|
||||
/// drains every pending submission without reading its staging buffer, then re-primes with a single zero submission. used when processor state is cleared so the next get_spectrum sees a fresh pipeline instead of a stale frame from before the reset.
|
||||
pub fn reset(&mut self) {
|
||||
while let Some((slot_idx, sub_idx)) = self.pending.pop_front() {
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: Some(sub_idx),
|
||||
timeout: None,
|
||||
});
|
||||
let slot = &mut self.slots[slot_idx];
|
||||
if slot.staging_mapped {
|
||||
slot.staging.unmap();
|
||||
slot.staging_mapped = false;
|
||||
}
|
||||
}
|
||||
self.next_submit = 0;
|
||||
let zero = vec![Complex64::new(0.0, 0.0); self.n as usize];
|
||||
self.submit_forward(&zero);
|
||||
}
|
||||
|
||||
/// rebuilds the per-pass args table for the requested direction, skipping if already cached.
|
||||
fn ensure_all_args(&self, inverse: bool) {
|
||||
if self.cached_inverse.get() == Some(inverse) {
|
||||
return;
|
||||
}
|
||||
let mut all = Vec::with_capacity((self.log2_n + 1) as usize);
|
||||
|
||||
all.push(Args {
|
||||
n: self.n,
|
||||
log2_n: self.log2_n,
|
||||
stride: 1,
|
||||
inverse: inverse as u32,
|
||||
});
|
||||
|
||||
let mut s = 1u32;
|
||||
while s < self.n {
|
||||
all.push(Args {
|
||||
|
|
@ -259,23 +301,32 @@ impl GpuFft1D {
|
|||
self.cached_inverse.set(Some(inverse));
|
||||
}
|
||||
|
||||
/// uploads input, dispatches bit-reversal and log2(N) butterfly passes, and queues the readback copy.
|
||||
/// uploads input to the next slot, dispatches bit-reversal + log2(N) butterfly passes against that slot's exclusive resources, queues the readback copy, and records the submission index for later collection.
|
||||
fn submit(&mut self, input: &[Complex64], inverse: bool) {
|
||||
debug_assert_eq!(input.len(), self.n as usize);
|
||||
self.ensure_all_args(inverse);
|
||||
|
||||
if self.staging_mapped {
|
||||
self.staging.unmap();
|
||||
self.staging_mapped = false;
|
||||
}
|
||||
self.pending = None;
|
||||
let slot_idx = self.next_submit;
|
||||
self.next_submit = (self.next_submit + 1) % self.slots.len();
|
||||
|
||||
let groups_n = (self.n + 63) / 64;
|
||||
let groups_half = (self.n / 2 + 63) / 64;
|
||||
let log2_n = self.log2_n;
|
||||
let n_bytes = (self.n as u64) * 8;
|
||||
|
||||
let mut input_f32: Vec<[f32; 2]> = Vec::with_capacity(self.n as usize);
|
||||
for c in input.iter() {
|
||||
input_f32.push([c.re as f32, c.im as f32]);
|
||||
}
|
||||
|
||||
let slot = &mut self.slots[slot_idx];
|
||||
if slot.staging_mapped {
|
||||
slot.staging.unmap();
|
||||
slot.staging_mapped = false;
|
||||
}
|
||||
|
||||
self.queue
|
||||
.write_buffer(&self.data_buf, 0, bytemuck::cast_slice(&input_f32));
|
||||
.write_buffer(&slot.data_buf, 0, bytemuck::cast_slice(&input_f32));
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
|
|
@ -283,40 +334,31 @@ impl GpuFft1D {
|
|||
label: Some("yr_crystals.fft.encoder"),
|
||||
});
|
||||
|
||||
let groups_n = (self.n + 63) / 64;
|
||||
let groups_half = (self.n / 2 + 63) / 64;
|
||||
|
||||
encoder.copy_buffer_to_buffer(&self.all_args_buf, 0, &self.args_buf, 0, ARGS_BYTES);
|
||||
encoder.copy_buffer_to_buffer(&self.all_args_buf, 0, &slot.args_buf, 0, ARGS_BYTES);
|
||||
{
|
||||
let mut p = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("yr_crystals.fft.bit_reverse"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
p.set_pipeline(&self.bit_reverse);
|
||||
p.set_bind_group(0, &self.bind_group, &[]);
|
||||
p.set_bind_group(0, &slot.bind_group, &[]);
|
||||
p.dispatch_workgroups(groups_n, 1, 1);
|
||||
}
|
||||
|
||||
for pass_idx in 1..=self.log2_n {
|
||||
for pass_idx in 1..=log2_n {
|
||||
let off = (pass_idx as u64) * ARGS_BYTES;
|
||||
encoder.copy_buffer_to_buffer(&self.all_args_buf, off, &self.args_buf, 0, ARGS_BYTES);
|
||||
encoder.copy_buffer_to_buffer(&self.all_args_buf, off, &slot.args_buf, 0, ARGS_BYTES);
|
||||
let mut p = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("yr_crystals.fft.butterfly"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
p.set_pipeline(&self.butterfly);
|
||||
p.set_bind_group(0, &self.bind_group, &[]);
|
||||
p.set_bind_group(0, &slot.bind_group, &[]);
|
||||
p.dispatch_workgroups(groups_half, 1, 1);
|
||||
}
|
||||
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&self.scratch_buf,
|
||||
0,
|
||||
&self.staging,
|
||||
0,
|
||||
(self.n as u64) * 8,
|
||||
);
|
||||
encoder.copy_buffer_to_buffer(&slot.scratch_buf, 0, &slot.staging, 0, n_bytes);
|
||||
let sub = self.queue.submit(std::iter::once(encoder.finish()));
|
||||
self.pending = Some(sub);
|
||||
self.pending.push_back((slot_idx, sub));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/ios.rs
25
src/ios.rs
|
|
@ -357,6 +357,31 @@ pub extern "C" fn viewport_set_capture_metadata(
|
|||
h.set_capture_metadata(title, artist, position_ms, duration_ms);
|
||||
}
|
||||
|
||||
/// drains the pending Picture-in-Picture entry-request flag set by Message::EnterPip.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn viewport_take_pending_pip_request(handle: *mut ViewportHandle) -> bool {
|
||||
let Some(h) = (unsafe { handle.as_mut() }) else { return false };
|
||||
h.take_pending_pip_request()
|
||||
}
|
||||
|
||||
/// writes the latest left-channel bin dB values into a caller-provided float buffer. returns the number of values actually written.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn viewport_get_pip_snapshot(
|
||||
handle: *mut ViewportHandle,
|
||||
out: *mut f32,
|
||||
max_len: usize,
|
||||
) -> usize {
|
||||
let Some(h) = (unsafe { handle.as_mut() }) else { return 0 };
|
||||
if out.is_null() || max_len == 0 { return 0 }
|
||||
let frames = h.frame_data_snapshot();
|
||||
let Some(frame) = frames.first() else { return 0 };
|
||||
let db = &frame.db;
|
||||
let n = db.len().min(max_len);
|
||||
let slice = unsafe { std::slice::from_raw_parts_mut(out, n) };
|
||||
slice[..n].copy_from_slice(&db[..n]);
|
||||
n
|
||||
}
|
||||
|
||||
/// serializes both settings slots as JSON. caller must release the returned pointer via viewport_free_string.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn viewport_get_settings_json(handle: *mut ViewportHandle) -> *mut c_char {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ pub mod viewport;
|
|||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
pub mod shell;
|
||||
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
pub mod desktop_capture;
|
||||
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
pub mod devices;
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios;
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,17 @@ impl Processor {
|
|||
}
|
||||
}
|
||||
|
||||
/// zeros the analytic-signal buffer, drops the rolling history, and resets the gpu pipeline. cheap reset that doesn't touch fft plans.
|
||||
pub fn clear_state(&mut self) {
|
||||
for c in self.buffer.iter_mut() {
|
||||
*c = Complex64::new(0.0, 0.0);
|
||||
}
|
||||
self.history.clear();
|
||||
if let Some(gpu) = self.gpu_fft.as_mut() {
|
||||
gpu.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// rebuilds fft plans, the blackman-harris window, and the working buffer at the requested transform length.
|
||||
pub fn set_frame_size(&mut self, size: usize) {
|
||||
if self.frame_size == size {
|
||||
|
|
@ -172,11 +183,16 @@ impl Processor {
|
|||
}
|
||||
|
||||
/// shifts an incoming chunk into the tail of the analytic-signal buffer, evicting the head.
|
||||
/// when the chunk is larger than the frame, only the latest frame_size samples are retained.
|
||||
pub fn push_data(&mut self, data: &[Complex64]) {
|
||||
let n = self.frame_size;
|
||||
if data.len() == n {
|
||||
self.buffer.copy_from_slice(data);
|
||||
} else if data.len() < n {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if data.len() >= n {
|
||||
let start = data.len() - n;
|
||||
self.buffer.copy_from_slice(&data[start..]);
|
||||
} else {
|
||||
let drop = data.len();
|
||||
self.buffer.copy_within(drop.., 0);
|
||||
let tail = n - drop;
|
||||
|
|
@ -208,10 +224,10 @@ impl Processor {
|
|||
None
|
||||
};
|
||||
|
||||
// lag-pattern gpu read: collect the previously-queued submission's result, then queue this frame's work. cpu and gpu overlap across the rayon scope so high call rates (small hop) don't serialize on per-call wait.
|
||||
let gpu_work = if blend > 0.0 {
|
||||
let gpu = self.gpu_fft.as_mut().expect("gpu fft plan");
|
||||
let mut prev = vec![Complex64::new(0.0, 0.0); n];
|
||||
// collect lags one frame behind the cpu path.
|
||||
let got_prev = gpu.try_collect_into(&mut prev);
|
||||
gpu.submit_forward(&work_template);
|
||||
if got_prev {
|
||||
|
|
@ -220,7 +236,6 @@ impl Processor {
|
|||
None
|
||||
}
|
||||
} else {
|
||||
|
||||
let gpu = self.gpu_fft.as_mut().expect("gpu fft plan");
|
||||
let mut discard = vec![Complex64::new(0.0, 0.0); n];
|
||||
let _ = gpu.try_collect_into(&mut discard);
|
||||
|
|
|
|||
34
src/shell.rs
34
src/shell.rs
|
|
@ -8,6 +8,7 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
|||
use winit::keyboard::{Key, ModifiersState, NamedKey};
|
||||
use winit::window::{Window, WindowAttributes, WindowId};
|
||||
|
||||
use crate::desktop_capture::DesktopCapture;
|
||||
use crate::viewport::ViewportHandle;
|
||||
|
||||
const DEFAULT_LOGICAL: (u32, u32) = (1100, 700);
|
||||
|
|
@ -19,6 +20,7 @@ pub struct ShellApp {
|
|||
handle: Option<ViewportHandle>,
|
||||
modifiers: ModifiersState,
|
||||
last_cursor: PhysicalPosition<f64>,
|
||||
capture: Option<DesktopCapture>,
|
||||
}
|
||||
|
||||
impl Default for ShellApp {
|
||||
|
|
@ -28,6 +30,7 @@ impl Default for ShellApp {
|
|||
handle: None,
|
||||
modifiers: ModifiersState::empty(),
|
||||
last_cursor: PhysicalPosition::new(0.0, 0.0),
|
||||
capture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -184,15 +187,44 @@ impl ApplicationHandler for ShellApp {
|
|||
let Some(handle) = self.handle.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
match handle.take_pending_capture_action() {
|
||||
1 => {
|
||||
if self.capture.is_none() {
|
||||
let pusher = handle.pcm_sender();
|
||||
let device = handle.selected_input_device().map(|s| s.to_string());
|
||||
match DesktopCapture::start_with_device(pusher, device.as_deref()) {
|
||||
Ok(cap) => self.capture = Some(cap),
|
||||
Err(e) => eprintln!("yr_crystals: capture start failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
2 => self.capture = None,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if handle.take_pending_rebuild_capture() && self.capture.is_some() {
|
||||
self.capture = None;
|
||||
let pusher = handle.pcm_sender();
|
||||
let device = handle.selected_input_device().map(|s| s.to_string());
|
||||
match DesktopCapture::start_with_device(pusher, device.as_deref()) {
|
||||
Ok(cap) => self.capture = Some(cap),
|
||||
Err(e) => eprintln!("yr_crystals: capture rebuild failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let playing = handle
|
||||
.state
|
||||
.engine
|
||||
.as_ref()
|
||||
.map(|e| e.is_playing())
|
||||
.unwrap_or(false);
|
||||
let in_capture =
|
||||
handle.state.playback_mode == crate::ui::app::PlaybackMode::Capture;
|
||||
let animating = playing
|
||||
|| handle.state.track_loading
|
||||
|| handle.state.library_progress.is_some();
|
||||
|| handle.state.library_progress.is_some()
|
||||
|| in_capture;
|
||||
if animating {
|
||||
window.request_redraw();
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
|
|
|||
|
|
@ -126,6 +126,21 @@ pub struct App {
|
|||
|
||||
/// show_settings copy bridging the middle-tap collapse cycle.
|
||||
pub saved_show_settings: bool,
|
||||
|
||||
/// selected output device name on desktop (None = system default).
|
||||
pub output_device: Option<String>,
|
||||
|
||||
/// selected input device name on desktop (None = system default).
|
||||
pub input_device: Option<String>,
|
||||
|
||||
/// cached list of output device names refreshed on demand.
|
||||
pub output_devices: Vec<String>,
|
||||
|
||||
/// cached list of input device names refreshed on demand.
|
||||
pub input_devices: Vec<String>,
|
||||
|
||||
/// pending flag drained by the shell to rebuild the capture stream after an input-device change.
|
||||
pub pending_rebuild_capture: bool,
|
||||
}
|
||||
|
||||
/// every visualizer toggle, slider value, and DSP parameter the settings panel exposes.
|
||||
|
|
@ -149,6 +164,15 @@ pub struct Settings {
|
|||
|
||||
/// fraction of FFT work routed to the GPU pipeline, blended against the CPU result.
|
||||
pub gpu_blend: f32,
|
||||
|
||||
/// dB threshold gating live-mode PCM chunks. chunks below this RMS are replaced with silence.
|
||||
#[serde(default = "default_noise_gate_db")]
|
||||
pub noise_gate_db: f32,
|
||||
}
|
||||
|
||||
/// default noise-gate threshold used when older settings JSON omits the field.
|
||||
fn default_noise_gate_db() -> f32 {
|
||||
-60.0
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
|
|
@ -170,6 +194,7 @@ impl Default for Settings {
|
|||
detail: 50,
|
||||
strength: 0.0,
|
||||
gpu_blend: 0.7,
|
||||
noise_gate_db: default_noise_gate_db(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +245,10 @@ pub enum Message {
|
|||
SetDetail(i32),
|
||||
SetStrength(f32),
|
||||
SetGpuBlend(f32),
|
||||
SetNoiseGate(f32),
|
||||
SetOutputDevice(Option<String>),
|
||||
SetInputDevice(Option<String>),
|
||||
RefreshDeviceList,
|
||||
PickedFolder(PathBuf),
|
||||
PickedFile(PathBuf),
|
||||
PickedFiles(Vec<PathBuf>),
|
||||
|
|
@ -273,7 +302,14 @@ impl App {
|
|||
worker.set_dsp_params(settings.fft as usize, settings.hop as usize);
|
||||
worker.set_smoothing(settings.granularity, settings.detail, settings.strength);
|
||||
worker.set_gpu_blend(settings.gpu_blend);
|
||||
worker.set_noise_gate(settings.noise_gate_db);
|
||||
let library_worker = LibraryWorker::spawn();
|
||||
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
let (output_devices, input_devices) =
|
||||
(crate::devices::list_outputs(), crate::devices::list_inputs());
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let (output_devices, input_devices) = (Vec::new(), Vec::new());
|
||||
Self {
|
||||
library: Library::default(),
|
||||
selected_track: None,
|
||||
|
|
@ -310,6 +346,11 @@ impl App {
|
|||
restore_sidebar_scroll: false,
|
||||
restore_settings_scroll: false,
|
||||
saved_show_settings: false,
|
||||
output_device: None,
|
||||
input_device: None,
|
||||
output_devices,
|
||||
input_devices,
|
||||
pending_rebuild_capture: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +383,7 @@ impl App {
|
|||
self.settings.strength,
|
||||
);
|
||||
self.worker.set_gpu_blend(self.settings.gpu_blend);
|
||||
self.worker.set_noise_gate(self.settings.noise_gate_db);
|
||||
}
|
||||
|
||||
/// replaces the active settings slot with the platform defaults for the current mode and re-applies them.
|
||||
|
|
@ -474,6 +516,28 @@ impl App {
|
|||
v
|
||||
}
|
||||
|
||||
/// drains the pending capture-rebuild flag set after an input-device change while in capture mode.
|
||||
pub fn take_pending_rebuild_capture(&mut self) -> bool {
|
||||
let v = self.pending_rebuild_capture;
|
||||
self.pending_rebuild_capture = false;
|
||||
v
|
||||
}
|
||||
|
||||
/// rebuilds the AudioEngine against the currently-selected output device and reloads the active track.
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
pub fn rebuild_audio_engine(&mut self) {
|
||||
let name = self.output_device.as_deref();
|
||||
match crate::engine::AudioEngine::with_output_device(name) {
|
||||
Ok(eng) => {
|
||||
self.engine = Some(eng);
|
||||
if let Some(idx) = self.selected_track {
|
||||
self.load_index(idx);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("yr_crystals: rebuild engine failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// returns the picker flag and clears the slot in one step.
|
||||
pub fn take_pending_pick(&mut self) -> u8 {
|
||||
let p = self.pending_pick;
|
||||
|
|
@ -816,6 +880,7 @@ impl App {
|
|||
| Message::SetDetail(_)
|
||||
| Message::SetStrength(_)
|
||||
| Message::SetGpuBlend(_)
|
||||
| Message::SetNoiseGate(_)
|
||||
| Message::ResetSettings,
|
||||
);
|
||||
match msg {
|
||||
|
|
@ -1001,6 +1066,28 @@ impl App {
|
|||
self.settings.gpu_blend = v.clamp(0.0, 1.0);
|
||||
self.worker.set_gpu_blend(self.settings.gpu_blend);
|
||||
}
|
||||
Message::SetNoiseGate(v) => {
|
||||
self.settings.noise_gate_db = v.clamp(-100.0, 0.0);
|
||||
self.worker.set_noise_gate(self.settings.noise_gate_db);
|
||||
}
|
||||
Message::SetOutputDevice(name) => {
|
||||
self.output_device = name;
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
self.rebuild_audio_engine();
|
||||
}
|
||||
Message::SetInputDevice(name) => {
|
||||
self.input_device = name;
|
||||
if self.playback_mode == PlaybackMode::Capture {
|
||||
self.pending_rebuild_capture = true;
|
||||
}
|
||||
}
|
||||
Message::RefreshDeviceList => {
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
{
|
||||
self.output_devices = crate::devices::list_outputs();
|
||||
self.input_devices = crate::devices::list_inputs();
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings_changed {
|
||||
self.pending_persist_settings = true;
|
||||
|
|
|
|||
113
src/ui/player.rs
113
src/ui/player.rs
|
|
@ -8,6 +8,8 @@ use iced_widget::{
|
|||
checkbox, column, container, image, lazy, mouse_area, progress_bar, row, scrollable, shader,
|
||||
slider, stack, svg, text, Space,
|
||||
};
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
use iced_widget::pick_list;
|
||||
|
||||
use crate::visualizer::{VisualizerProgram, VizParams};
|
||||
|
||||
|
|
@ -517,11 +519,32 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere
|
|||
format!("{:.2}", s.gpu_blend),
|
||||
Message::SetGpuBlend,
|
||||
),
|
||||
Space::new().height(Length::Fixed(8.0)),
|
||||
section_label("capture noise gate"),
|
||||
slider_row(
|
||||
"threshold",
|
||||
s.noise_gate_db,
|
||||
-100.0..=0.0,
|
||||
1.0,
|
||||
format!("{:.0} dB", s.noise_gate_db),
|
||||
Message::SetNoiseGate,
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.padding(Padding::from(16))
|
||||
.width(Length::Fixed(SETTINGS_W));
|
||||
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
let body = body
|
||||
.push(Space::new().height(Length::Fixed(8.0)))
|
||||
.push(section_label("audio devices"))
|
||||
.push(device_row("output", &app.output_devices, app.output_device.as_deref(), Message::SetOutputDevice))
|
||||
.push(device_row("input", &app.input_devices, app.input_device.as_deref(), Message::SetInputDevice))
|
||||
.push(row![
|
||||
Space::new().width(Length::Fill),
|
||||
chip_button("Refresh devices", Message::RefreshDeviceList),
|
||||
].padding(Padding::from([4, 0])));
|
||||
|
||||
let scroll = scrollable(body)
|
||||
.id(settings_scroll_id())
|
||||
.on_scroll(|vp| Message::SettingsScrolled(vp.absolute_offset()))
|
||||
|
|
@ -609,6 +632,66 @@ where
|
|||
)
|
||||
}
|
||||
|
||||
/// label + pick_list pair for selecting a sound device by name, with "System default" as the first option.
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
fn device_row<'a, F>(
|
||||
label: &'a str,
|
||||
available: &[String],
|
||||
selected_name: Option<&str>,
|
||||
on_change: F,
|
||||
) -> Element<'a, Message, Theme, iced_wgpu::Renderer>
|
||||
where
|
||||
F: 'a + Fn(Option<String>) -> Message,
|
||||
{
|
||||
let mut options: Vec<DeviceChoice> = Vec::with_capacity(available.len() + 1);
|
||||
options.push(DeviceChoice::Default);
|
||||
for name in available {
|
||||
options.push(DeviceChoice::Named(name.clone()));
|
||||
}
|
||||
let selected = match selected_name {
|
||||
Some(s) => DeviceChoice::Named(s.to_string()),
|
||||
None => DeviceChoice::Default,
|
||||
};
|
||||
let picker = pick_list(options, Some(selected), move |choice| {
|
||||
on_change(match choice {
|
||||
DeviceChoice::Default => None,
|
||||
DeviceChoice::Named(s) => Some(s),
|
||||
})
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.text_size(13);
|
||||
|
||||
container(
|
||||
row![
|
||||
container(text(label).size(13).color(palette::text_dim()))
|
||||
.width(Length::Fixed(110.0)),
|
||||
picker,
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(iced_wgpu::core::Alignment::Center),
|
||||
)
|
||||
.height(Length::Fixed(40.0))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// device pick_list option: either the system default or a specific named device.
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum DeviceChoice {
|
||||
Default,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
|
||||
impl std::fmt::Display for DeviceChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Default => write!(f, "System default"),
|
||||
Self::Named(s) => f.write_str(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// label + checkbox pair used by every boolean setting.
|
||||
fn toggle_row<'a, F>(
|
||||
label: &'a str,
|
||||
|
|
@ -741,12 +824,15 @@ fn capture_no_session_row<'a>(_app: &App) -> Element<'a, Message, Theme, iced_wg
|
|||
let hint = text("Play something nearby through your speaker to see it react.")
|
||||
.size(13)
|
||||
.color(palette::text_dim());
|
||||
row![
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let row = row![
|
||||
hint,
|
||||
Space::new().width(Length::Fill),
|
||||
chip_button("PiP", Message::EnterPip),
|
||||
]
|
||||
.padding(Padding::from([0, 16]))
|
||||
];
|
||||
#[cfg(not(any(target_os = "ios", target_os = "android")))]
|
||||
let row = row![hint, Space::new().width(Length::Fill)];
|
||||
row.padding(Padding::from([0, 16]))
|
||||
.align_y(iced_wgpu::core::Alignment::Center)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
|
|
@ -786,7 +872,8 @@ fn capture_session_row(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rend
|
|||
.size(11)
|
||||
.color(palette::text_dim());
|
||||
|
||||
row![
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let row = row![
|
||||
meta,
|
||||
Space::new().width(Length::Fixed(12.0)),
|
||||
prev,
|
||||
|
|
@ -800,8 +887,22 @@ fn capture_session_row(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rend
|
|||
pos_label,
|
||||
Space::new().width(Length::Fixed(12.0)),
|
||||
chip_button("PiP", Message::EnterPip),
|
||||
]
|
||||
.padding(Padding::from([0, 16]))
|
||||
];
|
||||
#[cfg(not(any(target_os = "ios", target_os = "android")))]
|
||||
let row = row![
|
||||
meta,
|
||||
Space::new().width(Length::Fixed(12.0)),
|
||||
prev,
|
||||
Space::new().width(Length::Fixed(6.0)),
|
||||
play,
|
||||
Space::new().width(Length::Fixed(6.0)),
|
||||
next,
|
||||
Space::new().width(Length::Fixed(16.0)),
|
||||
scrub,
|
||||
Space::new().width(Length::Fixed(12.0)),
|
||||
pos_label,
|
||||
];
|
||||
row.padding(Padding::from([0, 16]))
|
||||
.align_y(iced_wgpu::core::Alignment::Center)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
|
|
|
|||
|
|
@ -397,6 +397,11 @@ impl ViewportHandle {
|
|||
self.state.take_pending_pip_request()
|
||||
}
|
||||
|
||||
/// returns a cloned handle to the most recent analyzer frame pair for read-only host inspection (PiP mirror, debug).
|
||||
pub fn frame_data_snapshot(&self) -> std::sync::Arc<Vec<crate::analyzer::FrameData>> {
|
||||
self.state.frame_data.clone()
|
||||
}
|
||||
|
||||
/// re-applies Playback capture mode onto a freshly created viewport without firing the start action.
|
||||
pub fn restore_capture_session(&mut self) {
|
||||
self.state.restore_capture_session();
|
||||
|
|
@ -479,6 +484,16 @@ impl ViewportHandle {
|
|||
pub fn take_pending_capture_seek(&mut self) -> f32 {
|
||||
self.state.take_pending_capture_seek()
|
||||
}
|
||||
|
||||
/// drains the pending capture-rebuild flag set after an input-device change.
|
||||
pub fn take_pending_rebuild_capture(&mut self) -> bool {
|
||||
self.state.take_pending_rebuild_capture()
|
||||
}
|
||||
|
||||
/// returns the currently-selected input device name, or None for the system default.
|
||||
pub fn selected_input_device(&self) -> Option<&str> {
|
||||
self.state.input_device.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// builds a wgpu instance restricted to the platform's preferred backend and obtains a surface from the caller.
|
||||
|
|
|
|||
Loading…
Reference in New Issue