Umsci
Upmix Spatial Control Interface — OCA/OCP.1 spatial audio utility
Loading...
Searching...
No Matches
UmsciUpmixIndicatorPaintNControlComponent.cpp
Go to the documentation of this file.
1/* Copyright (c) 2026, Christian Ahrens
2 *
3 * This file is part of Umsci <https://github.com/ChristianAhrens/Umsci>
4 *
5 * This tool is free software; you can redistribute it and/or modify it under
6 * the terms of the GNU Lesser General Public License version 3.0 as published
7 * by the Free Software Foundation.
8 *
9 * This tool is distributed in the hope that it will be useful, but WITHOUT
10 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
12 * details.
13 *
14 * You should have received a copy of the GNU Lesser General Public License
15 * along with this tool; if not, write to the Free Software Foundation, Inc.,
16 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17 */
18
20
21#include <CustomLookAndFeel.h>
22
23
25 : UmsciPaintNControlComponentBase(), JUCEAppBasics::TwoDFieldBase()
26{
27 setChannelConfiguration(juce::AudioChannelSet::create7point1point4());
28}
29
33
35{
36 auto indicatorColour = getLookAndFeel().findColour(JUCEAppBasics::CustomLookAndFeel::ColourIds::MeteringRmsColourId);
37 auto labelColour = getLookAndFeel().findColour(juce::ResizableWindow::backgroundColourId);
38 auto font = juce::Font(juce::FontOptions(m_subCircleRadius * 1.1f, juce::Font::plain));
39 g.setFont(font);
40
41 auto opacity = (isTimerRunning() && !m_flashState) ? 0.25f : 1.0f;
42
43 // draw floor channel ring
44 g.setColour(indicatorColour);
45 g.setOpacity(opacity);
46 g.fillPath(m_upmixIndicator);
47
48 g.setColour(labelColour);
49 g.setOpacity(opacity);
50 for (const auto& rcp : m_renderedFloorPositions)
51 {
52 auto labelBounds = juce::Rectangle<float>(m_subCircleRadius * 2.0f, m_subCircleRadius * 2.0f)
53 .withCentre(rcp.screenPos);
54 g.drawFittedText(rcp.label, labelBounds.toNearestInt(), juce::Justification::centred, 1);
55 }
56
57 // draw height channel ring
58 g.setColour(indicatorColour);
59 g.setOpacity(opacity);
60 g.fillPath(m_upmixHeightIndicator);
61
62 g.setColour(labelColour);
63 g.setOpacity(opacity);
64 for (const auto& rcp : m_renderedHeightPositions)
65 {
66 auto labelBounds = juce::Rectangle<float>(m_subCircleRadius * 2.0f, m_subCircleRadius * 2.0f)
67 .withCentre(rcp.screenPos);
68 g.drawFittedText(rcp.label, labelBounds.toNearestInt(), juce::Justification::centred, 1);
69 }
70
71 // draw height annotation in lower-right corner
72 if (!m_renderedFloorPositions.empty())
73 {
74 g.setFont(juce::Font(juce::FontOptions(12.0f * getControlsSizeMultiplier(), juce::Font::plain)));
75 g.setColour(indicatorColour);
76 g.setOpacity(opacity);
77
78 auto bounds = getLocalBounds().toFloat();
80 auto annotationWidth = bounds.getWidth() * 0.25f;
81 auto margin = 4.0f;
82
83 if (!m_renderedHeightPositions.empty())
84 {
85 auto heightLine = juce::Rectangle<float>(annotationWidth, lineHeight)
86 .withBottomY(bounds.getBottom() - margin)
87 .withRightX(bounds.getRight() - margin);
88 auto effectiveHeightZ = m_speakersRealBoundingCube[5]
89 + (m_speakersRealBoundingCube[5] - m_speakersRealBoundingCube[2]) * m_boundingFitFactor;
90 g.drawFittedText(juce::String("Height: ") + juce::String(effectiveHeightZ, 2) + juce::String(" m"),
91 heightLine.toNearestInt(), juce::Justification::bottomRight, 1);
92
93 auto floorLine = heightLine.withBottomY(heightLine.getY());
94 g.drawFittedText(juce::String("Normal: 1.20 m"),
95 floorLine.toNearestInt(), juce::Justification::bottomRight, 1);
96 }
97 else
98 {
99 auto floorLine = juce::Rectangle<float>(annotationWidth, lineHeight)
100 .withBottomY(bounds.getBottom() - margin)
101 .withRightX(bounds.getRight() - margin);
102 g.drawFittedText(juce::String("Normal: 1.20 m"),
103 floorLine.toNearestInt(), juce::Justification::bottomRight, 1);
104 }
105 }
106
107 // draw center position handle and angle stretch handle (paths prerendered in PrerenderUpmixIndicatorInBounds)
108 g.setColour(indicatorColour);
109 g.setOpacity(opacity);
110 g.fillPath(m_centerHandlePath);
111 g.fillPath(m_stretchHandlePath);
112
113 // draw re-fit button in upper-right corner
114 {
115 auto refitBounds = getRefitButtonBounds();
116 g.setColour(indicatorColour);
117 g.setOpacity(1.0f);
118 g.fillRect(refitBounds);
119 g.setFont(juce::Font(juce::FontOptions(11.0f * std::min(getControlsSizeMultiplier(), 1.5f), juce::Font::plain)));
120 g.setColour(labelColour);
121 g.drawFittedText("Re-fit to\nbounding cube", refitBounds.reduced(4), juce::Justification::centred, 2);
122 }
123
124 // draw hint text while flashing
125 if (isTimerRunning())
126 {
127 g.setFont(juce::Font(juce::FontOptions(16.0f, juce::Font::plain)));
128 g.setColour(indicatorColour);
129 g.setOpacity(1.0f);
130 auto hintText = m_liveMode
131 ? juce::String("External position changes detected. Double-click the upmix indicator to apply its current positions.")
132 : juce::String("Double-click the upmix indicator to change sound object positions to match it.");
133 g.drawFittedText(
134 hintText,
136 juce::Justification::centred,
137 4);
138 }
139}
140
142{
143 PrerenderUpmixIndicatorInBounds();
144}
145
146void UmsciUpmixIndicatorPaintNControlComponent::setSpeakersRealBoundingCube(const std::array<float, 6>& speakersRealBoundingCube)
147{
148 m_speakersRealBoundingCube = speakersRealBoundingCube;
149
150 PrerenderUpmixIndicatorInBounds();
151}
152
153void UmsciUpmixIndicatorPaintNControlComponent::setSourcePositions(const std::map<std::int16_t, std::array<std::float_t, 3>>& sourcePositions)
154{
155 m_sourcePositions = sourcePositions;
156
157 PrerenderUpmixIndicatorInBounds();
158}
159
160void UmsciUpmixIndicatorPaintNControlComponent::setSourcePosition(std::int16_t sourceId, const std::array<std::float_t, 3>& position)
161{
162 m_sourcePositions[sourceId] = position;
163 if (m_inhibitFlashCount > 0)
164 --m_inhibitFlashCount;
165 else
166 updateFlashState();
167}
168
170{
171 if (isTimerRunning())
172 return true; // capture all clicks in the component area while hint is visible
173
174 // stretch handle hit area — oriented bounding rectangle of the arrow shape
175 if (!m_renderedFloorPositions.empty() && m_stretchHandleTangent != juce::Point<float>{})
176 {
177 float dx = float(x) - m_stretchHandlePos.x;
178 float dy = float(y) - m_stretchHandlePos.y;
179 float tx = m_stretchHandleTangent.x;
180 float ty = m_stretchHandleTangent.y;
181 float localTangent = dx * tx + dy * ty;
182 float localRadial = dx * (-ty) + dy * tx; // (-ty, tx) is the radial unit direction
183 if (std::abs(localTangent) <= m_subCircleRadius * 1.2f
184 && std::abs(localRadial) <= m_subCircleRadius * 0.4f)
185 return true;
186 }
187
188 // center handle hit area — bounding square of the cross shape
189 {
190 float dx = float(x) - m_upmixCenter.x;
191 float dy = float(y) - m_upmixCenter.y;
192 float halfLen = m_subCircleRadius * 1.0f;
193 if (std::abs(dx) <= halfLen && std::abs(dy) <= halfLen)
194 return true;
195 }
196
197 return getRefitButtonBounds().contains(x, y)
198 || m_upmixIndicator.contains(float(x), float(y))
199 || m_upmixHeightIndicator.contains(float(x), float(y));
200}
201
203{
204 if (processPinchGesture(e, true, false)) return;
205
206 // Stretch handle takes priority over ring drags
207 if (!m_renderedFloorPositions.empty() && m_stretchHandleTangent != juce::Point<float>{})
208 {
209 float dx = e.position.x - m_stretchHandlePos.x;
210 float dy = e.position.y - m_stretchHandlePos.y;
211 float tx = m_stretchHandleTangent.x;
212 float ty = m_stretchHandleTangent.y;
213 float localTangent = dx * tx + dy * ty;
214 float localRadial = dx * (-ty) + dy * tx;
215 if (std::abs(localTangent) <= m_subCircleRadius * 1.2f
216 && std::abs(localRadial) <= m_subCircleRadius * 0.4f)
217 {
218 m_draggingStretchHandle = true;
219 m_draggingHeightRing = false;
220 m_dragStartStretch = m_upmixAngleStretch;
221 m_dragStartAngle = std::atan2(e.position.x - m_upmixCenter.x,
222 -(e.position.y - m_upmixCenter.y)) - m_upmixRot;
223 return;
224 }
225 }
226 m_draggingStretchHandle = false;
227
228 // Center handle: square hit area matching the cross bounding box
229 {
230 float dx = e.position.x - m_upmixCenter.x;
231 float dy = e.position.y - m_upmixCenter.y;
232 float halfLen = m_subCircleRadius * 1.0f;
233 if (std::abs(dx) <= halfLen && std::abs(dy) <= halfLen)
234 {
235 m_draggingCenterHandle = true;
236 m_draggingHeightRing = false;
237 m_dragStartOffsetX = m_upmixOffsetX;
238 m_dragStartOffsetY = m_upmixOffsetY;
239 m_dragStartMousePos = e.position;
240 return;
241 }
242 }
243 m_draggingCenterHandle = false;
244
245 if (getRefitButtonBounds().contains(e.getPosition()))
246 {
247 m_upmixRot = 0.0f;
248 m_upmixTrans = 1.0f;
249 m_upmixHeightTrans = 0.6f;
250 m_upmixAngleStretch = 1.0f;
251 m_upmixOffsetX = 0.0f;
252 m_upmixOffsetY = 0.0f;
253 PrerenderUpmixIndicatorInBounds();
254 if (m_liveMode)
255 {
256 for (auto const& rcp : m_renderedFloorPositions)
257 {
258 m_sourcePositions[rcp.sourceId] = rcp.realPos;
260 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
261 }
262 for (auto const& rcp : m_renderedHeightPositions)
263 {
264 m_sourcePositions[rcp.sourceId] = rcp.realPos;
266 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
267 }
268 updateFlashState();
269 }
270 repaint();
273 return;
274 }
275
276 auto dx = e.position.x - m_upmixCenter.x;
277 auto dy = e.position.y - m_upmixCenter.y;
278
279 // atan2(dx, -dy): 0 = 12 o'clock, clockwise positive — matches JUCE arc angle convention
280 m_dragStartAngle = std::atan2(dx, -dy);
281 m_dragStartDist = std::sqrt(dx * dx + dy * dy);
282 m_dragStartRot = m_upmixRot;
283 m_dragStartTrans = m_upmixTrans;
284 m_dragStartHeightTrans = m_upmixHeightTrans;
285
286 // determine whether the drag targets the height ring or the floor ring
287 m_draggingHeightRing = m_upmixHeightIndicator.contains(e.position.x, e.position.y);
288
289 DBG(juce::String(__FUNCTION__) << " rot:" << m_upmixRot
290 << " trans:" << m_upmixTrans << " heightTrans:" << m_upmixHeightTrans
291 << " heightRing:" << (int)m_draggingHeightRing);
292}
293
295{
296 if (processPinchGesture(e, false, false)) return;
297
298 if (m_draggingStretchHandle)
299 {
300 auto dx = e.position.x - m_upmixCenter.x;
301 auto dy = e.position.y - m_upmixCenter.y;
302 // Delta-drag: accumulate the angular delta from the drag-start angle so that
303 // the atan2 discontinuity at ±π never causes a snap.
304 auto currentAngle = std::atan2(dx, -dy) - m_upmixRot;
305 auto deltaAngle = currentAngle - m_dragStartAngle;
306 // Unwrap to (−π, π] so one revolution cannot produce a large jump.
307 if (deltaAngle > juce::MathConstants<float>::pi) deltaAngle -= juce::MathConstants<float>::twoPi;
308 if (deltaAngle < -juce::MathConstants<float>::pi) deltaAngle += juce::MathConstants<float>::twoPi;
309 if (m_naturalFloorMaxAngleDeg > 0.0f)
310 m_upmixAngleStretch = juce::jlimit(0.05f,
311 180.0f / m_naturalFloorMaxAngleDeg,
312 m_dragStartStretch + deltaAngle / juce::degreesToRadians(m_naturalFloorMaxAngleDeg));
313
314 PrerenderUpmixIndicatorInBounds();
315
316 if (m_liveMode)
317 {
318 for (auto const& rcp : m_renderedFloorPositions)
319 {
320 m_sourcePositions[rcp.sourceId] = rcp.realPos;
322 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
323 }
324 for (auto const& rcp : m_renderedHeightPositions)
325 {
326 m_sourcePositions[rcp.sourceId] = rcp.realPos;
328 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
329 }
330 updateFlashState();
331 }
332
333 repaint();
334 return;
335 }
336
337 if (m_draggingCenterHandle)
338 {
339 if (m_baseRadius > 0.0f)
340 {
341 m_upmixOffsetX = juce::jlimit(-2.0f, 2.0f,
342 m_dragStartOffsetX + (e.position.x - m_dragStartMousePos.x) / m_baseRadius);
343 m_upmixOffsetY = juce::jlimit(-2.0f, 2.0f,
344 m_dragStartOffsetY + (e.position.y - m_dragStartMousePos.y) / m_baseRadius);
345 }
346
347 PrerenderUpmixIndicatorInBounds();
348
349 if (m_liveMode)
350 {
351 for (auto const& rcp : m_renderedFloorPositions)
352 {
353 m_sourcePositions[rcp.sourceId] = rcp.realPos;
355 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
356 }
357 for (auto const& rcp : m_renderedHeightPositions)
358 {
359 m_sourcePositions[rcp.sourceId] = rcp.realPos;
361 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
362 }
363 updateFlashState();
364 }
365
366 repaint();
367 return;
368 }
369
370 // Ignore drags that originated on the refit button — a tiny touch/mouse slip
371 // must not bleed through into the ring rotation/scale logic.
372 if (getRefitButtonBounds().contains(e.getMouseDownPosition()))
373 return;
374
375 auto dx = e.position.x - m_upmixCenter.x;
376 auto dy = e.position.y - m_upmixCenter.y;
377
378 // tangential component: angle delta drives rotation — shared by both rings
379 m_upmixRot = m_dragStartRot + std::atan2(dx, -dy) - m_dragStartAngle;
380
381 // radial component: distance ratio drives the scale of whichever ring was grabbed
382 if (m_dragStartDist > 0.0f)
383 {
384 auto scaleFactor = std::sqrt(dx * dx + dy * dy) / m_dragStartDist;
385 if (m_draggingHeightRing)
386 m_upmixHeightTrans = juce::jlimit(0.1f, 10.0f, m_dragStartHeightTrans * scaleFactor);
387 else
388 m_upmixTrans = juce::jlimit(0.1f, 10.0f, m_dragStartTrans * scaleFactor);
389 }
390
391 DBG(juce::String(__FUNCTION__) << " rot:" << m_upmixRot
392 << " trans:" << m_upmixTrans << " heightTrans:" << m_upmixHeightTrans);
393
394 PrerenderUpmixIndicatorInBounds();
395
396 if (m_liveMode)
397 {
398 for (auto const& rcp : m_renderedFloorPositions)
399 {
400 m_sourcePositions[rcp.sourceId] = rcp.realPos;
402 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
403 }
404 for (auto const& rcp : m_renderedHeightPositions)
405 {
406 m_sourcePositions[rcp.sourceId] = rcp.realPos;
408 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
409 }
410 updateFlashState(); // re-sync: stops any flash that PrerenderUpmixIndicatorInBounds may have started
411 }
412
413 repaint();
414}
415
417{
418 if (processPinchGesture(e, false, true)) return;
419
422}
423
425{
426 if (m_upmixIndicator.contains(e.position.x, e.position.y)
427 || m_upmixHeightIndicator.contains(e.position.x, e.position.y))
428 {
429 // snap all source positions to the current ring positions
430 for (auto const& rcp : m_renderedFloorPositions)
431 {
432 m_sourcePositions[rcp.sourceId] = rcp.realPos;
434 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
435 }
436 for (auto const& rcp : m_renderedHeightPositions)
437 {
438 m_sourcePositions[rcp.sourceId] = rcp.realPos;
440 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
441 }
442 updateFlashState();
443 }
444 else
445 {
446 // Delegate to base class so empty-area double-click still resets zoom,
447 // even when the component captures all events while flashing.
449 }
450}
451
453{
454 m_flashState = !m_flashState;
455 repaint();
456}
457
458void UmsciUpmixIndicatorPaintNControlComponent::onZoomChanged()
459{
460 PrerenderUpmixIndicatorInBounds();
461 repaint();
462}
463
464void UmsciUpmixIndicatorPaintNControlComponent::PrerenderUpmixIndicatorInBounds()
465{
466 m_upmixIndicator.clear();
467 m_upmixHeightIndicator.clear();
468 m_renderedFloorPositions.clear();
469 m_renderedHeightPositions.clear();
470
471 auto speakersRealBoundingTopLeft = std::array<float, 3>{ m_speakersRealBoundingCube.at(0), m_speakersRealBoundingCube.at(1), m_speakersRealBoundingCube.at(2) };
472 auto speakersRealBoundingBottomRight = std::array<float, 3>{ m_speakersRealBoundingCube.at(3), m_speakersRealBoundingCube.at(4), m_speakersRealBoundingCube.at(5) };
474 auto upmixIndicatorBounds = speakersScreenBoundingRect.getAspectRatio() <= 1 ? speakersScreenBoundingRect.expanded(speakersScreenBoundingRect.getWidth() * m_boundingFitFactor) : speakersScreenBoundingRect.expanded(speakersScreenBoundingRect.getHeight() * m_boundingFitFactor);
475
476 std::vector<float> upmixPositionAnglesDeg;
477 std::vector<std::string> upmixPositionNames;
478 std::vector<juce::AudioChannelSet::ChannelType> upmixPositionChannelTypes;
479 std::vector<float> upmixHeightPositionAnglesDeg;
480 std::vector<std::string> upmixHeightPositionNames;
481 std::vector<juce::AudioChannelSet::ChannelType> upmixHeightPositionChannelTypes;
483 {
485 {
487 upmixPositionNames.push_back(juce::AudioChannelSet::getAbbreviatedChannelTypeName(channelType).toStdString());
489 }
491 {
493 upmixHeightPositionNames.push_back(juce::AudioChannelSet::getAbbreviatedChannelTypeName(channelType).toStdString());
495 }
496 }
497
498 // Capture the natural (pre-stretch) max floor angle for drag computation, then apply stretch.
499 if (!upmixPositionAnglesDeg.empty())
500 m_naturalFloorMaxAngleDeg = *std::max_element(upmixPositionAnglesDeg.begin(), upmixPositionAnglesDeg.end());
501 for (auto& angleDeg : upmixPositionAnglesDeg)
502 angleDeg *= m_upmixAngleStretch;
504 angleDeg *= m_upmixAngleStretch;
505
506 if (upmixIndicatorBounds.isEmpty())
507 return;
508
509 m_upmixCenter = upmixIndicatorBounds.getCentre();
510 // Use the larger screen dimension so the indicator fills the room's dominant axis rather
511 // than being constrained to the smaller one (which makes it look undersized in landscape layouts).
512 m_baseRadius = std::max(upmixIndicatorBounds.getWidth(), upmixIndicatorBounds.getHeight()) * 0.5f;
513 m_upmixCenter.x += m_upmixOffsetX * m_baseRadius;
514 m_upmixCenter.y += m_upmixOffsetY * m_baseRadius;
515 auto cx = m_upmixCenter.x;
516 auto cy = m_upmixCenter.y;
517 auto baseRadius = m_baseRadius;
518 auto radius = baseRadius * m_upmixTrans;
519 m_subCircleRadius = 15.0f * getControlsSizeMultiplier();
520 auto arcStrokeWidth = m_subCircleRadius * 0.5f;
521 auto cosRot = std::cos(m_upmixRot);
522 auto sinRot = std::sin(m_upmixRot);
523
524 // builds an open rectangle path from minDeg to maxDeg (clockwise), rotated by m_upmixRot
525 auto buildOpenRectPath = [&](float r, float minDeg, float maxDeg) -> juce::Path
526 {
527 struct Corner { float angleDeg, lx, ly; };
528 const Corner corners[4] = {
529 { 45.0f, r, -r },
530 { 135.0f, r, r },
531 { 225.0f, -r, r },
532 { 315.0f, -r, -r },
533 };
534
535 // clockwise angular distance from a normalised origin to an arbitrary angle
536 auto cwDist = [](float fromNorm, float toAngle) {
537 float toNorm = std::fmod(toAngle, 360.0f);
538 if (toNorm < 0.0f) toNorm += 360.0f;
539 float d = toNorm - fromNorm;
540 return d <= 0.0f ? d + 360.0f : d;
541 };
542
543 // project an angle onto the axis-aligned square perimeter (Chebyshev)
544 auto toLocal = [&](float angleDeg) -> juce::Point<float> {
545 auto rad = juce::degreesToRadians(angleDeg);
546 auto dx = std::sin(rad);
547 auto dy = -std::cos(rad);
548 auto t = r / std::max(std::abs(dx), std::abs(dy));
549 return { t * dx, t * dy };
550 };
551
552 // rotate a local offset by m_upmixRot and translate to screen space
553 auto toScreen = [&](juce::Point<float> local) -> juce::Point<float> {
554 return { cx + local.x * cosRot - local.y * sinRot,
555 cy + local.x * sinRot + local.y * cosRot };
556 };
557
558 float minNorm = std::fmod(minDeg, 360.0f);
559 if (minNorm < 0.0f) minNorm += 360.0f;
560 float maxDist = cwDist(minNorm, maxDeg);
561
562 // find the first corner clockwise from minNorm
563 int firstIdx = 0;
564 float firstDist = 361.0f;
565 for (int k = 0; k < 4; ++k)
566 {
567 float d = cwDist(minNorm, corners[k].angleDeg);
568 if (d < firstDist) { firstDist = d; firstIdx = k; }
569 }
570
571 juce::Path path;
572 path.startNewSubPath(toScreen(toLocal(minDeg)));
573 for (int j = 0; j < 4; ++j)
574 {
575 int k = (firstIdx + j) % 4;
577 path.lineTo(toScreen({ corners[k].lx, corners[k].ly }));
578 else
579 break;
580 }
581 path.lineTo(toScreen(toLocal(maxDeg)));
582 return path;
583 };
584
585 // build the floor channel ring
586 if (!upmixPositionAnglesDeg.empty())
587 {
588 auto minAngleDeg = *std::min_element(upmixPositionAnglesDeg.begin(), upmixPositionAnglesDeg.end());
589 auto maxAngleDeg = *std::max_element(upmixPositionAnglesDeg.begin(), upmixPositionAnglesDeg.end());
590
591 if (m_shape == IndicatorShape::Rectangle)
592 {
593 juce::PathStrokeType(arcStrokeWidth).createStrokedPath(m_upmixIndicator,
595 }
596 else
597 {
598 // build the arc segment and stroke it into a filled band in m_upmixIndicator
599 // angles follow JUCE convention: 0 = 12 o'clock, clockwise positive — matching standard audio azimuth
600 juce::Path arcPath;
601 arcPath.addCentredArc(cx, cy, radius, radius, m_upmixRot,
602 juce::degreesToRadians(minAngleDeg),
603 juce::degreesToRadians(maxAngleDeg),
604 true);
605 juce::PathStrokeType(arcStrokeWidth).createStrokedPath(m_upmixIndicator, arcPath);
606 }
607
608 // add a filled sub-circle at each position and record its data for paint() and hit-testing
609 for (size_t i = 0; i < upmixPositionAnglesDeg.size(); ++i)
610 {
611 float px, py;
612 if (m_shape == IndicatorShape::Rectangle)
613 {
614 // project base angle (no rotation) onto axis-aligned square, then rotate the result
615 auto baseAngleRad = juce::degreesToRadians(upmixPositionAnglesDeg[i]);
616 auto dx = std::sin(baseAngleRad);
617 auto dy = -std::cos(baseAngleRad);
618 auto t = radius / std::max(std::abs(dx), std::abs(dy));
619 px = cx + (t * dx) * cosRot - (t * dy) * sinRot;
620 py = cy + (t * dx) * sinRot + (t * dy) * cosRot;
621 }
622 else
623 {
624 // screen coords for arc angle θ with rotation R: x = cx + r·sin(θ+R), y = cy - r·cos(θ+R)
625 auto effectiveAngleRad = juce::degreesToRadians(upmixPositionAnglesDeg[i]) + m_upmixRot;
626 px = cx + radius * std::sin(effectiveAngleRad);
627 py = cy - radius * std::cos(effectiveAngleRad);
628 }
629
630 m_upmixIndicator.addEllipse(px - m_subCircleRadius, py - m_subCircleRadius,
631 m_subCircleRadius * 2.0f, m_subCircleRadius * 2.0f);
632
633 if (i < upmixPositionNames.size())
634 {
635 RenderedChannelPosition rcp;
636 rcp.sourceId = static_cast<std::int16_t>(
638 rcp.screenPos = juce::Point<float>(px, py);
639 rcp.realPos = GetRealCoordinateForPoint(rcp.screenPos);
640 rcp.realPos[2] = 1.2f;
641 rcp.label = juce::String(upmixPositionNames[i]);
642 m_renderedFloorPositions.push_back(rcp);
643 }
644 }
645 }
646
647 // compute stretch handle position — radially beyond the max-angle floor sub-circle
648 if (!upmixPositionAnglesDeg.empty())
649 {
650 auto maxStretchedAngleDeg = *std::max_element(upmixPositionAnglesDeg.begin(), upmixPositionAnglesDeg.end());
651 float anchorX, anchorY;
652 float rectEdgeTangentX = 0.0f, rectEdgeTangentY = 0.0f;
653 if (m_shape == IndicatorShape::Rectangle)
654 {
655 auto baseAngleRad = juce::degreesToRadians(maxStretchedAngleDeg);
656 auto dx = std::sin(baseAngleRad);
657 auto dy = -std::cos(baseAngleRad);
658 auto t = radius / std::max(std::abs(dx), std::abs(dy));
659 anchorX = cx + (t * dx) * cosRot - (t * dy) * sinRot;
660 anchorY = cy + (t * dx) * sinRot + (t * dy) * cosRot;
661 // Tangent along the rectangle edge in the unrotated frame:
662 // |dx| >= |dy| → left/right (vertical) edge → unrotated tangent (0, 1)
663 // |dy| > |dx| → top/bottom (horizontal) edge → unrotated tangent (1, 0)
664 // Then rotate by m_upmixRot into screen space.
665 float utx = (std::abs(dx) >= std::abs(dy)) ? 0.0f : 1.0f;
666 float uty = (std::abs(dx) >= std::abs(dy)) ? 1.0f : 0.0f;
669 }
670 else
671 {
672 auto effectiveAngleRad = juce::degreesToRadians(maxStretchedAngleDeg) + m_upmixRot;
673 anchorX = cx + radius * std::sin(effectiveAngleRad);
674 anchorY = cy - radius * std::cos(effectiveAngleRad);
675 }
676 float dirX = anchorX - cx;
677 float dirY = anchorY - cy;
678 float dirLen = std::sqrt(dirX * dirX + dirY * dirY);
679 if (dirLen > 0.0f)
680 {
681 float ux = dirX / dirLen;
682 float uy = dirY / dirLen;
683 m_stretchHandlePos = { anchorX + ux * m_subCircleRadius * 1.5f,
684 anchorY + uy * m_subCircleRadius * 1.5f };
685 if (m_shape == IndicatorShape::Rectangle)
686 // Tangent lies along the rectangle edge at the anchor point.
687 m_stretchHandleTangent = { rectEdgeTangentX, rectEdgeTangentY };
688 else
689 // Tangent: 90° CW from radial (ux, uy) in screen space = (uy, -ux)
690 m_stretchHandleTangent = { uy, -ux };
691 }
692 }
693
694 // build the height channel ring — same rotation, independent scale
695 if (!upmixHeightPositionAnglesDeg.empty())
696 {
697 auto heightRadius = baseRadius * m_upmixHeightTrans;
699
700 auto minHeightAngleDeg = *std::min_element(upmixHeightPositionAnglesDeg.begin(), upmixHeightPositionAnglesDeg.end());
701 auto maxHeightAngleDeg = *std::max_element(upmixHeightPositionAnglesDeg.begin(), upmixHeightPositionAnglesDeg.end());
702
703 if (m_shape == IndicatorShape::Rectangle)
704 {
705 juce::PathStrokeType(heightArcStrokeWidth).createStrokedPath(m_upmixHeightIndicator,
707 }
708 else
709 {
710 juce::Path heightArcPath;
711 heightArcPath.addCentredArc(cx, cy, heightRadius, heightRadius, m_upmixRot,
712 juce::degreesToRadians(minHeightAngleDeg),
713 juce::degreesToRadians(maxHeightAngleDeg),
714 true);
715 juce::PathStrokeType(heightArcStrokeWidth).createStrokedPath(m_upmixHeightIndicator, heightArcPath);
716 }
717
718 for (size_t i = 0; i < upmixHeightPositionAnglesDeg.size(); ++i)
719 {
720 float px, py;
721 if (m_shape == IndicatorShape::Rectangle)
722 {
723 auto baseAngleRad = juce::degreesToRadians(upmixHeightPositionAnglesDeg[i]);
724 auto dx = std::sin(baseAngleRad);
725 auto dy = -std::cos(baseAngleRad);
726 auto t = heightRadius / std::max(std::abs(dx), std::abs(dy));
727 px = cx + (t * dx) * cosRot - (t * dy) * sinRot;
728 py = cy + (t * dx) * sinRot + (t * dy) * cosRot;
729 }
730 else
731 {
732 auto effectiveAngleRad = juce::degreesToRadians(upmixHeightPositionAnglesDeg[i]) + m_upmixRot;
733 px = cx + heightRadius * std::sin(effectiveAngleRad);
734 py = cy - heightRadius * std::cos(effectiveAngleRad);
735 }
736
737 m_upmixHeightIndicator.addEllipse(px - m_subCircleRadius, py - m_subCircleRadius,
738 m_subCircleRadius * 2.0f, m_subCircleRadius * 2.0f);
739
740 if (i < upmixHeightPositionNames.size())
741 {
742 RenderedChannelPosition rcp;
743 rcp.sourceId = static_cast<std::int16_t>(
745 rcp.screenPos = juce::Point<float>(px, py);
746 rcp.realPos = GetRealCoordinateForPoint(rcp.screenPos);
747 rcp.realPos[2] = m_speakersRealBoundingCube[5]
748 + (m_speakersRealBoundingCube[5] - m_speakersRealBoundingCube[2]) * m_boundingFitFactor;
749 rcp.label = juce::String(upmixHeightPositionNames[i]);
750 m_renderedHeightPositions.push_back(rcp);
751 }
752 }
753 }
754
755 // prerender handle paths — shared builder for a single bidirectional arrow
756 auto buildBiArrowPath = [](float hcx, float hcy, float axX, float axY,
757 float halfLen, float headLen, float headWidth, float lineWidth) -> juce::Path
758 {
759 float px = -axY, py = axX; // perpendicular to axis
760
761 juce::Path shaft;
762 shaft.startNewSubPath(hcx - axX * (halfLen - headLen), hcy - axY * (halfLen - headLen));
763 shaft.lineTo (hcx + axX * (halfLen - headLen), hcy + axY * (halfLen - headLen));
764
765 juce::Path result;
766 juce::PathStrokeType(lineWidth, juce::PathStrokeType::beveled,
767 juce::PathStrokeType::square).createStrokedPath(result, shaft);
768
769 // arrowhead at positive end
770 float tipX = hcx + axX * halfLen, tipY = hcy + axY * halfLen;
771 float bpX = hcx + axX * (halfLen - headLen), bpY = hcy + axY * (halfLen - headLen);
772 result.startNewSubPath(tipX, tipY);
773 result.lineTo(bpX + px * headWidth, bpY + py * headWidth);
774 result.lineTo(bpX - px * headWidth, bpY - py * headWidth);
775 result.closeSubPath();
776
777 // arrowhead at negative end
778 float tnX = hcx - axX * halfLen, tnY = hcy - axY * halfLen;
779 float bnX = hcx - axX * (halfLen - headLen), bnY = hcy - axY * (halfLen - headLen);
780 result.startNewSubPath(tnX, tnY);
781 result.lineTo(bnX - px * headWidth, bnY - py * headWidth);
782 result.lineTo(bnX + px * headWidth, bnY + py * headWidth);
783 result.closeSubPath();
784
785 return result;
786 };
787
788 // center handle: two crossed bidirectional arrows aligned to screen axes
789 m_centerHandlePath.clear();
790 {
791 float halfLen = m_subCircleRadius * 1.0f;
792 float headLen = m_subCircleRadius * 0.45f;
793 float headWidth = m_subCircleRadius * 0.35f;
794 float lineWidth = m_subCircleRadius * 0.18f;
795 m_centerHandlePath.addPath(buildBiArrowPath(cx, cy, 1.0f, 0.0f, halfLen, headLen, headWidth, lineWidth));
796 m_centerHandlePath.addPath(buildBiArrowPath(cx, cy, 0.0f, 1.0f, halfLen, headLen, headWidth, lineWidth));
797 }
798
799 // stretch handle: single bidirectional arrow along the tangent at the arc endpoint
800 m_stretchHandlePath.clear();
801 if (!upmixPositionAnglesDeg.empty() && m_stretchHandleTangent != juce::Point<float>{})
802 {
803 float halfLen = m_subCircleRadius * 1.2f;
804 float headLen = m_subCircleRadius * 0.55f;
805 float headWidth = m_subCircleRadius * 0.4f;
806 float lineWidth = m_subCircleRadius * 0.18f;
807 m_stretchHandlePath = buildBiArrowPath(
808 m_stretchHandlePos.x, m_stretchHandlePos.y,
809 m_stretchHandleTangent.x, m_stretchHandleTangent.y,
811 }
812
813 updateFlashState();
814}
815
817{
818 m_sourceStartId = juce::jmax(1, startId);
819 PrerenderUpmixIndicatorInBounds();
820 repaint();
821}
822
824{
825 return m_sourceStartId;
826}
827
829{
830 m_liveMode = liveMode;
831}
832
834{
835 return m_liveMode;
836}
837
843
845{
846 m_shape = shape;
847 PrerenderUpmixIndicatorInBounds();
848 repaint();
849}
850
855
856void UmsciUpmixIndicatorPaintNControlComponent::setUpmixTransform(float rot, float trans, float heightTrans, float angleStretch)
857{
858 m_upmixRot = rot;
859 m_upmixTrans = trans;
860 m_upmixHeightTrans = heightTrans;
861 m_upmixAngleStretch = m_naturalFloorMaxAngleDeg > 0.0f
862 ? juce::jlimit(0.05f, 180.0f / m_naturalFloorMaxAngleDeg, angleStretch)
863 : angleStretch;
864 PrerenderUpmixIndicatorInBounds();
865 repaint();
866}
867
870float UmsciUpmixIndicatorPaintNControlComponent::getUpmixHeightTrans() const { return m_upmixHeightTrans; }
871float UmsciUpmixIndicatorPaintNControlComponent::getUpmixAngleStretch() const { return m_upmixAngleStretch; }
872
874{
875 m_upmixOffsetX = x;
876 m_upmixOffsetY = y;
877 PrerenderUpmixIndicatorInBounds();
878 repaint();
879}
880
883
885{
886 if (m_liveMode)
887 {
888 m_inhibitFlashCount += static_cast<int>(m_renderedFloorPositions.size() + m_renderedHeightPositions.size());
889 stopTimer();
890 m_flashState = false;
891 repaint();
892
893 for (auto const& rcp : m_renderedFloorPositions)
894 {
895 m_sourcePositions[rcp.sourceId] = rcp.realPos;
897 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
898 }
899 for (auto const& rcp : m_renderedHeightPositions)
900 {
901 m_sourcePositions[rcp.sourceId] = rcp.realPos;
903 onSourcePositionChanged(rcp.sourceId, rcp.realPos);
904 }
905 }
908}
909
914
915bool UmsciUpmixIndicatorPaintNControlComponent::setChannelConfiguration(const juce::AudioChannelSet& channelLayout)
916{
917 auto rVal = TwoDFieldBase::setChannelConfiguration(channelLayout);
918
919 PrerenderUpmixIndicatorInBounds();
920
921 repaint();
922
923 return rVal;
924}
925
926juce::Rectangle<int> UmsciUpmixIndicatorPaintNControlComponent::getRefitButtonBounds() const
927{
928 auto margin = 4;
929 auto buttonScale = std::min(getControlsSizeMultiplier(), 1.5f);
930 auto buttonHeight = juce::roundToInt(40.0f * buttonScale);
931 auto buttonWidth = juce::roundToInt(60.0f * buttonScale);
932 return juce::Rectangle<int>(getWidth() - buttonWidth - margin, margin, buttonWidth, buttonHeight);
933}
934
935void UmsciUpmixIndicatorPaintNControlComponent::updateFlashState()
936{
937 auto const tolerance = 0.01f;
938 bool mismatch = false;
939
940 auto checkPos = [&](const RenderedChannelPosition& rcp)
941 {
942 auto it = m_sourcePositions.find(rcp.sourceId);
943 if (it == m_sourcePositions.end())
944 {
945 mismatch = true;
946 return;
947 }
948 auto const& sp = it->second;
949 if (std::abs(sp.at(0) - rcp.realPos.at(0)) > tolerance
950 || std::abs(sp.at(1) - rcp.realPos.at(1)) > tolerance
951 || std::abs(sp.at(2) - rcp.realPos.at(2)) > tolerance)
952 mismatch = true;
953 };
954
955 for (auto const& rcp : m_renderedFloorPositions)
956 checkPos(rcp);
957 for (auto const& rcp : m_renderedHeightPositions)
958 checkPos(rcp);
959
960 if (mismatch)
961 {
962 if (!isTimerRunning())
963 startTimer(500); // 2 Hz
964 }
965 else
966 {
967 stopTimer();
968 m_flashState = false;
969 repaint();
970 }
971}
972
Abstract base class for all three overlaid visualisation layers in UmsciControlComponent.
ControlsSize
Visual size of source/speaker icons. Multiplier accessible via getControlsSizeMultiplier().
void mouseDoubleClick(const juce::MouseEvent &) override
Double-click resets zoom to 1.0 via resetZoom().
bool processPinchGesture(const juce::MouseEvent &e, bool isDown, bool isUp)
JUCE-level two-touch pinch-zoom fallback for platforms where neither mouseMagnify nor a native gestur...
std::array< float, 3 > GetRealCoordinateForPoint(const juce::Point< float > &screenPoint)
Inverse of GetPointForRealCoordinate — converts a screen pixel point back to a 3D real-world coordina...
juce::Point< float > GetPointForRealCoordinate(const std::array< float, 3 > &realCoordinate)
Converts a 3D real-world coordinate to a 2D screen pixel point.
virtual void setControlsSize(ControlsSize size)
Updates the icon size; derived classes may override to re-prerender.
float getControlsSizeMultiplier() const
Returns a multiplier (e.g. 0.5 / 1.0 / 1.5) for S/M/L icon sizes.
IndicatorShape
The geometric shape used to draw the upmix speaker ring.
void notifyTransformChanged()
Fires live-mode position callbacks and onTransformChanged after a programmatic transform change (e....
void setUpmixTransform(float rot, float trans, float heightTrans, float angleStretch=1.0f)
Applies all four transform parameters and triggers a prerender + repaint.
void triggerFlashCheck()
Checks whether the ideal ring positions diverge from the stored DS100 positions and starts the flash ...
std::function< void()> onTransformChanged
Fired whenever any transform parameter changes via an interactive drag, so UmsciControlComponent can ...
float getUpmixAngleStretch() const
Front/rear angular compression factor.
float getUpmixRot() const
Ring rotation (normalised 0–1 = 0–360°).
void setLiveMode(bool liveMode)
When true, actual DS100 positions for the upmix channels are overlaid on the ideal indicator ring so ...
void setSourcePosition(std::int16_t sourceId, const std::array< std::float_t, 3 > &position)
Updates a single source position (called on each OCP.1 notification).
void setControlsSize(ControlsSize size) override
Updates the icon size; derived classes may override to re-prerender.
void setSpeakersRealBoundingCube(const std::array< float, 6 > &speakersRealBoundingCube)
Provides the axis-aligned bounding cube of all loudspeaker positions.
float getUpmixHeightTrans() const
Height ring radius as a fraction of floor radius.
bool setChannelConfiguration(const juce::AudioChannelSet &channelLayout) override
void setSourcePositions(const std::map< std::int16_t, std::array< std::float_t, 3 > > &sourcePositions)
Provides live DS100 source positions for all upmix channels. Only rendered when m_liveMode is true.
void setSourceStartId(int startId)
Sets the first DS100 input channel (1-based) assigned to the upmix renderer.
void setUpmixOffset(float x, float y)
Sets the ring centre offset in units of base radius.
void setShape(IndicatorShape shape)
Sets the indicator ring geometry (circle or rectangle).
std::function< void(std::int16_t, std::array< std::float_t, 3 >)> onSourcePositionChanged
Fired when the user drags a source circle in live mode (pass-through from this component,...